From 04d8772cba74b14b7c8df96c088dfaf00f60d3b0 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 16:50:08 +0000 Subject: [PATCH] refactor(i18n): remove C() and move intents to test-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/cli/utils.go | 73 +-- pkg/i18n/compose.go | 88 --- pkg/i18n/compose_intents_test.go | 39 +- pkg/i18n/compose_test.go | 46 -- pkg/i18n/i18n.go | 17 - pkg/i18n/i18n_test.go | 20 - pkg/i18n/intents.go | 679 ----------------------- pkg/i18n/intents_test.go | 910 ++++++++++++++++++++++--------- pkg/i18n/interface.go | 10 +- pkg/i18n/interface_test.go | 28 +- pkg/i18n/mode_test.go | 34 -- pkg/i18n/service.go | 61 --- 12 files changed, 706 insertions(+), 1299 deletions(-) delete mode 100644 pkg/i18n/intents.go diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index 1f5dea77..edd82fee 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -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. diff --git a/pkg/i18n/compose.go b/pkg/i18n/compose.go index 867150bf..20fbb759 100644 --- a/pkg/i18n/compose.go +++ b/pkg/i18n/compose.go @@ -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 -} diff --git a/pkg/i18n/compose_intents_test.go b/pkg/i18n/compose_intents_test.go index a4e488db..7700b14a 100644 --- a/pkg/i18n/compose_intents_test.go +++ b/pkg/i18n/compose_intents_test.go @@ -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()") }) } } diff --git a/pkg/i18n/compose_test.go b/pkg/i18n/compose_test.go index fa01c97d..ae22779e 100644 --- a/pkg/i18n/compose_test.go +++ b/pkg/i18n/compose_test.go @@ -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()) - }) -} diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 596fd828..a39df487 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -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. diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index a16dea6d..d0d1826e 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -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 diff --git a/pkg/i18n/intents.go b/pkg/i18n/intents.go deleted file mode 100644 index c1433c5b..00000000 --- a/pkg/i18n/intents.go +++ /dev/null @@ -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) -} diff --git a/pkg/i18n/intents_test.go b/pkg/i18n/intents_test.go index 389d8624..c1433c5b 100644 --- a/pkg/i18n/intents_test.go +++ b/pkg/i18n/intents_test.go @@ -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) - }) -} + "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}}", + }, -func TestRegisterIntent(t *testing.T) { - // Register a custom intent - RegisterIntent("test.custom", Intent{ + "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: "custom", + Verb: "create", Default: "yes", }, - Question: "Custom {{.Subject}}?", - Success: "{{.Subject | title}} customised", - Failure: "Failed to customise {{.Subject}}", - }) + Question: "Create {{.Subject}}?", + Confirm: "Create {{.Subject}}?", + Success: "{{.Subject | title}} created", + Failure: "Failed to create {{.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?", + "core.add": { + Meta: IntentMeta{ + Type: "action", + Verb: "add", + Default: "yes", }, - "test.batch2": { - Meta: IntentMeta{Type: "action", Verb: "batch2", Default: "no"}, - Question: "Batch 2?", + 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}}", + }, - // Verify both were registered - assert.True(t, HasIntent("test.batch1")) - assert.True(t, HasIntent("test.batch2")) + "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}}", + }, - intent1 := GetIntent("test.batch1") - require.NotNil(t, intent1) - assert.Equal(t, "batch1", intent1.Meta.Verb) + // --- Modification Actions --- - intent2 := GetIntent("test.batch2") - require.NotNil(t, intent2) - assert.Equal(t, "batch2", intent2.Meta.Verb) + "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}}", + }, - // Clean up - UnregisterIntent("test.batch1") - UnregisterIntent("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}}", + }, - // Verify cleanup - assert.False(t, HasIntent("test.batch1")) - assert.False(t, HasIntent("test.batch2")) + "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) } diff --git a/pkg/i18n/interface.go b/pkg/i18n/interface.go index 7e05502e..0fddc2ec 100644 --- a/pkg/i18n/interface.go +++ b/pkg/i18n/interface.go @@ -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. diff --git a/pkg/i18n/interface_test.go b/pkg/i18n/interface_test.go index 700ccf9c..fde57a59 100644 --- a/pkg/i18n/interface_test.go +++ b/pkg/i18n/interface_test.go @@ -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 @@ -56,15 +47,17 @@ func (m *MockTranslator) Language() string { return m.language } -func (m *MockTranslator) SetMode(mode Mode) {} -func (m *MockTranslator) Mode() Mode { return ModeNormal } -func (m *MockTranslator) SetDebug(enabled bool) {} -func (m *MockTranslator) Debug() bool { return false } +func (m *MockTranslator) SetMode(mode Mode) {} +func (m *MockTranslator) Mode() Mode { return ModeNormal } +func (m *MockTranslator) SetDebug(enabled bool) {} +func (m *MockTranslator) Debug() bool { return false } func (m *MockTranslator) SetFormality(f Formality) {} -func (m *MockTranslator) Formality() Formality { return FormalityNeutral } +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) IsRTL() bool { return false } +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) } diff --git a/pkg/i18n/mode_test.go b/pkg/i18n/mode_test.go index 668849c3..d872c5b1 100644 --- a/pkg/i18n/mode_test.go +++ b/pkg/i18n/mode_test.go @@ -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")) - }) -} diff --git a/pkg/i18n/service.go b/pkg/i18n/service.go index 5cd89706..9fd45855 100644 --- a/pkg/i18n/service.go +++ b/pkg/i18n/service.go @@ -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 {