refactor(i18n): remove C() and move intents to test-only

Breaking change: Remove C() semantic intent composition API.

- Remove C() global function and Service.C() method
- Remove IntentBuilder fluent API (I(), For(), Compose(), etc.)
- Move coreIntents from intents.go to intents_test.go (test data only)
- Remove C() from Translator interface

Replace intent-based CLI helpers with grammar composition:
- ConfirmIntent → ConfirmAction(verb, subject)
- ConfirmDangerous → ConfirmDangerousAction(verb, subject)
- QuestionIntent → QuestionAction(verb, subject)
- ChooseIntent → ChooseAction(verb, subject, items)
- ChooseMultiIntent → ChooseMultiAction(verb, subject, items)

The intent definitions now serve purely as test data to verify
the grammar engine can compose identical strings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 16:50:08 +00:00
parent 106a751511
commit 04d8772cba
12 changed files with 706 additions and 1299 deletions

View file

@ -149,42 +149,27 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
}
}
// ConfirmIntent prompts for confirmation using a semantic intent.
// The intent determines the question text, danger level, and default response.
// ConfirmAction prompts for confirmation of an action using grammar composition.
//
// if ConfirmIntent("core.delete", i18n.S("file", "config.yaml")) { ... }
func ConfirmIntent(intent string, subject *i18n.Subject, opts ...ConfirmOption) bool {
result := i18n.C(intent, subject)
// Apply intent metadata to options
if result.Meta.Dangerous {
opts = append([]ConfirmOption{Required()}, opts...)
}
if result.Meta.Default == "yes" {
opts = append([]ConfirmOption{DefaultYes()}, opts...)
}
return Confirm(result.Question, opts...)
// if ConfirmAction("delete", "config.yaml") { ... }
// if ConfirmAction("save", "changes", DefaultYes()) { ... }
func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool {
question := i18n.Title(verb) + " " + subject + "?"
return Confirm(question, opts...)
}
// ConfirmDangerous prompts for confirmation of a dangerous action.
// Shows both the question and a confirmation prompt, requiring explicit "yes".
// ConfirmDangerousAction prompts for double confirmation of a dangerous action.
// Shows initial question, then a "Really verb subject?" confirmation.
//
// if ConfirmDangerous("core.delete", i18n.S("file", "config.yaml")) { ... }
func ConfirmDangerous(intent string, subject *i18n.Subject) bool {
result := i18n.C(intent, subject)
// Show initial question
if !Confirm(result.Question, Required()) {
// if ConfirmDangerousAction("delete", "config.yaml") { ... }
func ConfirmDangerousAction(verb, subject string) bool {
question := i18n.Title(verb) + " " + subject + "?"
if !Confirm(question, Required()) {
return false
}
// For dangerous actions, show confirmation prompt
if result.Meta.Dangerous && result.Confirm != "" {
return Confirm(result.Confirm, Required())
}
return true
confirm := "Really " + verb + " " + subject + "?"
return Confirm(confirm, Required())
}
// QuestionOption configures Question behaviour.
@ -262,12 +247,12 @@ func Question(prompt string, opts ...QuestionOption) string {
}
}
// QuestionIntent prompts for text input using a semantic intent.
// QuestionAction prompts for text input using grammar composition.
//
// name := QuestionIntent("core.rename", i18n.S("file", "old.txt"))
func QuestionIntent(intent string, subject *i18n.Subject, opts ...QuestionOption) string {
result := i18n.C(intent, subject)
return Question(result.Question, opts...)
// name := QuestionAction("rename", "old.txt")
func QuestionAction(verb, subject string, opts ...QuestionOption) string {
question := i18n.Title(verb) + " " + subject + "?"
return Question(question, opts...)
}
// ChooseOption configures Choose behaviour.
@ -372,12 +357,12 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
}
}
// ChooseIntent prompts for selection using a semantic intent.
// ChooseAction prompts for selection using grammar composition.
//
// file := ChooseIntent("core.select", i18n.S("file", ""), files)
func ChooseIntent[T any](intent string, subject *i18n.Subject, items []T, opts ...ChooseOption[T]) T {
result := i18n.C(intent, subject)
return Choose(result.Question, items, opts...)
// file := ChooseAction("select", "file", files)
func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T {
question := i18n.Title(verb) + " " + subject + ":"
return Choose(question, items, opts...)
}
// ChooseMulti prompts the user to select multiple items from a list.
@ -486,12 +471,12 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
return result, nil
}
// ChooseMultiIntent prompts for multiple selections using a semantic intent.
// ChooseMultiAction prompts for multiple selections using grammar composition.
//
// files := ChooseMultiIntent("core.select", i18n.S("files", ""), files)
func ChooseMultiIntent[T any](intent string, subject *i18n.Subject, items []T, opts ...ChooseOption[T]) []T {
result := i18n.C(intent, subject)
return ChooseMulti(result.Question, items, opts...)
// files := ChooseMultiAction("select", "files", files)
func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T {
question := i18n.Title(verb) + " " + subject + ":"
return ChooseMulti(question, items, opts...)
}
// FormatAge formats a time as a human-readable age string.

View file

@ -221,91 +221,3 @@ func newTemplateData(s *Subject) templateData {
}
}
// --- Fluent Intent Builder API ---
// IntentBuilder provides a fluent API for composing semantic intents.
// Use I() to start building an intent.
//
// I("core.delete").For(S("file", path)).Question()
// I("core.save").For(S("changes", n).Count(n)).Compose()
type IntentBuilder struct {
intent string
subject *Subject
}
// I creates a new IntentBuilder for the given intent key.
// This is the entry point for the fluent intent API.
//
// I("core.delete").For(S("file", "config.yaml")).Question()
// I("core.commit").For(S("file", files).Count(len(files))).Success()
func I(intent string) *IntentBuilder {
return &IntentBuilder{intent: intent}
}
// For sets the subject for this intent.
// Returns the builder for chaining.
//
// I("core.delete").For(S("file", path))
func (b *IntentBuilder) For(subject *Subject) *IntentBuilder {
b.subject = subject
return b
}
// With is an alias for For() - sets the subject for this intent.
//
// I("core.delete").With(S("file", path))
func (b *IntentBuilder) With(subject *Subject) *IntentBuilder {
return b.For(subject)
}
// Compose returns all output forms for the intent.
// Uses the default service to resolve the intent.
//
// result := I("core.delete").For(subject).Compose()
// fmt.Println(result.Question)
func (b *IntentBuilder) Compose() *Composed {
return C(b.intent, b.subject)
}
// Question returns just the question form of the intent.
//
// question := I("core.delete").For(subject).Question()
func (b *IntentBuilder) Question() string {
return b.Compose().Question
}
// Confirm returns just the confirmation form of the intent.
//
// confirm := I("core.delete").For(subject).Confirm()
func (b *IntentBuilder) Confirm() string {
return b.Compose().Confirm
}
// Success returns just the success message form of the intent.
//
// success := I("core.delete").For(subject).Success()
func (b *IntentBuilder) Success() string {
return b.Compose().Success
}
// Failure returns just the failure message form of the intent.
//
// failure := I("core.delete").For(subject).Failure()
func (b *IntentBuilder) Failure() string {
return b.Compose().Failure
}
// Meta returns just the intent metadata.
//
// meta := I("core.delete").For(subject).Meta()
// if meta.Dangerous { ... }
func (b *IntentBuilder) Meta() IntentMeta {
return b.Compose().Meta
}
// IsDangerous returns true if the intent is marked as dangerous.
//
// if I("core.delete").IsDangerous() { ... }
func (b *IntentBuilder) IsDangerous() bool {
return b.Meta().Dangerous
}

View file

@ -4,16 +4,25 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// composeIntent executes intent templates with a subject for testing.
// This is a test helper that replicates what C() used to do.
func composeIntent(intent Intent, subject *Subject) *Composed {
data := newTemplateData(subject)
return &Composed{
Question: executeIntentTemplate(intent.Question, data),
Confirm: executeIntentTemplate(intent.Confirm, data),
Success: executeIntentTemplate(intent.Success, data),
Failure: executeIntentTemplate(intent.Failure, data),
Meta: intent.Meta,
}
}
// TestGrammarComposition_MatchesIntents verifies that the grammar engine
// can compose the same strings as the intent templates.
// This turns the intents.go file into a comprehensive test suite.
// This turns the intents definitions into a comprehensive test suite.
func TestGrammarComposition_MatchesIntents(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Test subjects for validation
subjects := []struct {
noun string
@ -34,8 +43,8 @@ func TestGrammarComposition_MatchesIntents(t *testing.T) {
for _, subj := range subjects {
subject := S(subj.noun, subj.value)
// Compose using C()
composed := svc.C(key, subject)
// Compose using intent templates directly
composed := composeIntent(intent, subject)
// Verify Success output matches ActionResult
if intent.Success != "" && intent.Meta.Verb != "" {
@ -415,11 +424,8 @@ func TestIntentConsistency(t *testing.T) {
}
}
// TestComposedVsManual compares C() output with manual grammar composition.
// TestComposedVsManual compares template output with manual grammar composition.
func TestComposedVsManual(t *testing.T) {
svc, err := New()
require.NoError(t, err)
tests := []struct {
intentKey string
noun string
@ -436,18 +442,19 @@ func TestComposedVsManual(t *testing.T) {
for _, tt := range tests {
t.Run(tt.intentKey, func(t *testing.T) {
subject := S(tt.noun, tt.value)
composed := svc.C(tt.intentKey, subject)
intent := getIntent(tt.intentKey)
require.NotNil(t, intent)
intent := coreIntents[tt.intentKey]
// Compose using intent templates
composed := composeIntent(intent, subject)
// Manual composition using grammar functions
manualSuccess := ActionResult(intent.Meta.Verb, tt.value)
manualFailure := ActionFailed(intent.Meta.Verb, tt.value)
assert.Equal(t, manualSuccess, composed.Success,
"C() Success should match ActionResult()")
"Template Success should match ActionResult()")
assert.Equal(t, manualFailure, composed.Failure,
"C() Failure should match ActionFailed()")
"Template Failure should match ActionFailed()")
})
}
}

View file

@ -223,49 +223,3 @@ func TestSubject_Formality(t *testing.T) {
})
}
func TestIntentBuilder(t *testing.T) {
// Initialize the default service for tests
_ = Init()
t.Run("basic fluent API", func(t *testing.T) {
builder := I("core.delete").For(S("file", "config.yaml"))
assert.NotNil(t, builder)
})
t.Run("With alias", func(t *testing.T) {
builder := I("core.delete").With(S("file", "config.yaml"))
assert.NotNil(t, builder)
})
t.Run("Compose returns all forms", func(t *testing.T) {
result := I("core.delete").For(S("file", "config.yaml")).Compose()
assert.NotEmpty(t, result.Question)
assert.NotEmpty(t, result.Success)
assert.NotEmpty(t, result.Failure)
})
t.Run("Question returns string", func(t *testing.T) {
question := I("core.delete").For(S("file", "config.yaml")).Question()
assert.Contains(t, question, "config.yaml")
})
t.Run("Success returns string", func(t *testing.T) {
success := I("core.delete").For(S("file", "config.yaml")).Success()
assert.NotEmpty(t, success)
})
t.Run("Failure returns string", func(t *testing.T) {
failure := I("core.delete").For(S("file", "config.yaml")).Failure()
assert.Contains(t, failure, "delete")
})
t.Run("Meta returns metadata", func(t *testing.T) {
meta := I("core.delete").Meta()
assert.True(t, meta.Dangerous)
})
t.Run("IsDangerous helper", func(t *testing.T) {
assert.True(t, I("core.delete").IsDangerous())
assert.False(t, I("core.save").IsDangerous())
})
}

View file

@ -89,23 +89,6 @@ func T(messageID string, args ...any) string {
return messageID
}
// C composes a semantic intent using the default service.
// Returns all output forms (Question, Confirm, Success, Failure) for the intent.
//
// result := C("core.delete", S("file", "config.yaml"))
// fmt.Println(result.Question) // "Delete config.yaml?"
func C(intent string, subject *Subject) *Composed {
if svc := Default(); svc != nil {
return svc.C(intent, subject)
}
return &Composed{
Question: intent,
Confirm: intent,
Success: intent,
Failure: intent,
}
}
// _ is the raw gettext-style translation helper.
// Unlike T(), this does NOT handle core.* namespace magic.
// Use this for direct key lookups without auto-composition.

View file

@ -278,26 +278,6 @@ func TestDebugMode(t *testing.T) {
assert.Equal(t, "Multi-repo development workflow", result)
})
t.Run("C with debug mode", func(t *testing.T) {
svc, err := New()
require.NoError(t, err)
subject := S("file", "config.yaml")
// Without debug
result := svc.C("core.delete", subject)
assert.NotContains(t, result.Question, "[core.delete]")
// Enable debug
svc.SetDebug(true)
// With debug - shows key prefix on all forms
result = svc.C("core.delete", subject)
assert.Contains(t, result.Question, "[core.delete]")
assert.Contains(t, result.Success, "[core.delete]")
assert.Contains(t, result.Failure, "[core.delete]")
})
t.Run("package-level SetDebug", func(t *testing.T) {
// Reset default
defaultService = nil

View file

@ -1,679 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"sync"
)
// coreIntents defines the built-in semantic intents for common operations.
// These are accessed via the "core.*" namespace in T() and C() calls.
//
// Each intent provides templates for all output forms:
// - Question: Initial prompt to the user
// - Confirm: Secondary confirmation (for dangerous actions)
// - Success: Message shown on successful completion
// - Failure: Message shown on failure
//
// Templates use Go text/template syntax with the following data available:
// - .Subject: Display value of the subject
// - .Noun: The noun type (e.g., "file", "repo")
// - .Count: Count for pluralization
// - .Location: Location context
//
// Template functions available:
// - title, lower, upper: Case transformations
// - past, gerund: Verb conjugations
// - plural, pluralForm: Noun pluralization
// - article: Indefinite article selection (a/an)
// - quote: Wrap in double quotes
var coreIntents = map[string]Intent{
// --- Destructive Actions ---
"core.delete": {
Meta: IntentMeta{
Type: "action",
Verb: "delete",
Dangerous: true,
Default: "no",
},
Question: "Delete {{.Subject}}?",
Confirm: "Really delete {{.Subject}}? This cannot be undone.",
Success: "{{.Subject | title}} deleted",
Failure: "Failed to delete {{.Subject}}",
},
"core.remove": {
Meta: IntentMeta{
Type: "action",
Verb: "remove",
Dangerous: true,
Default: "no",
},
Question: "Remove {{.Subject}}?",
Confirm: "Really remove {{.Subject}}?",
Success: "{{.Subject | title}} removed",
Failure: "Failed to remove {{.Subject}}",
},
"core.discard": {
Meta: IntentMeta{
Type: "action",
Verb: "discard",
Dangerous: true,
Default: "no",
},
Question: "Discard {{.Subject}}?",
Confirm: "Really discard {{.Subject}}? All changes will be lost.",
Success: "{{.Subject | title}} discarded",
Failure: "Failed to discard {{.Subject}}",
},
"core.reset": {
Meta: IntentMeta{
Type: "action",
Verb: "reset",
Dangerous: true,
Default: "no",
},
Question: "Reset {{.Subject}}?",
Confirm: "Really reset {{.Subject}}? This cannot be undone.",
Success: "{{.Subject | title}} reset",
Failure: "Failed to reset {{.Subject}}",
},
"core.overwrite": {
Meta: IntentMeta{
Type: "action",
Verb: "overwrite",
Dangerous: true,
Default: "no",
},
Question: "Overwrite {{.Subject}}?",
Confirm: "Really overwrite {{.Subject}}? Existing content will be lost.",
Success: "{{.Subject | title}} overwritten",
Failure: "Failed to overwrite {{.Subject}}",
},
// --- Creation Actions ---
"core.create": {
Meta: IntentMeta{
Type: "action",
Verb: "create",
Default: "yes",
},
Question: "Create {{.Subject}}?",
Confirm: "Create {{.Subject}}?",
Success: "{{.Subject | title}} created",
Failure: "Failed to create {{.Subject}}",
},
"core.add": {
Meta: IntentMeta{
Type: "action",
Verb: "add",
Default: "yes",
},
Question: "Add {{.Subject}}?",
Confirm: "Add {{.Subject}}?",
Success: "{{.Subject | title}} added",
Failure: "Failed to add {{.Subject}}",
},
"core.clone": {
Meta: IntentMeta{
Type: "action",
Verb: "clone",
Default: "yes",
},
Question: "Clone {{.Subject}}?",
Confirm: "Clone {{.Subject}}?",
Success: "{{.Subject | title}} cloned",
Failure: "Failed to clone {{.Subject}}",
},
"core.copy": {
Meta: IntentMeta{
Type: "action",
Verb: "copy",
Default: "yes",
},
Question: "Copy {{.Subject}}?",
Confirm: "Copy {{.Subject}}?",
Success: "{{.Subject | title}} copied",
Failure: "Failed to copy {{.Subject}}",
},
// --- Modification Actions ---
"core.save": {
Meta: IntentMeta{
Type: "action",
Verb: "save",
Default: "yes",
},
Question: "Save {{.Subject}}?",
Confirm: "Save {{.Subject}}?",
Success: "{{.Subject | title}} saved",
Failure: "Failed to save {{.Subject}}",
},
"core.update": {
Meta: IntentMeta{
Type: "action",
Verb: "update",
Default: "yes",
},
Question: "Update {{.Subject}}?",
Confirm: "Update {{.Subject}}?",
Success: "{{.Subject | title}} updated",
Failure: "Failed to update {{.Subject}}",
},
"core.rename": {
Meta: IntentMeta{
Type: "action",
Verb: "rename",
Default: "yes",
},
Question: "Rename {{.Subject}}?",
Confirm: "Rename {{.Subject}}?",
Success: "{{.Subject | title}} renamed",
Failure: "Failed to rename {{.Subject}}",
},
"core.move": {
Meta: IntentMeta{
Type: "action",
Verb: "move",
Default: "yes",
},
Question: "Move {{.Subject}}?",
Confirm: "Move {{.Subject}}?",
Success: "{{.Subject | title}} moved",
Failure: "Failed to move {{.Subject}}",
},
// --- Git Actions ---
"core.commit": {
Meta: IntentMeta{
Type: "action",
Verb: "commit",
Default: "yes",
},
Question: "Commit {{.Subject}}?",
Confirm: "Commit {{.Subject}}?",
Success: "{{.Subject | title}} committed",
Failure: "Failed to commit {{.Subject}}",
},
"core.push": {
Meta: IntentMeta{
Type: "action",
Verb: "push",
Default: "yes",
},
Question: "Push {{.Subject}}?",
Confirm: "Push {{.Subject}}?",
Success: "{{.Subject | title}} pushed",
Failure: "Failed to push {{.Subject}}",
},
"core.pull": {
Meta: IntentMeta{
Type: "action",
Verb: "pull",
Default: "yes",
},
Question: "Pull {{.Subject}}?",
Confirm: "Pull {{.Subject}}?",
Success: "{{.Subject | title}} pulled",
Failure: "Failed to pull {{.Subject}}",
},
"core.merge": {
Meta: IntentMeta{
Type: "action",
Verb: "merge",
Dangerous: true,
Default: "no",
},
Question: "Merge {{.Subject}}?",
Confirm: "Really merge {{.Subject}}?",
Success: "{{.Subject | title}} merged",
Failure: "Failed to merge {{.Subject}}",
},
"core.rebase": {
Meta: IntentMeta{
Type: "action",
Verb: "rebase",
Dangerous: true,
Default: "no",
},
Question: "Rebase {{.Subject}}?",
Confirm: "Really rebase {{.Subject}}? This rewrites history.",
Success: "{{.Subject | title}} rebased",
Failure: "Failed to rebase {{.Subject}}",
},
// --- Network Actions ---
"core.install": {
Meta: IntentMeta{
Type: "action",
Verb: "install",
Default: "yes",
},
Question: "Install {{.Subject}}?",
Confirm: "Install {{.Subject}}?",
Success: "{{.Subject | title}} installed",
Failure: "Failed to install {{.Subject}}",
},
"core.download": {
Meta: IntentMeta{
Type: "action",
Verb: "download",
Default: "yes",
},
Question: "Download {{.Subject}}?",
Confirm: "Download {{.Subject}}?",
Success: "{{.Subject | title}} downloaded",
Failure: "Failed to download {{.Subject}}",
},
"core.upload": {
Meta: IntentMeta{
Type: "action",
Verb: "upload",
Default: "yes",
},
Question: "Upload {{.Subject}}?",
Confirm: "Upload {{.Subject}}?",
Success: "{{.Subject | title}} uploaded",
Failure: "Failed to upload {{.Subject}}",
},
"core.publish": {
Meta: IntentMeta{
Type: "action",
Verb: "publish",
Dangerous: true,
Default: "no",
},
Question: "Publish {{.Subject}}?",
Confirm: "Really publish {{.Subject}}? This will be publicly visible.",
Success: "{{.Subject | title}} published",
Failure: "Failed to publish {{.Subject}}",
},
"core.deploy": {
Meta: IntentMeta{
Type: "action",
Verb: "deploy",
Dangerous: true,
Default: "no",
},
Question: "Deploy {{.Subject}}?",
Confirm: "Really deploy {{.Subject}}?",
Success: "{{.Subject | title}} deployed",
Failure: "Failed to deploy {{.Subject}}",
},
// --- Process Actions ---
"core.start": {
Meta: IntentMeta{
Type: "action",
Verb: "start",
Default: "yes",
},
Question: "Start {{.Subject}}?",
Confirm: "Start {{.Subject}}?",
Success: "{{.Subject | title}} started",
Failure: "Failed to start {{.Subject}}",
},
"core.stop": {
Meta: IntentMeta{
Type: "action",
Verb: "stop",
Default: "yes",
},
Question: "Stop {{.Subject}}?",
Confirm: "Stop {{.Subject}}?",
Success: "{{.Subject | title}} stopped",
Failure: "Failed to stop {{.Subject}}",
},
"core.restart": {
Meta: IntentMeta{
Type: "action",
Verb: "restart",
Default: "yes",
},
Question: "Restart {{.Subject}}?",
Confirm: "Restart {{.Subject}}?",
Success: "{{.Subject | title}} restarted",
Failure: "Failed to restart {{.Subject}}",
},
"core.run": {
Meta: IntentMeta{
Type: "action",
Verb: "run",
Default: "yes",
},
Question: "Run {{.Subject}}?",
Confirm: "Run {{.Subject}}?",
Success: "{{.Subject | title}} completed",
Failure: "Failed to run {{.Subject}}",
},
"core.build": {
Meta: IntentMeta{
Type: "action",
Verb: "build",
Default: "yes",
},
Question: "Build {{.Subject}}?",
Confirm: "Build {{.Subject}}?",
Success: "{{.Subject | title}} built",
Failure: "Failed to build {{.Subject}}",
},
"core.test": {
Meta: IntentMeta{
Type: "action",
Verb: "test",
Default: "yes",
},
Question: "Test {{.Subject}}?",
Confirm: "Test {{.Subject}}?",
Success: "{{.Subject | title}} passed",
Failure: "{{.Subject | title}} failed",
},
// --- Information Actions ---
"core.continue": {
Meta: IntentMeta{
Type: "question",
Verb: "continue",
Default: "yes",
},
Question: "Continue?",
Confirm: "Continue?",
Success: "Continuing",
Failure: "Aborted",
},
"core.proceed": {
Meta: IntentMeta{
Type: "question",
Verb: "proceed",
Default: "yes",
},
Question: "Proceed?",
Confirm: "Proceed?",
Success: "Proceeding",
Failure: "Aborted",
},
"core.confirm": {
Meta: IntentMeta{
Type: "question",
Verb: "confirm",
Default: "no",
},
Question: "Are you sure?",
Confirm: "Are you sure?",
Success: "Confirmed",
Failure: "Cancelled",
},
// --- Additional Actions ---
"core.sync": {
Meta: IntentMeta{
Type: "action",
Verb: "sync",
Default: "yes",
},
Question: "Sync {{.Subject}}?",
Confirm: "Sync {{.Subject}}?",
Success: "{{.Subject | title}} synced",
Failure: "Failed to sync {{.Subject}}",
},
"core.boot": {
Meta: IntentMeta{
Type: "action",
Verb: "boot",
Default: "yes",
},
Question: "Boot {{.Subject}}?",
Confirm: "Boot {{.Subject}}?",
Success: "{{.Subject | title}} booted",
Failure: "Failed to boot {{.Subject}}",
},
"core.format": {
Meta: IntentMeta{
Type: "action",
Verb: "format",
Default: "yes",
},
Question: "Format {{.Subject}}?",
Confirm: "Format {{.Subject}}?",
Success: "{{.Subject | title}} formatted",
Failure: "Failed to format {{.Subject}}",
},
"core.analyse": {
Meta: IntentMeta{
Type: "action",
Verb: "analyse",
Default: "yes",
},
Question: "Analyse {{.Subject}}?",
Confirm: "Analyse {{.Subject}}?",
Success: "{{.Subject | title}} analysed",
Failure: "Failed to analyse {{.Subject}}",
},
"core.link": {
Meta: IntentMeta{
Type: "action",
Verb: "link",
Default: "yes",
},
Question: "Link {{.Subject}}?",
Confirm: "Link {{.Subject}}?",
Success: "{{.Subject | title}} linked",
Failure: "Failed to link {{.Subject}}",
},
"core.unlink": {
Meta: IntentMeta{
Type: "action",
Verb: "unlink",
Default: "yes",
},
Question: "Unlink {{.Subject}}?",
Confirm: "Unlink {{.Subject}}?",
Success: "{{.Subject | title}} unlinked",
Failure: "Failed to unlink {{.Subject}}",
},
"core.fetch": {
Meta: IntentMeta{
Type: "action",
Verb: "fetch",
Default: "yes",
},
Question: "Fetch {{.Subject}}?",
Confirm: "Fetch {{.Subject}}?",
Success: "{{.Subject | title}} fetched",
Failure: "Failed to fetch {{.Subject}}",
},
"core.generate": {
Meta: IntentMeta{
Type: "action",
Verb: "generate",
Default: "yes",
},
Question: "Generate {{.Subject}}?",
Confirm: "Generate {{.Subject}}?",
Success: "{{.Subject | title}} generated",
Failure: "Failed to generate {{.Subject}}",
},
"core.validate": {
Meta: IntentMeta{
Type: "action",
Verb: "validate",
Default: "yes",
},
Question: "Validate {{.Subject}}?",
Confirm: "Validate {{.Subject}}?",
Success: "{{.Subject | title}} valid",
Failure: "{{.Subject | title}} invalid",
},
"core.check": {
Meta: IntentMeta{
Type: "action",
Verb: "check",
Default: "yes",
},
Question: "Check {{.Subject}}?",
Confirm: "Check {{.Subject}}?",
Success: "{{.Subject | title}} OK",
Failure: "{{.Subject | title}} failed",
},
"core.scan": {
Meta: IntentMeta{
Type: "action",
Verb: "scan",
Default: "yes",
},
Question: "Scan {{.Subject}}?",
Confirm: "Scan {{.Subject}}?",
Success: "{{.Subject | title}} scanned",
Failure: "Failed to scan {{.Subject}}",
},
}
// customIntents holds user-registered intents.
// Separated from coreIntents to allow thread-safe registration.
var (
customIntents = make(map[string]Intent)
customIntentsMu sync.RWMutex
)
// getIntent retrieves an intent by its key.
// Checks custom intents first, then falls back to core intents.
// Returns nil if the intent is not found.
func getIntent(key string) *Intent {
// Check custom intents first (thread-safe)
customIntentsMu.RLock()
if intent, ok := customIntents[key]; ok {
customIntentsMu.RUnlock()
return &intent
}
customIntentsMu.RUnlock()
// Fall back to core intents
if intent, ok := coreIntents[key]; ok {
return &intent
}
return nil
}
// RegisterIntent adds a custom intent at runtime.
// Use this to extend the built-in intents with application-specific ones.
// This function is thread-safe.
//
// i18n.RegisterIntent("myapp.archive", i18n.Intent{
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
// Question: "Archive {{.Subject}}?",
// Success: "{{.Subject | title}} archived",
// Failure: "Failed to archive {{.Subject}}",
// })
func RegisterIntent(key string, intent Intent) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
customIntents[key] = intent
}
// RegisterIntents adds multiple custom intents at runtime.
// This is more efficient than calling RegisterIntent multiple times.
// This function is thread-safe.
//
// i18n.RegisterIntents(map[string]i18n.Intent{
// "myapp.archive": {
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive"},
// Question: "Archive {{.Subject}}?",
// },
// "myapp.export": {
// Meta: i18n.IntentMeta{Type: "action", Verb: "export"},
// Question: "Export {{.Subject}}?",
// },
// })
func RegisterIntents(intents map[string]Intent) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
for k, v := range intents {
customIntents[k] = v
}
}
// UnregisterIntent removes a custom intent by key.
// This only affects custom intents, not core intents.
// This function is thread-safe.
func UnregisterIntent(key string) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
delete(customIntents, key)
}
// IntentKeys returns all registered intent keys (both core and custom).
func IntentKeys() []string {
customIntentsMu.RLock()
defer customIntentsMu.RUnlock()
keys := make([]string, 0, len(coreIntents)+len(customIntents))
for key := range coreIntents {
keys = append(keys, key)
}
for key := range customIntents {
// Avoid duplicates if custom overrides core
found := false
for _, k := range keys {
if k == key {
found = true
break
}
}
if !found {
keys = append(keys, key)
}
}
return keys
}
// HasIntent returns true if an intent with the given key exists.
func HasIntent(key string) bool {
return getIntent(key) != nil
}
// GetIntent returns the intent for a key, or nil if not found.
// This is the public API for retrieving intents.
func GetIntent(key string) *Intent {
return getIntent(key)
}

View file

@ -1,303 +1,679 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sync"
)
func TestGetIntent(t *testing.T) {
t.Run("existing intent", func(t *testing.T) {
intent := getIntent("core.delete")
require.NotNil(t, intent)
assert.Equal(t, "action", intent.Meta.Type)
assert.Equal(t, "delete", intent.Meta.Verb)
assert.True(t, intent.Meta.Dangerous)
assert.Equal(t, "no", intent.Meta.Default)
})
// coreIntents defines the built-in semantic intents for common operations.
// These are accessed via the "core.*" namespace in T() and C() calls.
//
// Each intent provides templates for all output forms:
// - Question: Initial prompt to the user
// - Confirm: Secondary confirmation (for dangerous actions)
// - Success: Message shown on successful completion
// - Failure: Message shown on failure
//
// Templates use Go text/template syntax with the following data available:
// - .Subject: Display value of the subject
// - .Noun: The noun type (e.g., "file", "repo")
// - .Count: Count for pluralization
// - .Location: Location context
//
// Template functions available:
// - title, lower, upper: Case transformations
// - past, gerund: Verb conjugations
// - plural, pluralForm: Noun pluralization
// - article: Indefinite article selection (a/an)
// - quote: Wrap in double quotes
var coreIntents = map[string]Intent{
// --- Destructive Actions ---
t.Run("non-existent intent", func(t *testing.T) {
intent := getIntent("nonexistent.intent")
assert.Nil(t, intent)
})
}
func TestRegisterIntent(t *testing.T) {
// Register a custom intent
RegisterIntent("test.custom", Intent{
"core.delete": {
Meta: IntentMeta{
Type: "action",
Verb: "custom",
Verb: "delete",
Dangerous: true,
Default: "no",
},
Question: "Delete {{.Subject}}?",
Confirm: "Really delete {{.Subject}}? This cannot be undone.",
Success: "{{.Subject | title}} deleted",
Failure: "Failed to delete {{.Subject}}",
},
"core.remove": {
Meta: IntentMeta{
Type: "action",
Verb: "remove",
Dangerous: true,
Default: "no",
},
Question: "Remove {{.Subject}}?",
Confirm: "Really remove {{.Subject}}?",
Success: "{{.Subject | title}} removed",
Failure: "Failed to remove {{.Subject}}",
},
"core.discard": {
Meta: IntentMeta{
Type: "action",
Verb: "discard",
Dangerous: true,
Default: "no",
},
Question: "Discard {{.Subject}}?",
Confirm: "Really discard {{.Subject}}? All changes will be lost.",
Success: "{{.Subject | title}} discarded",
Failure: "Failed to discard {{.Subject}}",
},
"core.reset": {
Meta: IntentMeta{
Type: "action",
Verb: "reset",
Dangerous: true,
Default: "no",
},
Question: "Reset {{.Subject}}?",
Confirm: "Really reset {{.Subject}}? This cannot be undone.",
Success: "{{.Subject | title}} reset",
Failure: "Failed to reset {{.Subject}}",
},
"core.overwrite": {
Meta: IntentMeta{
Type: "action",
Verb: "overwrite",
Dangerous: true,
Default: "no",
},
Question: "Overwrite {{.Subject}}?",
Confirm: "Really overwrite {{.Subject}}? Existing content will be lost.",
Success: "{{.Subject | title}} overwritten",
Failure: "Failed to overwrite {{.Subject}}",
},
// --- Creation Actions ---
"core.create": {
Meta: IntentMeta{
Type: "action",
Verb: "create",
Default: "yes",
},
Question: "Custom {{.Subject}}?",
Success: "{{.Subject | title}} customised",
Failure: "Failed to customise {{.Subject}}",
})
// Verify it was registered
intent := getIntent("test.custom")
require.NotNil(t, intent)
assert.Equal(t, "custom", intent.Meta.Verb)
assert.Equal(t, "Custom {{.Subject}}?", intent.Question)
// Clean up
UnregisterIntent("test.custom")
}
func TestRegisterIntents_Batch(t *testing.T) {
// Register multiple intents at once
RegisterIntents(map[string]Intent{
"test.batch1": {
Meta: IntentMeta{Type: "action", Verb: "batch1", Default: "yes"},
Question: "Batch 1?",
Question: "Create {{.Subject}}?",
Confirm: "Create {{.Subject}}?",
Success: "{{.Subject | title}} created",
Failure: "Failed to create {{.Subject}}",
},
"test.batch2": {
Meta: IntentMeta{Type: "action", Verb: "batch2", Default: "no"},
Question: "Batch 2?",
"core.add": {
Meta: IntentMeta{
Type: "action",
Verb: "add",
Default: "yes",
},
Question: "Add {{.Subject}}?",
Confirm: "Add {{.Subject}}?",
Success: "{{.Subject | title}} added",
Failure: "Failed to add {{.Subject}}",
},
})
// Verify both were registered
assert.True(t, HasIntent("test.batch1"))
assert.True(t, HasIntent("test.batch2"))
"core.clone": {
Meta: IntentMeta{
Type: "action",
Verb: "clone",
Default: "yes",
},
Question: "Clone {{.Subject}}?",
Confirm: "Clone {{.Subject}}?",
Success: "{{.Subject | title}} cloned",
Failure: "Failed to clone {{.Subject}}",
},
intent1 := GetIntent("test.batch1")
require.NotNil(t, intent1)
assert.Equal(t, "batch1", intent1.Meta.Verb)
"core.copy": {
Meta: IntentMeta{
Type: "action",
Verb: "copy",
Default: "yes",
},
Question: "Copy {{.Subject}}?",
Confirm: "Copy {{.Subject}}?",
Success: "{{.Subject | title}} copied",
Failure: "Failed to copy {{.Subject}}",
},
intent2 := GetIntent("test.batch2")
require.NotNil(t, intent2)
assert.Equal(t, "batch2", intent2.Meta.Verb)
// --- Modification Actions ---
// Clean up
UnregisterIntent("test.batch1")
UnregisterIntent("test.batch2")
"core.save": {
Meta: IntentMeta{
Type: "action",
Verb: "save",
Default: "yes",
},
Question: "Save {{.Subject}}?",
Confirm: "Save {{.Subject}}?",
Success: "{{.Subject | title}} saved",
Failure: "Failed to save {{.Subject}}",
},
// Verify cleanup
assert.False(t, HasIntent("test.batch1"))
assert.False(t, HasIntent("test.batch2"))
"core.update": {
Meta: IntentMeta{
Type: "action",
Verb: "update",
Default: "yes",
},
Question: "Update {{.Subject}}?",
Confirm: "Update {{.Subject}}?",
Success: "{{.Subject | title}} updated",
Failure: "Failed to update {{.Subject}}",
},
"core.rename": {
Meta: IntentMeta{
Type: "action",
Verb: "rename",
Default: "yes",
},
Question: "Rename {{.Subject}}?",
Confirm: "Rename {{.Subject}}?",
Success: "{{.Subject | title}} renamed",
Failure: "Failed to rename {{.Subject}}",
},
"core.move": {
Meta: IntentMeta{
Type: "action",
Verb: "move",
Default: "yes",
},
Question: "Move {{.Subject}}?",
Confirm: "Move {{.Subject}}?",
Success: "{{.Subject | title}} moved",
Failure: "Failed to move {{.Subject}}",
},
// --- Git Actions ---
"core.commit": {
Meta: IntentMeta{
Type: "action",
Verb: "commit",
Default: "yes",
},
Question: "Commit {{.Subject}}?",
Confirm: "Commit {{.Subject}}?",
Success: "{{.Subject | title}} committed",
Failure: "Failed to commit {{.Subject}}",
},
"core.push": {
Meta: IntentMeta{
Type: "action",
Verb: "push",
Default: "yes",
},
Question: "Push {{.Subject}}?",
Confirm: "Push {{.Subject}}?",
Success: "{{.Subject | title}} pushed",
Failure: "Failed to push {{.Subject}}",
},
"core.pull": {
Meta: IntentMeta{
Type: "action",
Verb: "pull",
Default: "yes",
},
Question: "Pull {{.Subject}}?",
Confirm: "Pull {{.Subject}}?",
Success: "{{.Subject | title}} pulled",
Failure: "Failed to pull {{.Subject}}",
},
"core.merge": {
Meta: IntentMeta{
Type: "action",
Verb: "merge",
Dangerous: true,
Default: "no",
},
Question: "Merge {{.Subject}}?",
Confirm: "Really merge {{.Subject}}?",
Success: "{{.Subject | title}} merged",
Failure: "Failed to merge {{.Subject}}",
},
"core.rebase": {
Meta: IntentMeta{
Type: "action",
Verb: "rebase",
Dangerous: true,
Default: "no",
},
Question: "Rebase {{.Subject}}?",
Confirm: "Really rebase {{.Subject}}? This rewrites history.",
Success: "{{.Subject | title}} rebased",
Failure: "Failed to rebase {{.Subject}}",
},
// --- Network Actions ---
"core.install": {
Meta: IntentMeta{
Type: "action",
Verb: "install",
Default: "yes",
},
Question: "Install {{.Subject}}?",
Confirm: "Install {{.Subject}}?",
Success: "{{.Subject | title}} installed",
Failure: "Failed to install {{.Subject}}",
},
"core.download": {
Meta: IntentMeta{
Type: "action",
Verb: "download",
Default: "yes",
},
Question: "Download {{.Subject}}?",
Confirm: "Download {{.Subject}}?",
Success: "{{.Subject | title}} downloaded",
Failure: "Failed to download {{.Subject}}",
},
"core.upload": {
Meta: IntentMeta{
Type: "action",
Verb: "upload",
Default: "yes",
},
Question: "Upload {{.Subject}}?",
Confirm: "Upload {{.Subject}}?",
Success: "{{.Subject | title}} uploaded",
Failure: "Failed to upload {{.Subject}}",
},
"core.publish": {
Meta: IntentMeta{
Type: "action",
Verb: "publish",
Dangerous: true,
Default: "no",
},
Question: "Publish {{.Subject}}?",
Confirm: "Really publish {{.Subject}}? This will be publicly visible.",
Success: "{{.Subject | title}} published",
Failure: "Failed to publish {{.Subject}}",
},
"core.deploy": {
Meta: IntentMeta{
Type: "action",
Verb: "deploy",
Dangerous: true,
Default: "no",
},
Question: "Deploy {{.Subject}}?",
Confirm: "Really deploy {{.Subject}}?",
Success: "{{.Subject | title}} deployed",
Failure: "Failed to deploy {{.Subject}}",
},
// --- Process Actions ---
"core.start": {
Meta: IntentMeta{
Type: "action",
Verb: "start",
Default: "yes",
},
Question: "Start {{.Subject}}?",
Confirm: "Start {{.Subject}}?",
Success: "{{.Subject | title}} started",
Failure: "Failed to start {{.Subject}}",
},
"core.stop": {
Meta: IntentMeta{
Type: "action",
Verb: "stop",
Default: "yes",
},
Question: "Stop {{.Subject}}?",
Confirm: "Stop {{.Subject}}?",
Success: "{{.Subject | title}} stopped",
Failure: "Failed to stop {{.Subject}}",
},
"core.restart": {
Meta: IntentMeta{
Type: "action",
Verb: "restart",
Default: "yes",
},
Question: "Restart {{.Subject}}?",
Confirm: "Restart {{.Subject}}?",
Success: "{{.Subject | title}} restarted",
Failure: "Failed to restart {{.Subject}}",
},
"core.run": {
Meta: IntentMeta{
Type: "action",
Verb: "run",
Default: "yes",
},
Question: "Run {{.Subject}}?",
Confirm: "Run {{.Subject}}?",
Success: "{{.Subject | title}} completed",
Failure: "Failed to run {{.Subject}}",
},
"core.build": {
Meta: IntentMeta{
Type: "action",
Verb: "build",
Default: "yes",
},
Question: "Build {{.Subject}}?",
Confirm: "Build {{.Subject}}?",
Success: "{{.Subject | title}} built",
Failure: "Failed to build {{.Subject}}",
},
"core.test": {
Meta: IntentMeta{
Type: "action",
Verb: "test",
Default: "yes",
},
Question: "Test {{.Subject}}?",
Confirm: "Test {{.Subject}}?",
Success: "{{.Subject | title}} passed",
Failure: "{{.Subject | title}} failed",
},
// --- Information Actions ---
"core.continue": {
Meta: IntentMeta{
Type: "question",
Verb: "continue",
Default: "yes",
},
Question: "Continue?",
Confirm: "Continue?",
Success: "Continuing",
Failure: "Aborted",
},
"core.proceed": {
Meta: IntentMeta{
Type: "question",
Verb: "proceed",
Default: "yes",
},
Question: "Proceed?",
Confirm: "Proceed?",
Success: "Proceeding",
Failure: "Aborted",
},
"core.confirm": {
Meta: IntentMeta{
Type: "question",
Verb: "confirm",
Default: "no",
},
Question: "Are you sure?",
Confirm: "Are you sure?",
Success: "Confirmed",
Failure: "Cancelled",
},
// --- Additional Actions ---
"core.sync": {
Meta: IntentMeta{
Type: "action",
Verb: "sync",
Default: "yes",
},
Question: "Sync {{.Subject}}?",
Confirm: "Sync {{.Subject}}?",
Success: "{{.Subject | title}} synced",
Failure: "Failed to sync {{.Subject}}",
},
"core.boot": {
Meta: IntentMeta{
Type: "action",
Verb: "boot",
Default: "yes",
},
Question: "Boot {{.Subject}}?",
Confirm: "Boot {{.Subject}}?",
Success: "{{.Subject | title}} booted",
Failure: "Failed to boot {{.Subject}}",
},
"core.format": {
Meta: IntentMeta{
Type: "action",
Verb: "format",
Default: "yes",
},
Question: "Format {{.Subject}}?",
Confirm: "Format {{.Subject}}?",
Success: "{{.Subject | title}} formatted",
Failure: "Failed to format {{.Subject}}",
},
"core.analyse": {
Meta: IntentMeta{
Type: "action",
Verb: "analyse",
Default: "yes",
},
Question: "Analyse {{.Subject}}?",
Confirm: "Analyse {{.Subject}}?",
Success: "{{.Subject | title}} analysed",
Failure: "Failed to analyse {{.Subject}}",
},
"core.link": {
Meta: IntentMeta{
Type: "action",
Verb: "link",
Default: "yes",
},
Question: "Link {{.Subject}}?",
Confirm: "Link {{.Subject}}?",
Success: "{{.Subject | title}} linked",
Failure: "Failed to link {{.Subject}}",
},
"core.unlink": {
Meta: IntentMeta{
Type: "action",
Verb: "unlink",
Default: "yes",
},
Question: "Unlink {{.Subject}}?",
Confirm: "Unlink {{.Subject}}?",
Success: "{{.Subject | title}} unlinked",
Failure: "Failed to unlink {{.Subject}}",
},
"core.fetch": {
Meta: IntentMeta{
Type: "action",
Verb: "fetch",
Default: "yes",
},
Question: "Fetch {{.Subject}}?",
Confirm: "Fetch {{.Subject}}?",
Success: "{{.Subject | title}} fetched",
Failure: "Failed to fetch {{.Subject}}",
},
"core.generate": {
Meta: IntentMeta{
Type: "action",
Verb: "generate",
Default: "yes",
},
Question: "Generate {{.Subject}}?",
Confirm: "Generate {{.Subject}}?",
Success: "{{.Subject | title}} generated",
Failure: "Failed to generate {{.Subject}}",
},
"core.validate": {
Meta: IntentMeta{
Type: "action",
Verb: "validate",
Default: "yes",
},
Question: "Validate {{.Subject}}?",
Confirm: "Validate {{.Subject}}?",
Success: "{{.Subject | title}} valid",
Failure: "{{.Subject | title}} invalid",
},
"core.check": {
Meta: IntentMeta{
Type: "action",
Verb: "check",
Default: "yes",
},
Question: "Check {{.Subject}}?",
Confirm: "Check {{.Subject}}?",
Success: "{{.Subject | title}} OK",
Failure: "{{.Subject | title}} failed",
},
"core.scan": {
Meta: IntentMeta{
Type: "action",
Verb: "scan",
Default: "yes",
},
Question: "Scan {{.Subject}}?",
Confirm: "Scan {{.Subject}}?",
Success: "{{.Subject | title}} scanned",
Failure: "Failed to scan {{.Subject}}",
},
}
func TestCustomIntentOverridesCoreIntent(t *testing.T) {
// Custom intents should be checked before core intents
RegisterIntent("core.delete", Intent{
Meta: IntentMeta{Type: "action", Verb: "delete", Default: "yes"},
Question: "Custom delete {{.Subject}}?",
})
// customIntents holds user-registered intents.
// Separated from coreIntents to allow thread-safe registration.
var (
customIntents = make(map[string]Intent)
customIntentsMu sync.RWMutex
)
// Should get custom intent
intent := getIntent("core.delete")
require.NotNil(t, intent)
assert.Equal(t, "Custom delete {{.Subject}}?", intent.Question)
assert.Equal(t, "yes", intent.Meta.Default) // Changed from core's "no"
// getIntent retrieves an intent by its key.
// Checks custom intents first, then falls back to core intents.
// Returns nil if the intent is not found.
func getIntent(key string) *Intent {
// Check custom intents first (thread-safe)
customIntentsMu.RLock()
if intent, ok := customIntents[key]; ok {
customIntentsMu.RUnlock()
return &intent
}
customIntentsMu.RUnlock()
// Clean up
UnregisterIntent("core.delete")
// Now should get core intent again
intent = getIntent("core.delete")
require.NotNil(t, intent)
assert.Equal(t, "Delete {{.Subject}}?", intent.Question)
assert.Equal(t, "no", intent.Meta.Default) // Back to core default
// Fall back to core intents
if intent, ok := coreIntents[key]; ok {
return &intent
}
return nil
}
func TestHasIntent(t *testing.T) {
assert.True(t, HasIntent("core.delete"))
assert.True(t, HasIntent("core.create"))
assert.False(t, HasIntent("nonexistent.intent"))
// RegisterIntent adds a custom intent at runtime.
// Use this to extend the built-in intents with application-specific ones.
// This function is thread-safe.
//
// i18n.RegisterIntent("myapp.archive", i18n.Intent{
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
// Question: "Archive {{.Subject}}?",
// Success: "{{.Subject | title}} archived",
// Failure: "Failed to archive {{.Subject}}",
// })
func RegisterIntent(key string, intent Intent) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
customIntents[key] = intent
}
func TestGetIntent_Public(t *testing.T) {
intent := GetIntent("core.delete")
require.NotNil(t, intent)
assert.Equal(t, "delete", intent.Meta.Verb)
// Non-existent intent
intent = GetIntent("nonexistent.intent")
assert.Nil(t, intent)
}
func TestIntentKeys(t *testing.T) {
keys := IntentKeys()
// Should contain core intents
assert.Contains(t, keys, "core.delete")
assert.Contains(t, keys, "core.create")
assert.Contains(t, keys, "core.save")
assert.Contains(t, keys, "core.commit")
assert.Contains(t, keys, "core.push")
// Should have a reasonable number of intents
assert.GreaterOrEqual(t, len(keys), 20)
}
func TestCoreIntents_Structure(t *testing.T) {
// Verify all core intents have required fields
for key, intent := range coreIntents {
t.Run(key, func(t *testing.T) {
// Meta should be set
assert.NotEmpty(t, intent.Meta.Type, "intent %s missing Type", key)
assert.NotEmpty(t, intent.Meta.Verb, "intent %s missing Verb", key)
assert.NotEmpty(t, intent.Meta.Default, "intent %s missing Default", key)
// At least Question and one output should be set
assert.NotEmpty(t, intent.Question, "intent %s missing Question", key)
// Default should be valid
assert.Contains(t, []string{"yes", "no"}, intent.Meta.Default,
"intent %s has invalid Default: %s", key, intent.Meta.Default)
// Type should be valid
assert.Contains(t, []string{"action", "question", "info"}, intent.Meta.Type,
"intent %s has invalid Type: %s", key, intent.Meta.Type)
})
// RegisterIntents adds multiple custom intents at runtime.
// This is more efficient than calling RegisterIntent multiple times.
// This function is thread-safe.
//
// i18n.RegisterIntents(map[string]i18n.Intent{
// "myapp.archive": {
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive"},
// Question: "Archive {{.Subject}}?",
// },
// "myapp.export": {
// Meta: i18n.IntentMeta{Type: "action", Verb: "export"},
// Question: "Export {{.Subject}}?",
// },
// })
func RegisterIntents(intents map[string]Intent) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
for k, v := range intents {
customIntents[k] = v
}
}
func TestCoreIntents_Categories(t *testing.T) {
// Destructive actions should be dangerous
destructive := []string{"core.delete", "core.remove", "core.discard", "core.reset", "core.overwrite"}
for _, key := range destructive {
intent := getIntent(key)
require.NotNil(t, intent, "missing intent: %s", key)
assert.True(t, intent.Meta.Dangerous, "%s should be marked as dangerous", key)
assert.Equal(t, "no", intent.Meta.Default, "%s should default to no", key)
}
// Creation actions should not be dangerous
creation := []string{"core.create", "core.add", "core.clone", "core.copy"}
for _, key := range creation {
intent := getIntent(key)
require.NotNil(t, intent, "missing intent: %s", key)
assert.False(t, intent.Meta.Dangerous, "%s should not be marked as dangerous", key)
assert.Equal(t, "yes", intent.Meta.Default, "%s should default to yes", key)
}
// UnregisterIntent removes a custom intent by key.
// This only affects custom intents, not core intents.
// This function is thread-safe.
func UnregisterIntent(key string) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
delete(customIntents, key)
}
func TestCoreIntents_Templates(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// IntentKeys returns all registered intent keys (both core and custom).
func IntentKeys() []string {
customIntentsMu.RLock()
defer customIntentsMu.RUnlock()
tests := []struct {
intent string
subject *Subject
expectedQ string
expectedSuccess string
}{
{
intent: "core.delete",
subject: S("file", "config.yaml"),
expectedQ: "Delete config.yaml?",
expectedSuccess: "Config.Yaml deleted", // strings.Title capitalizes after dots
},
{
intent: "core.create",
subject: S("directory", "src"),
expectedQ: "Create src?",
expectedSuccess: "Src created",
},
{
intent: "core.commit",
subject: S("changes", "3 files"),
expectedQ: "Commit 3 files?",
expectedSuccess: "3 Files committed",
},
{
intent: "core.push",
subject: S("commits", "5 commits"),
expectedQ: "Push 5 commits?",
expectedSuccess: "5 Commits pushed",
},
}
for _, tt := range tests {
t.Run(tt.intent, func(t *testing.T) {
result := svc.C(tt.intent, tt.subject)
assert.Equal(t, tt.expectedQ, result.Question)
assert.Equal(t, tt.expectedSuccess, result.Success)
})
}
}
func TestCoreIntents_TemplateErrors(t *testing.T) {
// Templates with invalid syntax should return the original template
RegisterIntent("test.invalid", Intent{
Meta: IntentMeta{Type: "action", Verb: "test", Default: "yes"},
Question: "{{.Invalid", // Invalid template syntax
Success: "Success",
Failure: "Failure",
})
defer delete(coreIntents, "test.invalid")
svc, err := New()
require.NoError(t, err)
result := svc.C("test.invalid", S("item", "test"))
// Should return the original invalid template
assert.Equal(t, "{{.Invalid", result.Question)
}
func TestCoreIntents_TemplateFunctions(t *testing.T) {
// Register an intent that uses template functions
RegisterIntent("test.funcs", Intent{
Meta: IntentMeta{Type: "action", Verb: "test", Default: "yes"},
Question: "Process {{.Subject | quote}}?",
Success: "{{.Subject | upper}} processed",
Failure: "{{.Subject | lower}} failed",
})
defer delete(coreIntents, "test.funcs")
svc, err := New()
require.NoError(t, err)
result := svc.C("test.funcs", S("item", "Test"))
assert.Equal(t, `Process "Test"?`, result.Question)
assert.Equal(t, "TEST processed", result.Success)
assert.Equal(t, "test failed", result.Failure)
}
func TestIntentT_Integration(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Using C with intent key and Subject
composed := svc.C("core.delete", S("file", "config.yaml"))
assert.Equal(t, "Delete config.yaml?", composed.Question)
// Using T with regular key should work normally
result := svc.T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
}
func TestIntent_EmptyTemplates(t *testing.T) {
RegisterIntent("test.empty", Intent{
Meta: IntentMeta{Type: "info", Verb: "info", Default: "yes"},
Question: "Question",
Confirm: "", // Empty confirm
Success: "", // Empty success
Failure: "", // Empty failure
})
defer delete(coreIntents, "test.empty")
svc, err := New()
require.NoError(t, err)
result := svc.C("test.empty", S("item", "test"))
assert.Equal(t, "Question", result.Question)
assert.Equal(t, "", result.Confirm)
assert.Equal(t, "", result.Success)
assert.Equal(t, "", result.Failure)
}
func TestCoreIntents_AllKeysPrefixed(t *testing.T) {
keys := make([]string, 0, len(coreIntents)+len(customIntents))
for key := range coreIntents {
assert.True(t, strings.HasPrefix(key, "core."),
"intent key %q should be prefixed with 'core.'", key)
keys = append(keys, key)
}
for key := range customIntents {
// Avoid duplicates if custom overrides core
found := false
for _, k := range keys {
if k == key {
found = true
break
}
}
if !found {
keys = append(keys, key)
}
}
return keys
}
// HasIntent returns true if an intent with the given key exists.
func HasIntent(key string) bool {
return getIntent(key) != nil
}
// GetIntent returns the intent for a key, or nil if not found.
// This is the public API for retrieving intents.
func GetIntent(key string) *Intent {
return getIntent(key)
}

View file

@ -32,12 +32,6 @@ type Translator interface {
// svc.T("cli.count.items", map[string]any{"Count": 5})
T(messageID string, args ...any) string
// C composes a semantic intent with a subject.
// Returns all output forms (Question, Confirm, Success, Failure).
//
// result := svc.C("core.delete", S("file", "config.yaml"))
C(intent string, subject *Subject) *Composed
// SetLanguage sets the language for translations.
// Returns an error if the language is not supported.
SetLanguage(lang string) error
@ -94,8 +88,8 @@ type MissingKeyHandler func(missing MissingKey)
type MissingKey struct {
Key string // The missing translation key
Args map[string]any // Arguments passed to the translation
CallerFile string // Source file where T()/C() was called
CallerLine int // Line number where T()/C() was called
CallerFile string // Source file where T() was called
CallerLine int // Line number where T() was called
}
// MissingKeyAction is an alias for backwards compatibility.

View file

@ -38,15 +38,6 @@ func (m *MockTranslator) T(key string, args ...any) string {
return key
}
func (m *MockTranslator) C(intent string, subject *Subject) *Composed {
return &Composed{
Question: "Mock: " + intent,
Confirm: "Mock confirm",
Success: "Mock success",
Failure: "Mock failure",
}
}
func (m *MockTranslator) SetLanguage(lang string) error {
m.language = lang
return nil
@ -64,7 +55,9 @@ func (m *MockTranslator) SetFormality(f Formality) {}
func (m *MockTranslator) Formality() Formality { return FormalityNeutral }
func (m *MockTranslator) Direction() TextDirection { return DirLTR }
func (m *MockTranslator) IsRTL() bool { return false }
func (m *MockTranslator) PluralCategory(n int) PluralCategory { return PluralOther }
func (m *MockTranslator) PluralCategory(n int) PluralCategory {
return PluralOther
}
func (m *MockTranslator) AvailableLanguages() []string { return []string{"en-GB"} }
func TestMockTranslator(t *testing.T) {
@ -78,7 +71,4 @@ func TestMockTranslator(t *testing.T) {
assert.Equal(t, "Hello from mock", translator.T("test.hello"))
assert.Equal(t, "test.missing", translator.T("test.missing"))
assert.Equal(t, "en-GB", translator.Language())
result := translator.C("core.delete", S("file", "test.txt"))
assert.Equal(t, "Mock: core.delete", result.Question)
}

View file

@ -160,37 +160,3 @@ func TestModeCollect_MissingKey(t *testing.T) {
assert.Greater(t, received.CallerLine, 0)
}
func TestModeCollect_MissingIntent(t *testing.T) {
// Reset handler after test
defer SetActionHandler(nil)
svc, err := New()
require.NoError(t, err)
svc.SetMode(ModeCollect)
var received MissingKeyAction
SetActionHandler(func(action MissingKeyAction) {
received = action
})
// Missing intent should dispatch action and return [key]
result := svc.C("nonexistent.intent", S("file", "test.txt"))
assert.Equal(t, "[nonexistent.intent]", result.Question)
assert.Equal(t, "[nonexistent.intent]", result.Success)
assert.Equal(t, "[nonexistent.intent]", result.Failure)
assert.Equal(t, "nonexistent.intent", received.Key)
}
func TestModeStrict_MissingIntent(t *testing.T) {
svc, err := New()
require.NoError(t, err)
svc.SetMode(ModeStrict)
// Missing intent should panic
assert.Panics(t, func() {
svc.C("nonexistent.intent", S("file", "test.txt"))
})
}

View file

@ -459,67 +459,6 @@ func (s *Service) handleMissingKey(key string, args []any) string {
}
}
// C composes a semantic intent with a subject.
// Returns all output forms (Question, Confirm, Success, Failure) for the intent.
//
// result := svc.C("core.delete", S("file", "config.yaml"))
// fmt.Println(result.Question) // "Delete config.yaml?"
// fmt.Println(result.Success) // "Config.yaml deleted"
func (s *Service) C(intent string, subject *Subject) *Composed {
// Look up the intent definition
intentDef := getIntent(intent)
if intentDef == nil {
// Intent not found, handle as missing key
s.mu.RLock()
mode := s.mode
s.mu.RUnlock()
switch mode {
case ModeStrict:
panic(fmt.Sprintf("i18n: missing intent %q", intent))
case ModeCollect:
dispatchMissingKey(intent, nil)
return &Composed{
Question: "[" + intent + "]",
Confirm: "[" + intent + "]",
Success: "[" + intent + "]",
Failure: "[" + intent + "]",
}
default:
return &Composed{
Question: intent,
Confirm: intent,
Success: intent,
Failure: intent,
}
}
}
// Create template data from subject
data := newTemplateData(subject)
result := &Composed{
Question: executeIntentTemplate(intentDef.Question, data),
Confirm: executeIntentTemplate(intentDef.Confirm, data),
Success: executeIntentTemplate(intentDef.Success, data),
Failure: executeIntentTemplate(intentDef.Failure, data),
Meta: intentDef.Meta,
}
// Debug mode: prefix each form with the intent key
s.mu.RLock()
debug := s.debug
s.mu.RUnlock()
if debug {
result.Question = debugFormat(intent, result.Question)
result.Confirm = debugFormat(intent, result.Confirm)
result.Success = debugFormat(intent, result.Success)
result.Failure = debugFormat(intent, result.Failure)
}
return result
}
// Raw is the raw translation helper without i18n.* namespace magic.
// Use T() for smart i18n.* handling, Raw() for direct key lookup.
func (s *Service) Raw(messageID string, args ...any) string {