From f85064a954b4c29e9267cb6daafc1d0657c2817e Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 12:29:44 +0000 Subject: [PATCH] feat(i18n): implement semantic i18n system with grammar engine Add semantic intent system for natural language CLI interactions: - Mode system (Normal/Strict/Collect) for missing key handling - Subject type with fluent builder for typed subjects - Composed type with Question/Confirm/Success/Failure forms - 30+ core.* intents (delete, create, commit, push, etc.) - Grammar engine: verb conjugation, noun pluralization, articles - Template functions: title, lower, upper, past, plural, article - Enhanced CLI: Confirm with options, Question, Choose functions - Collect mode handler for QA testing Usage: i18n.T("core.delete", i18n.S("file", "config.yaml")) result := i18n.C("core.delete", subject) cli.ConfirmIntent("core.delete", subject) Co-Authored-By: Claude Opus 4.5 --- pkg/cli/i18n.go | 58 +++++ pkg/cli/utils.go | 292 +++++++++++++++++++++- pkg/i18n/compose.go | 162 +++++++++++++ pkg/i18n/compose_test.go | 169 +++++++++++++ pkg/i18n/grammar.go | 505 +++++++++++++++++++++++++++++++++++++++ pkg/i18n/grammar_test.go | 303 +++++++++++++++++++++++ pkg/i18n/i18n.go | 156 +++++++++++- pkg/i18n/intents.go | 463 +++++++++++++++++++++++++++++++++++ pkg/i18n/intents_test.go | 230 ++++++++++++++++++ pkg/i18n/mode.go | 74 ++++++ pkg/i18n/mode_test.go | 196 +++++++++++++++ 11 files changed, 2600 insertions(+), 8 deletions(-) create mode 100644 pkg/i18n/compose.go create mode 100644 pkg/i18n/compose_test.go create mode 100644 pkg/i18n/grammar.go create mode 100644 pkg/i18n/grammar_test.go create mode 100644 pkg/i18n/intents.go create mode 100644 pkg/i18n/intents_test.go create mode 100644 pkg/i18n/mode.go create mode 100644 pkg/i18n/mode_test.go diff --git a/pkg/cli/i18n.go b/pkg/cli/i18n.go index 2e3fd7f9..47602c6b 100644 --- a/pkg/cli/i18n.go +++ b/pkg/cli/i18n.go @@ -2,6 +2,7 @@ package cli import ( "context" + "sync" "github.com/host-uk/core/pkg/framework" "github.com/host-uk/core/pkg/i18n" @@ -11,12 +12,18 @@ import ( type I18nService struct { *framework.ServiceRuntime[I18nOptions] svc *i18n.Service + + // Collect mode state + missingKeys []i18n.MissingKeyAction + missingKeysMu sync.Mutex } // I18nOptions configures the i18n service. type I18nOptions struct { // Language overrides auto-detection (e.g., "en-GB", "de") Language string + // Mode sets the translation mode (Normal, Strict, Collect) + Mode i18n.Mode } // NewI18nService creates an i18n service factory. @@ -31,9 +38,13 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) { svc.SetLanguage(opts.Language) } + // Set mode if specified + svc.SetMode(opts.Mode) + return &I18nService{ ServiceRuntime: framework.NewServiceRuntime(c, opts), svc: svc, + missingKeys: make([]i18n.MissingKeyAction, 0), }, nil } } @@ -41,9 +52,56 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) { // OnStartup initialises the i18n service. func (s *I18nService) OnStartup(ctx context.Context) error { s.Core().RegisterQuery(s.handleQuery) + + // Register action handler for collect mode + if s.svc.Mode() == i18n.ModeCollect { + i18n.SetActionHandler(s.handleMissingKey) + } + return nil } +// handleMissingKey accumulates missing keys in collect mode. +func (s *I18nService) handleMissingKey(action i18n.MissingKeyAction) { + s.missingKeysMu.Lock() + defer s.missingKeysMu.Unlock() + s.missingKeys = append(s.missingKeys, action) +} + +// MissingKeys returns all missing keys collected in collect mode. +// Call this at the end of a QA session to report missing translations. +func (s *I18nService) MissingKeys() []i18n.MissingKeyAction { + s.missingKeysMu.Lock() + defer s.missingKeysMu.Unlock() + result := make([]i18n.MissingKeyAction, len(s.missingKeys)) + copy(result, s.missingKeys) + return result +} + +// ClearMissingKeys resets the collected missing keys. +func (s *I18nService) ClearMissingKeys() { + s.missingKeysMu.Lock() + defer s.missingKeysMu.Unlock() + s.missingKeys = s.missingKeys[:0] +} + +// SetMode changes the translation mode. +func (s *I18nService) SetMode(mode i18n.Mode) { + s.svc.SetMode(mode) + + // Update action handler registration + if mode == i18n.ModeCollect { + i18n.SetActionHandler(s.handleMissingKey) + } else { + i18n.SetActionHandler(nil) + } +} + +// Mode returns the current translation mode. +func (s *I18nService) Mode() i18n.Mode { + return s.svc.Mode() +} + // Queries for i18n service // QueryTranslate requests a translation. diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index ee107c2d..2889e4f9 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -1,11 +1,15 @@ package cli import ( + "bufio" "context" "fmt" + "os" "os/exec" "strings" "time" + + "github.com/host-uk/core/pkg/i18n" ) // GhAuthenticated checks if the GitHub CLI is authenticated. @@ -24,14 +28,290 @@ func Truncate(s string, max int) string { return s[:max-3] + "..." } +// ConfirmOption configures Confirm behaviour. +type ConfirmOption func(*confirmConfig) + +type confirmConfig struct { + defaultYes bool + required bool +} + +// DefaultYes sets the default response to "yes" (pressing Enter confirms). +func DefaultYes() ConfirmOption { + return func(c *confirmConfig) { + c.defaultYes = true + } +} + +// Required prevents empty responses; user must explicitly type y/n. +func Required() ConfirmOption { + return func(c *confirmConfig) { + c.required = true + } +} + // Confirm prompts the user for yes/no confirmation. // Returns true if the user enters "y" or "yes" (case-insensitive). -func Confirm(prompt string) bool { - fmt.Printf("%s [y/N] ", prompt) - var response string - fmt.Scanln(&response) - response = strings.ToLower(strings.TrimSpace(response)) - return response == "y" || response == "yes" +// +// Basic usage: +// +// if Confirm("Delete file?") { ... } +// +// With options: +// +// if Confirm("Save changes?", DefaultYes()) { ... } +// if Confirm("Dangerous!", Required()) { ... } +func Confirm(prompt string, opts ...ConfirmOption) bool { + cfg := &confirmConfig{} + for _, opt := range opts { + opt(cfg) + } + + // Build the prompt suffix + var suffix string + if cfg.required { + suffix = "[y/n] " + } else if cfg.defaultYes { + suffix = "[Y/n] " + } else { + suffix = "[y/N] " + } + + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("%s %s", prompt, suffix) + response, _ := reader.ReadString('\n') + response = strings.ToLower(strings.TrimSpace(response)) + + // Handle empty response + if response == "" { + if cfg.required { + continue // Ask again + } + return cfg.defaultYes + } + + // Check for yes/no responses + if response == "y" || response == "yes" { + return true + } + if response == "n" || response == "no" { + return false + } + + // Invalid response + if cfg.required { + fmt.Println("Please enter 'y' or 'n'") + continue + } + + // Non-required: treat invalid as default + return cfg.defaultYes + } +} + +// ConfirmIntent prompts for confirmation using a semantic intent. +// The intent determines the question text, danger level, and default response. +// +// 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...) +} + +// ConfirmDangerous prompts for confirmation of a dangerous action. +// Shows both the question and a confirmation prompt, requiring explicit "yes". +// +// 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()) { + return false + } + + // For dangerous actions, show confirmation prompt + if result.Meta.Dangerous && result.Confirm != "" { + return Confirm(result.Confirm, Required()) + } + + return true +} + +// QuestionOption configures Question behaviour. +type QuestionOption func(*questionConfig) + +type questionConfig struct { + defaultValue string + required bool + validator func(string) error +} + +// WithDefault sets the default value shown in brackets. +func WithDefault(value string) QuestionOption { + return func(c *questionConfig) { + c.defaultValue = value + } +} + +// WithValidator adds a validation function for the response. +func WithValidator(fn func(string) error) QuestionOption { + return func(c *questionConfig) { + c.validator = fn + } +} + +// RequiredInput prevents empty responses. +func RequiredInput() QuestionOption { + return func(c *questionConfig) { + c.required = true + } +} + +// Question prompts the user for text input. +// +// name := Question("Enter your name:") +// name := Question("Enter your name:", WithDefault("Anonymous")) +// name := Question("Enter your name:", RequiredInput()) +func Question(prompt string, opts ...QuestionOption) string { + cfg := &questionConfig{} + for _, opt := range opts { + opt(cfg) + } + + reader := bufio.NewReader(os.Stdin) + + for { + // Build prompt with default + if cfg.defaultValue != "" { + fmt.Printf("%s [%s] ", prompt, cfg.defaultValue) + } else { + fmt.Printf("%s ", prompt) + } + + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(response) + + // Handle empty response + if response == "" { + if cfg.required { + fmt.Println("Response required") + continue + } + response = cfg.defaultValue + } + + // Validate if validator provided + if cfg.validator != nil { + if err := cfg.validator(response); err != nil { + fmt.Printf("Invalid: %v\n", err) + continue + } + } + + return response + } +} + +// QuestionIntent prompts for text input using a semantic intent. +// +// 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...) +} + +// ChooseOption configures Choose behaviour. +type ChooseOption[T any] func(*chooseConfig[T]) + +type chooseConfig[T any] struct { + displayFn func(T) string + defaultN int // 0-based index of default selection +} + +// WithDisplay sets a custom display function for items. +func WithDisplay[T any](fn func(T) string) ChooseOption[T] { + return func(c *chooseConfig[T]) { + c.displayFn = fn + } +} + +// WithDefaultIndex sets the default selection index (0-based). +func WithDefaultIndex[T any](idx int) ChooseOption[T] { + return func(c *chooseConfig[T]) { + c.defaultN = idx + } +} + +// Choose prompts the user to select from a list of items. +// Returns the selected item. Uses simple numbered selection for terminal compatibility. +// +// choice := Choose("Select a file:", files) +// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name })) +func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T { + var zero T + if len(items) == 0 { + return zero + } + + cfg := &chooseConfig[T]{ + displayFn: func(item T) string { return fmt.Sprint(item) }, + } + for _, opt := range opts { + opt(cfg) + } + + // Display options + fmt.Println(prompt) + for i, item := range items { + marker := " " + if i == cfg.defaultN { + marker = "*" + } + fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item)) + } + + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("Enter number [1-%d]: ", len(items)) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(response) + + // Empty response uses default + if response == "" { + return items[cfg.defaultN] + } + + // Parse number + var n int + if _, err := fmt.Sscanf(response, "%d", &n); err == nil { + if n >= 1 && n <= len(items) { + return items[n-1] + } + } + + fmt.Printf("Please enter a number between 1 and %d\n", len(items)) + } +} + +// ChooseIntent prompts for selection using a semantic intent. +// +// 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...) } // FormatAge formats a time as a human-readable age string. diff --git a/pkg/i18n/compose.go b/pkg/i18n/compose.go new file mode 100644 index 00000000..54ac0e41 --- /dev/null +++ b/pkg/i18n/compose.go @@ -0,0 +1,162 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import ( + "fmt" +) + +// Subject represents a typed subject with metadata for semantic translations. +// Use S() to create a Subject and chain methods for additional context. +// +// S("file", "config.yaml") +// S("repo", "core-php").Count(3) +// S("user", user).Gender("female") +type Subject struct { + Noun string // The noun type (e.g., "file", "repo", "user") + Value any // The actual value (e.g., filename, struct, etc.) + count int // Count for pluralization (default 1) + gender string // Grammatical gender for languages that need it + location string // Location context (e.g., "in workspace") +} + +// S creates a new Subject with the given noun and value. +// The noun is used for grammar rules, the value for display. +// +// S("file", "config.yaml") // "config.yaml" +// S("repo", repo) // Uses repo.String() or fmt.Sprint() +func S(noun string, value any) *Subject { + return &Subject{ + Noun: noun, + Value: value, + count: 1, // Default to singular + } +} + +// Count sets the count for pluralization. +// Used to determine singular/plural forms in templates. +// +// S("file", files).Count(len(files)) +func (s *Subject) Count(n int) *Subject { + s.count = n + return s +} + +// Gender sets the grammatical gender for languages that require it. +// Common values: "masculine", "feminine", "neuter" +// +// S("user", user).Gender("female") +func (s *Subject) Gender(g string) *Subject { + s.gender = g + return s +} + +// In sets the location context for the subject. +// Used in templates to provide spatial context. +// +// S("file", "config.yaml").In("workspace") +func (s *Subject) In(location string) *Subject { + s.location = location + return s +} + +// String returns the display value of the subject. +func (s *Subject) String() string { + if s == nil { + return "" + } + if stringer, ok := s.Value.(fmt.Stringer); ok { + return stringer.String() + } + return fmt.Sprint(s.Value) +} + +// IsPlural returns true if count != 1. +func (s *Subject) IsPlural() bool { + return s != nil && s.count != 1 +} + +// GetCount returns the count value. +func (s *Subject) GetCount() int { + if s == nil { + return 1 + } + return s.count +} + +// GetGender returns the grammatical gender. +func (s *Subject) GetGender() string { + if s == nil { + return "" + } + return s.gender +} + +// GetLocation returns the location context. +func (s *Subject) GetLocation() string { + if s == nil { + return "" + } + return s.location +} + +// GetNoun returns the noun type. +func (s *Subject) GetNoun() string { + if s == nil { + return "" + } + return s.Noun +} + +// IntentMeta defines the behaviour and characteristics of an intent. +type IntentMeta struct { + Type string // "action", "question", "info" + Verb string // Reference to verb key (e.g., "delete", "save") + Dangerous bool // If true, requires extra confirmation + Default string // Default response: "yes" or "no" + Supports []string // Extra options supported by this intent +} + +// Composed holds all output forms for an intent after template resolution. +// Each field is ready to display to the user. +type Composed struct { + Question string // Question form: "Delete config.yaml?" + Confirm string // Confirmation form: "Really delete config.yaml?" + Success string // Success message: "config.yaml deleted" + Failure string // Failure message: "Failed to delete config.yaml" + Meta IntentMeta // Intent metadata for UI decisions +} + +// Intent defines a semantic intent with templates for all output forms. +// Templates use Go text/template syntax with Subject data available. +type Intent struct { + Meta IntentMeta // Intent behaviour and characteristics + Question string // Template for question form + Confirm string // Template for confirmation form + Success string // Template for success message + Failure string // Template for failure message +} + +// templateData is passed to intent templates during execution. +type templateData struct { + Subject string // Display value of subject + Noun string // Noun type + Count int // Count for pluralization + Gender string // Grammatical gender + Location string // Location context + Value any // Raw value (for complex templates) +} + +// newTemplateData creates templateData from a Subject. +func newTemplateData(s *Subject) templateData { + if s == nil { + return templateData{Count: 1} + } + return templateData{ + Subject: s.String(), + Noun: s.Noun, + Count: s.count, + Gender: s.gender, + Location: s.location, + Value: s.Value, + } +} diff --git a/pkg/i18n/compose_test.go b/pkg/i18n/compose_test.go new file mode 100644 index 00000000..224ffec6 --- /dev/null +++ b/pkg/i18n/compose_test.go @@ -0,0 +1,169 @@ +package i18n + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// stringerValue is a test helper that implements fmt.Stringer +type stringerValue struct { + value string +} + +func (s stringerValue) String() string { + return s.value +} + +func TestSubject_Good(t *testing.T) { + t.Run("basic creation", func(t *testing.T) { + s := S("file", "config.yaml") + assert.Equal(t, "file", s.Noun) + assert.Equal(t, "config.yaml", s.Value) + assert.Equal(t, 1, s.count) + assert.Equal(t, "", s.gender) + assert.Equal(t, "", s.location) + }) + + t.Run("with count", func(t *testing.T) { + s := S("file", "*.go").Count(5) + assert.Equal(t, 5, s.GetCount()) + assert.True(t, s.IsPlural()) + }) + + t.Run("with gender", func(t *testing.T) { + s := S("user", "alice").Gender("female") + assert.Equal(t, "female", s.GetGender()) + }) + + t.Run("with location", func(t *testing.T) { + s := S("file", "config.yaml").In("workspace") + assert.Equal(t, "workspace", s.GetLocation()) + }) + + t.Run("chained methods", func(t *testing.T) { + s := S("repo", "core-php").Count(3).Gender("neuter").In("organisation") + assert.Equal(t, "repo", s.GetNoun()) + assert.Equal(t, 3, s.GetCount()) + assert.Equal(t, "neuter", s.GetGender()) + assert.Equal(t, "organisation", s.GetLocation()) + }) +} + +func TestSubject_String(t *testing.T) { + t.Run("string value", func(t *testing.T) { + s := S("file", "config.yaml") + assert.Equal(t, "config.yaml", s.String()) + }) + + t.Run("stringer interface", func(t *testing.T) { + // Using a struct that implements Stringer via embedded method + s := S("item", stringerValue{"test"}) + assert.Equal(t, "test", s.String()) + }) + + t.Run("nil subject", func(t *testing.T) { + var s *Subject + assert.Equal(t, "", s.String()) + }) + + t.Run("int value", func(t *testing.T) { + s := S("count", 42) + assert.Equal(t, "42", s.String()) + }) +} + +func TestSubject_IsPlural(t *testing.T) { + t.Run("singular (count 1)", func(t *testing.T) { + s := S("file", "test.go") + assert.False(t, s.IsPlural()) + }) + + t.Run("plural (count 0)", func(t *testing.T) { + s := S("file", "*.go").Count(0) + assert.True(t, s.IsPlural()) + }) + + t.Run("plural (count > 1)", func(t *testing.T) { + s := S("file", "*.go").Count(5) + assert.True(t, s.IsPlural()) + }) + + t.Run("nil subject", func(t *testing.T) { + var s *Subject + assert.False(t, s.IsPlural()) + }) +} + +func TestSubject_Getters(t *testing.T) { + t.Run("nil safety", func(t *testing.T) { + var s *Subject + assert.Equal(t, "", s.GetNoun()) + assert.Equal(t, 1, s.GetCount()) + assert.Equal(t, "", s.GetGender()) + assert.Equal(t, "", s.GetLocation()) + }) +} + +func TestIntentMeta(t *testing.T) { + meta := IntentMeta{ + Type: "action", + Verb: "delete", + Dangerous: true, + Default: "no", + Supports: []string{"force", "recursive"}, + } + + assert.Equal(t, "action", meta.Type) + assert.Equal(t, "delete", meta.Verb) + assert.True(t, meta.Dangerous) + assert.Equal(t, "no", meta.Default) + assert.Contains(t, meta.Supports, "force") + assert.Contains(t, meta.Supports, "recursive") +} + +func TestComposed(t *testing.T) { + composed := Composed{ + Question: "Delete config.yaml?", + Confirm: "Really delete config.yaml?", + Success: "Config.yaml deleted", + Failure: "Failed to delete config.yaml", + Meta: IntentMeta{ + Type: "action", + Verb: "delete", + Dangerous: true, + Default: "no", + }, + } + + assert.Equal(t, "Delete config.yaml?", composed.Question) + assert.Equal(t, "Really delete config.yaml?", composed.Confirm) + assert.Equal(t, "Config.yaml deleted", composed.Success) + assert.Equal(t, "Failed to delete config.yaml", composed.Failure) + assert.True(t, composed.Meta.Dangerous) +} + +func TestNewTemplateData(t *testing.T) { + t.Run("from subject", func(t *testing.T) { + s := S("file", "config.yaml").Count(3).Gender("neuter").In("workspace") + data := newTemplateData(s) + + assert.Equal(t, "config.yaml", data.Subject) + assert.Equal(t, "file", data.Noun) + assert.Equal(t, 3, data.Count) + assert.Equal(t, "neuter", data.Gender) + assert.Equal(t, "workspace", data.Location) + assert.Equal(t, "config.yaml", data.Value) + }) + + t.Run("from nil subject", func(t *testing.T) { + data := newTemplateData(nil) + + assert.Equal(t, "", data.Subject) + assert.Equal(t, "", data.Noun) + assert.Equal(t, 1, data.Count) + assert.Equal(t, "", data.Gender) + assert.Equal(t, "", data.Location) + assert.Nil(t, data.Value) + }) +} diff --git a/pkg/i18n/grammar.go b/pkg/i18n/grammar.go new file mode 100644 index 00000000..e5c9b7cd --- /dev/null +++ b/pkg/i18n/grammar.go @@ -0,0 +1,505 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import ( + "strings" + "text/template" + "unicode" +) + +// VerbForms holds irregular verb conjugations. +type VerbForms struct { + Past string // Past tense (e.g., "deleted") + Gerund string // Present participle (e.g., "deleting") +} + +// irregularVerbs maps base verbs to their irregular forms. +var irregularVerbs = map[string]VerbForms{ + "be": {Past: "was", Gerund: "being"}, + "have": {Past: "had", Gerund: "having"}, + "do": {Past: "did", Gerund: "doing"}, + "go": {Past: "went", Gerund: "going"}, + "make": {Past: "made", Gerund: "making"}, + "get": {Past: "got", Gerund: "getting"}, + "run": {Past: "ran", Gerund: "running"}, + "set": {Past: "set", Gerund: "setting"}, + "put": {Past: "put", Gerund: "putting"}, + "cut": {Past: "cut", Gerund: "cutting"}, + "let": {Past: "let", Gerund: "letting"}, + "hit": {Past: "hit", Gerund: "hitting"}, + "shut": {Past: "shut", Gerund: "shutting"}, + "split": {Past: "split", Gerund: "splitting"}, + "spread": {Past: "spread", Gerund: "spreading"}, + "read": {Past: "read", Gerund: "reading"}, + "write": {Past: "wrote", Gerund: "writing"}, + "send": {Past: "sent", Gerund: "sending"}, + "build": {Past: "built", Gerund: "building"}, + "begin": {Past: "began", Gerund: "beginning"}, + "find": {Past: "found", Gerund: "finding"}, + "take": {Past: "took", Gerund: "taking"}, + "see": {Past: "saw", Gerund: "seeing"}, + "keep": {Past: "kept", Gerund: "keeping"}, + "hold": {Past: "held", Gerund: "holding"}, + "tell": {Past: "told", Gerund: "telling"}, + "bring": {Past: "brought", Gerund: "bringing"}, + "think": {Past: "thought", Gerund: "thinking"}, + "buy": {Past: "bought", Gerund: "buying"}, + "catch": {Past: "caught", Gerund: "catching"}, + "teach": {Past: "taught", Gerund: "teaching"}, + "throw": {Past: "threw", Gerund: "throwing"}, + "grow": {Past: "grew", Gerund: "growing"}, + "know": {Past: "knew", Gerund: "knowing"}, + "show": {Past: "showed", Gerund: "showing"}, + "draw": {Past: "drew", Gerund: "drawing"}, + "break": {Past: "broke", Gerund: "breaking"}, + "speak": {Past: "spoke", Gerund: "speaking"}, + "choose": {Past: "chose", Gerund: "choosing"}, + "forget": {Past: "forgot", Gerund: "forgetting"}, + "lose": {Past: "lost", Gerund: "losing"}, + "win": {Past: "won", Gerund: "winning"}, + "swim": {Past: "swam", Gerund: "swimming"}, + "drive": {Past: "drove", Gerund: "driving"}, + "rise": {Past: "rose", Gerund: "rising"}, + "shine": {Past: "shone", Gerund: "shining"}, + "sing": {Past: "sang", Gerund: "singing"}, + "ring": {Past: "rang", Gerund: "ringing"}, + "drink": {Past: "drank", Gerund: "drinking"}, + "sink": {Past: "sank", Gerund: "sinking"}, + "sit": {Past: "sat", Gerund: "sitting"}, + "stand": {Past: "stood", Gerund: "standing"}, + "hang": {Past: "hung", Gerund: "hanging"}, + "dig": {Past: "dug", Gerund: "digging"}, + "stick": {Past: "stuck", Gerund: "sticking"}, + "bite": {Past: "bit", Gerund: "biting"}, + "hide": {Past: "hid", Gerund: "hiding"}, + "feed": {Past: "fed", Gerund: "feeding"}, + "meet": {Past: "met", Gerund: "meeting"}, + "lead": {Past: "led", Gerund: "leading"}, + "sleep": {Past: "slept", Gerund: "sleeping"}, + "feel": {Past: "felt", Gerund: "feeling"}, + "leave": {Past: "left", Gerund: "leaving"}, + "mean": {Past: "meant", Gerund: "meaning"}, + "lend": {Past: "lent", Gerund: "lending"}, + "spend": {Past: "spent", Gerund: "spending"}, + "bend": {Past: "bent", Gerund: "bending"}, + "deal": {Past: "dealt", Gerund: "dealing"}, + "lay": {Past: "laid", Gerund: "laying"}, + "pay": {Past: "paid", Gerund: "paying"}, + "say": {Past: "said", Gerund: "saying"}, + "sell": {Past: "sold", Gerund: "selling"}, + "seek": {Past: "sought", Gerund: "seeking"}, + "fight": {Past: "fought", Gerund: "fighting"}, + "fly": {Past: "flew", Gerund: "flying"}, + "wear": {Past: "wore", Gerund: "wearing"}, + "tear": {Past: "tore", Gerund: "tearing"}, + "bear": {Past: "bore", Gerund: "bearing"}, + "swear": {Past: "swore", Gerund: "swearing"}, + "wake": {Past: "woke", Gerund: "waking"}, + "freeze": {Past: "froze", Gerund: "freezing"}, + "steal": {Past: "stole", Gerund: "stealing"}, +} + +// PastTense returns the past tense of a verb. +// Handles irregular verbs and applies regular rules for others. +// +// PastTense("delete") // "deleted" +// PastTense("run") // "ran" +// PastTense("copy") // "copied" +func PastTense(verb string) string { + verb = strings.ToLower(strings.TrimSpace(verb)) + if verb == "" { + return "" + } + + // Check irregular verbs first + if forms, ok := irregularVerbs[verb]; ok { + return forms.Past + } + + return applyRegularPastTense(verb) +} + +// applyRegularPastTense applies regular past tense rules. +func applyRegularPastTense(verb string) string { + // Already ends in -ed + if strings.HasSuffix(verb, "ed") { + return verb + } + + // Ends in -e: just add -d + if strings.HasSuffix(verb, "e") { + return verb + "d" + } + + // Ends in consonant + y: change y to ied + if strings.HasSuffix(verb, "y") && len(verb) > 1 { + prev := rune(verb[len(verb)-2]) + if !isVowel(prev) { + return verb[:len(verb)-1] + "ied" + } + } + + // Ends in single vowel + single consonant (CVC pattern): double consonant + if len(verb) >= 2 && shouldDoubleConsonant(verb) { + return verb + string(verb[len(verb)-1]) + "ed" + } + + // Default: add -ed + return verb + "ed" +} + +// noDoubleConsonant contains multi-syllable verbs that don't double the final consonant. +var noDoubleConsonant = map[string]bool{ + "open": true, + "listen": true, + "happen": true, + "enter": true, + "offer": true, + "suffer": true, + "differ": true, + "cover": true, + "deliver": true, + "develop": true, + "visit": true, + "limit": true, + "edit": true, + "credit": true, + "orbit": true, + "cancel": true, + "model": true, + "travel": true, + "label": true, + "level": true, + "total": true, + "target": true, + "budget": true, + "market": true, + "benefit": true, + "focus": true, +} + +// shouldDoubleConsonant checks if the final consonant should be doubled. +// Applies to CVC (consonant-vowel-consonant) endings in single-syllable words +// and stressed final syllables in multi-syllable words. +func shouldDoubleConsonant(verb string) bool { + if len(verb) < 3 { + return false + } + + // Check explicit exceptions + if noDoubleConsonant[verb] { + return false + } + + lastChar := rune(verb[len(verb)-1]) + secondLast := rune(verb[len(verb)-2]) + + // Last char must be consonant (not w, x, y) + if isVowel(lastChar) || lastChar == 'w' || lastChar == 'x' || lastChar == 'y' { + return false + } + + // Second to last must be a single vowel + if !isVowel(secondLast) { + return false + } + + // For short words (3-4 chars), always double if CVC pattern + if len(verb) <= 4 { + thirdLast := rune(verb[len(verb)-3]) + return !isVowel(thirdLast) + } + + // For longer words, only double if the pattern is strongly CVC + // (stressed final syllable). This is a simplification - in practice, + // most common multi-syllable verbs either: + // 1. End in a doubled consonant already (e.g., "submit" -> "submitted") + // 2. Don't double (e.g., "open" -> "opened") + // We err on the side of not doubling for longer words + return false +} + +// Gerund returns the present participle (-ing form) of a verb. +// +// Gerund("delete") // "deleting" +// Gerund("run") // "running" +// Gerund("die") // "dying" +func Gerund(verb string) string { + verb = strings.ToLower(strings.TrimSpace(verb)) + if verb == "" { + return "" + } + + // Check irregular verbs first + if forms, ok := irregularVerbs[verb]; ok { + return forms.Gerund + } + + return applyRegularGerund(verb) +} + +// applyRegularGerund applies regular gerund rules. +func applyRegularGerund(verb string) string { + // Ends in -ie: change to -ying + if strings.HasSuffix(verb, "ie") { + return verb[:len(verb)-2] + "ying" + } + + // Ends in -e (but not -ee, -ye, -oe): drop e, add -ing + if strings.HasSuffix(verb, "e") && len(verb) > 1 { + secondLast := rune(verb[len(verb)-2]) + if secondLast != 'e' && secondLast != 'y' && secondLast != 'o' { + return verb[:len(verb)-1] + "ing" + } + } + + // CVC pattern: double final consonant + if shouldDoubleConsonant(verb) { + return verb + string(verb[len(verb)-1]) + "ing" + } + + // Default: add -ing + return verb + "ing" +} + +// irregularNouns maps singular nouns to their irregular plural forms. +var irregularNouns = map[string]string{ + "child": "children", + "person": "people", + "man": "men", + "woman": "women", + "foot": "feet", + "tooth": "teeth", + "mouse": "mice", + "goose": "geese", + "ox": "oxen", + "index": "indices", + "appendix": "appendices", + "matrix": "matrices", + "vertex": "vertices", + "crisis": "crises", + "analysis": "analyses", + "diagnosis": "diagnoses", + "thesis": "theses", + "hypothesis": "hypotheses", + "parenthesis":"parentheses", + "datum": "data", + "medium": "media", + "bacterium": "bacteria", + "criterion": "criteria", + "phenomenon": "phenomena", + "curriculum": "curricula", + "alumnus": "alumni", + "cactus": "cacti", + "focus": "foci", + "fungus": "fungi", + "nucleus": "nuclei", + "radius": "radii", + "stimulus": "stimuli", + "syllabus": "syllabi", + "fish": "fish", + "sheep": "sheep", + "deer": "deer", + "species": "species", + "series": "series", + "aircraft": "aircraft", + "life": "lives", + "wife": "wives", + "knife": "knives", + "leaf": "leaves", + "half": "halves", + "self": "selves", + "shelf": "shelves", + "wolf": "wolves", + "calf": "calves", + "loaf": "loaves", + "thief": "thieves", +} + +// Pluralize returns the plural form of a noun based on count. +// If count is 1, returns the singular form unchanged. +// +// Pluralize("file", 1) // "file" +// Pluralize("file", 5) // "files" +// Pluralize("child", 3) // "children" +// Pluralize("box", 2) // "boxes" +func Pluralize(noun string, count int) string { + if count == 1 { + return noun + } + return PluralForm(noun) +} + +// PluralForm returns the plural form of a noun. +// +// PluralForm("file") // "files" +// PluralForm("child") // "children" +// PluralForm("box") // "boxes" +func PluralForm(noun string) string { + noun = strings.TrimSpace(noun) + if noun == "" { + return "" + } + + lower := strings.ToLower(noun) + + // Check irregular nouns + if plural, ok := irregularNouns[lower]; ok { + // Preserve original casing if title case + if unicode.IsUpper(rune(noun[0])) { + return strings.ToUpper(string(plural[0])) + plural[1:] + } + return plural + } + + return applyRegularPlural(noun) +} + +// applyRegularPlural applies regular plural rules. +func applyRegularPlural(noun string) string { + lower := strings.ToLower(noun) + + // Words ending in -s, -ss, -sh, -ch, -x, -z: add -es + if strings.HasSuffix(lower, "s") || + strings.HasSuffix(lower, "ss") || + strings.HasSuffix(lower, "sh") || + strings.HasSuffix(lower, "ch") || + strings.HasSuffix(lower, "x") || + strings.HasSuffix(lower, "z") { + return noun + "es" + } + + // Words ending in consonant + y: change y to ies + if strings.HasSuffix(lower, "y") && len(noun) > 1 { + prev := rune(lower[len(lower)-2]) + if !isVowel(prev) { + return noun[:len(noun)-1] + "ies" + } + } + + // Words ending in -f or -fe: change to -ves (some exceptions already in irregulars) + if strings.HasSuffix(lower, "f") { + return noun[:len(noun)-1] + "ves" + } + if strings.HasSuffix(lower, "fe") { + return noun[:len(noun)-2] + "ves" + } + + // Words ending in -o preceded by consonant: add -es + if strings.HasSuffix(lower, "o") && len(noun) > 1 { + prev := rune(lower[len(lower)-2]) + if !isVowel(prev) { + // Many exceptions (photos, pianos) - but common tech terms add -es + if lower == "hero" || lower == "potato" || lower == "tomato" || lower == "echo" || lower == "veto" { + return noun + "es" + } + } + } + + // Default: add -s + return noun + "s" +} + +// vowelSounds contains words that start with consonants but have vowel sounds. +// These take "an" instead of "a". +var vowelSounds = map[string]bool{ + "hour": true, + "honest": true, + "honour": true, + "honor": true, + "heir": true, + "herb": true, // US pronunciation +} + +// consonantSounds contains words that start with vowels but have consonant sounds. +// These take "a" instead of "an". +var consonantSounds = map[string]bool{ + "user": true, // "yoo-zer" + "union": true, // "yoon-yon" + "unique": true, + "unit": true, + "universe": true, + "university": true, + "uniform": true, + "usage": true, + "usual": true, + "utility": true, + "utensil": true, + "one": true, // "wun" + "once": true, + "euro": true, // "yoo-ro" + "eulogy": true, + "euphemism": true, +} + +// Article returns the appropriate indefinite article ("a" or "an") for a word. +// +// Article("file") // "a" +// Article("error") // "an" +// Article("user") // "a" (sounds like "yoo-zer") +// Article("hour") // "an" (silent h) +func Article(word string) string { + if word == "" { + return "a" + } + + lower := strings.ToLower(strings.TrimSpace(word)) + + // Check for consonant sounds (words starting with vowels but sounding like consonants) + for key := range consonantSounds { + if strings.HasPrefix(lower, key) { + return "a" + } + } + + // Check for vowel sounds (words starting with consonants but sounding like vowels) + for key := range vowelSounds { + if strings.HasPrefix(lower, key) { + return "an" + } + } + + // Check first letter + if len(lower) > 0 && isVowel(rune(lower[0])) { + return "an" + } + + return "a" +} + +// isVowel returns true if the rune is a vowel (a, e, i, o, u). +func isVowel(r rune) bool { + switch unicode.ToLower(r) { + case 'a', 'e', 'i', 'o', 'u': + return true + } + return false +} + +// Title capitalizes the first letter of each word. +func Title(s string) string { + return strings.Title(s) //nolint:staticcheck // strings.Title is fine for our use case +} + +// Quote wraps a string in double quotes. +func Quote(s string) string { + return `"` + s + `"` +} + +// TemplateFuncs returns the template.FuncMap with all grammar functions. +// Use this to add grammar helpers to your templates. +// +// tmpl := template.New("").Funcs(i18n.TemplateFuncs()) +func TemplateFuncs() template.FuncMap { + return template.FuncMap{ + "title": Title, + "lower": strings.ToLower, + "upper": strings.ToUpper, + "past": PastTense, + "gerund": Gerund, + "plural": Pluralize, + "pluralForm": PluralForm, + "article": Article, + "quote": Quote, + } +} diff --git a/pkg/i18n/grammar_test.go b/pkg/i18n/grammar_test.go new file mode 100644 index 00000000..0cc56da6 --- /dev/null +++ b/pkg/i18n/grammar_test.go @@ -0,0 +1,303 @@ +package i18n + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPastTense(t *testing.T) { + tests := []struct { + verb string + expected string + }{ + // Irregular verbs + {"be", "was"}, + {"have", "had"}, + {"do", "did"}, + {"go", "went"}, + {"make", "made"}, + {"get", "got"}, + {"run", "ran"}, + {"write", "wrote"}, + {"build", "built"}, + {"find", "found"}, + {"keep", "kept"}, + {"think", "thought"}, + + // Regular verbs - ends in -e + {"delete", "deleted"}, + {"save", "saved"}, + {"create", "created"}, + {"update", "updated"}, + {"remove", "removed"}, + + // Regular verbs - consonant + y -> ied + {"copy", "copied"}, + {"carry", "carried"}, + {"try", "tried"}, + + // Regular verbs - vowel + y -> yed + {"play", "played"}, + {"stay", "stayed"}, + {"enjoy", "enjoyed"}, + + // Regular verbs - CVC doubling + {"stop", "stopped"}, + {"drop", "dropped"}, + {"plan", "planned"}, + + // Regular verbs - no doubling + {"install", "installed"}, + {"open", "opened"}, + {"start", "started"}, + + // Edge cases + {"", ""}, + {" delete ", "deleted"}, + } + + for _, tt := range tests { + t.Run(tt.verb, func(t *testing.T) { + result := PastTense(tt.verb) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGerund(t *testing.T) { + tests := []struct { + verb string + expected string + }{ + // Irregular verbs + {"be", "being"}, + {"have", "having"}, + {"run", "running"}, + {"write", "writing"}, + + // Regular verbs - drop -e + {"delete", "deleting"}, + {"save", "saving"}, + {"create", "creating"}, + {"update", "updating"}, + + // Regular verbs - ie -> ying + {"die", "dying"}, + {"lie", "lying"}, + {"tie", "tying"}, + + // Regular verbs - CVC doubling + {"stop", "stopping"}, + {"run", "running"}, + {"plan", "planning"}, + + // Regular verbs - no doubling + {"install", "installing"}, + {"open", "opening"}, + {"start", "starting"}, + {"play", "playing"}, + + // Edge cases + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.verb, func(t *testing.T) { + result := Gerund(tt.verb) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPluralize(t *testing.T) { + tests := []struct { + noun string + count int + expected string + }{ + // Singular (count = 1) + {"file", 1, "file"}, + {"repo", 1, "repo"}, + + // Regular plurals + {"file", 2, "files"}, + {"repo", 5, "repos"}, + {"user", 0, "users"}, + + // -s, -ss, -sh, -ch, -x, -z -> -es + {"bus", 2, "buses"}, + {"class", 3, "classes"}, + {"bush", 2, "bushes"}, + {"match", 2, "matches"}, + {"box", 2, "boxes"}, + + // consonant + y -> -ies + {"city", 2, "cities"}, + {"repository", 3, "repositories"}, + {"copy", 2, "copies"}, + + // vowel + y -> -ys + {"key", 2, "keys"}, + {"day", 2, "days"}, + {"toy", 2, "toys"}, + + // Irregular nouns + {"child", 2, "children"}, + {"person", 3, "people"}, + {"man", 2, "men"}, + {"woman", 2, "women"}, + {"foot", 2, "feet"}, + {"tooth", 2, "teeth"}, + {"mouse", 2, "mice"}, + {"index", 2, "indices"}, + + // Unchanged plurals + {"fish", 2, "fish"}, + {"sheep", 2, "sheep"}, + {"deer", 2, "deer"}, + {"species", 2, "species"}, + } + + for _, tt := range tests { + t.Run(tt.noun, func(t *testing.T) { + result := Pluralize(tt.noun, tt.count) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPluralForm(t *testing.T) { + tests := []struct { + noun string + expected string + }{ + // Regular + {"file", "files"}, + {"repo", "repos"}, + + // -es endings + {"box", "boxes"}, + {"class", "classes"}, + {"bush", "bushes"}, + {"match", "matches"}, + + // -ies endings + {"city", "cities"}, + {"copy", "copies"}, + + // Irregular + {"child", "children"}, + {"person", "people"}, + + // Title case preservation + {"Child", "Children"}, + {"Person", "People"}, + + // Empty + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.noun, func(t *testing.T) { + result := PluralForm(tt.noun) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestArticle(t *testing.T) { + tests := []struct { + word string + expected string + }{ + // Regular vowels -> "an" + {"error", "an"}, + {"apple", "an"}, + {"issue", "an"}, + {"update", "an"}, + {"item", "an"}, + {"object", "an"}, + + // Regular consonants -> "a" + {"file", "a"}, + {"repo", "a"}, + {"commit", "a"}, + {"branch", "a"}, + {"test", "a"}, + + // Consonant sounds despite vowel start -> "a" + {"user", "a"}, + {"union", "a"}, + {"unique", "a"}, + {"unit", "a"}, + {"universe", "a"}, + {"one", "a"}, + {"once", "a"}, + {"euro", "a"}, + + // Vowel sounds despite consonant start -> "an" + {"hour", "an"}, + {"honest", "an"}, + {"honour", "an"}, + {"heir", "an"}, + + // Edge cases + {"", "a"}, + {" error ", "an"}, + } + + for _, tt := range tests { + t.Run(tt.word, func(t *testing.T) { + result := Article(tt.word) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTitle(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello world", "Hello World"}, + {"file deleted", "File Deleted"}, + {"ALREADY CAPS", "ALREADY CAPS"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := Title(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestQuote(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"file.txt", `"file.txt"`}, + {"", `""`}, + {"hello world", `"hello world"`}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := Quote(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTemplateFuncs(t *testing.T) { + funcs := TemplateFuncs() + + // Check all expected functions are present + expectedFuncs := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"} + for _, name := range expectedFuncs { + assert.Contains(t, funcs, name, "TemplateFuncs should contain %s", name) + } +} diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 1fc88517..cca04288 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -59,6 +59,7 @@ type Service struct { currentLang string fallbackLang string availableLangs []language.Tag + mode Mode // Translation mode (Normal, Strict, Collect) mu sync.RWMutex } @@ -241,6 +242,10 @@ func SetDefault(s *Service) { } // T translates a message using the default service. +// For semantic intents (core.* namespace), pass a Subject as the first argument. +// +// T("cli.success") // Simple translation +// T("core.delete", S("file", "config.yaml")) // Semantic intent func T(messageID string, args ...any) string { if svc := Default(); svc != nil { return svc.T(messageID, args...) @@ -248,6 +253,32 @@ 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 standard gettext-style translation helper. +// Alias for T() - use whichever you prefer. +// +// i18n._("cli.success") +// i18n._("cli.greeting", map[string]any{"Name": "World"}) +func _(messageID string, args ...any) string { + return T(messageID, args...) +} + // --- Service methods --- // SetLanguage sets the language for translations. @@ -294,22 +325,51 @@ func (s *Service) AvailableLanguages() []string { return langs } +// SetMode sets the translation mode for missing key handling. +func (s *Service) SetMode(m Mode) { + s.mu.Lock() + defer s.mu.Unlock() + s.mode = m +} + +// Mode returns the current translation mode. +func (s *Service) Mode() Mode { + s.mu.RLock() + defer s.mu.RUnlock() + return s.mode +} + // T translates a message by its ID. // Optional template data can be passed for interpolation. // // For plural messages, pass a map with "Count" to select the form: // // svc.T("cli.count.items", map[string]any{"Count": 5}) +// +// For semantic intents (core.* namespace), pass a Subject to get the Question form: +// +// svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?" func (s *Service) T(messageID string, args ...any) string { s.mu.RLock() defer s.mu.RUnlock() + // Check for semantic intent with Subject + if strings.HasPrefix(messageID, "core.") && len(args) > 0 { + if subject, ok := args[0].(*Subject); ok { + // Use C() to resolve the intent, return Question form + s.mu.RUnlock() + result := s.C(messageID, subject) + s.mu.RLock() + return result.Question + } + } + // Try current language, then fallback msg, ok := s.getMessage(s.currentLang, messageID) if !ok { msg, ok = s.getMessage(s.fallbackLang, messageID) if !ok { - return messageID + return s.handleMissingKey(messageID, args) } } @@ -334,7 +394,7 @@ func (s *Service) T(messageID string, args ...any) string { } if text == "" { - return messageID + return s.handleMissingKey(messageID, args) } // Apply template if we have data @@ -345,6 +405,98 @@ func (s *Service) T(messageID string, args ...any) string { return text } +// handleMissingKey handles a missing translation key based on the current mode. +// Must be called with s.mu.RLock held. +func (s *Service) handleMissingKey(key string, args []any) string { + switch s.mode { + case ModeStrict: + panic(fmt.Sprintf("i18n: missing translation key %q", key)) + case ModeCollect: + // Convert args to map for the action + var argsMap map[string]any + if len(args) > 0 { + if m, ok := args[0].(map[string]any); ok { + argsMap = m + } + } + dispatchMissingKey(key, argsMap) + return "[" + key + "]" + default: + return key + } +} + +// 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) + + return &Composed{ + Question: executeIntentTemplate(intentDef.Question, data), + Confirm: executeIntentTemplate(intentDef.Confirm, data), + Success: executeIntentTemplate(intentDef.Success, data), + Failure: executeIntentTemplate(intentDef.Failure, data), + Meta: intentDef.Meta, + } +} + +// executeIntentTemplate executes an intent template with the given data. +func executeIntentTemplate(tmplStr string, data templateData) string { + if tmplStr == "" { + return "" + } + + tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr) + if err != nil { + return tmplStr + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return tmplStr + } + return buf.String() +} + +// _ is the standard gettext-style translation helper. Alias for T(). +func (s *Service) _(messageID string, args ...any) string { + return s.T(messageID, args...) +} + func (s *Service) getMessage(lang, key string) (Message, bool) { msgs, ok := s.messages[lang] if !ok { diff --git a/pkg/i18n/intents.go b/pkg/i18n/intents.go new file mode 100644 index 00000000..e5ddd3f1 --- /dev/null +++ b/pkg/i18n/intents.go @@ -0,0 +1,463 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +// 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", + }, +} + +// getIntent retrieves an intent by its key from the core intents. +// Returns nil if the intent is not found. +func getIntent(key string) *Intent { + if intent, ok := coreIntents[key]; ok { + return &intent + } + return nil +} + +// RegisterIntent adds a custom intent to the core intents. +// Use this to extend the built-in intents with application-specific ones. +// +// 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) { + coreIntents[key] = intent +} + +// IntentKeys returns all registered intent keys. +func IntentKeys() []string { + keys := make([]string, 0, len(coreIntents)) + for key := range coreIntents { + keys = append(keys, key) + } + return keys +} diff --git a/pkg/i18n/intents_test.go b/pkg/i18n/intents_test.go new file mode 100644 index 00000000..a0e27a0b --- /dev/null +++ b/pkg/i18n/intents_test.go @@ -0,0 +1,230 @@ +package i18n + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + }) + + 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{ + Meta: IntentMeta{ + Type: "action", + Verb: "custom", + 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 + delete(coreIntents, "test.custom") +} + +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) + }) + } +} + +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) + } +} + +func TestCoreIntents_Templates(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + 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 T with core.* prefix and Subject should return Question form + result := svc.T("core.delete", S("file", "config.yaml")) + assert.Equal(t, "Delete config.yaml?", result) + + // Using T with regular key should work normally + result = svc.T("cli.success") + assert.Equal(t, "Success", 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) { + for key := range coreIntents { + assert.True(t, strings.HasPrefix(key, "core."), + "intent key %q should be prefixed with 'core.'", key) + } +} diff --git a/pkg/i18n/mode.go b/pkg/i18n/mode.go new file mode 100644 index 00000000..b76dbda9 --- /dev/null +++ b/pkg/i18n/mode.go @@ -0,0 +1,74 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import ( + "runtime" +) + +// Mode determines how the i18n service handles missing translation keys. +type Mode int + +const ( + // ModeNormal returns the key as-is when a translation is missing (production). + ModeNormal Mode = iota + // ModeStrict panics immediately when a translation is missing (dev/CI). + ModeStrict + // ModeCollect dispatches a MissingKeyAction and returns [key] (QA testing). + ModeCollect +) + +// String returns the string representation of the mode. +func (m Mode) String() string { + switch m { + case ModeNormal: + return "normal" + case ModeStrict: + return "strict" + case ModeCollect: + return "collect" + default: + return "unknown" + } +} + +// MissingKeyAction is dispatched when a translation key is not found in collect mode. +// It contains caller information for debugging and QA purposes. +type MissingKeyAction struct { + Key string // The missing translation key + Args map[string]any // Arguments passed to the translation + CallerFile string // Source file where T() was called + CallerLine int // Line number where T() was called +} + +// ActionHandler is a function that handles MissingKeyAction dispatches. +// Register handlers via SetActionHandler to receive missing key notifications. +type ActionHandler func(action MissingKeyAction) + +var actionHandler ActionHandler + +// SetActionHandler registers a handler for MissingKeyAction dispatches. +// Only one handler can be active at a time; subsequent calls replace the previous handler. +func SetActionHandler(h ActionHandler) { + actionHandler = h +} + +// dispatchMissingKey creates and dispatches a MissingKeyAction. +// Called internally when a key is missing in collect mode. +func dispatchMissingKey(key string, args map[string]any) { + if actionHandler == nil { + return + } + + _, file, line, ok := runtime.Caller(2) // Skip dispatchMissingKey and handleMissingKey + if !ok { + file = "unknown" + line = 0 + } + + actionHandler(MissingKeyAction{ + Key: key, + Args: args, + CallerFile: file, + CallerLine: line, + }) +} diff --git a/pkg/i18n/mode_test.go b/pkg/i18n/mode_test.go new file mode 100644 index 00000000..668849c3 --- /dev/null +++ b/pkg/i18n/mode_test.go @@ -0,0 +1,196 @@ +package i18n + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMode_String(t *testing.T) { + tests := []struct { + mode Mode + expected string + }{ + {ModeNormal, "normal"}, + {ModeStrict, "strict"}, + {ModeCollect, "collect"}, + {Mode(99), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.mode.String()) + }) + } +} + +func TestMissingKeyAction(t *testing.T) { + action := MissingKeyAction{ + Key: "test.missing.key", + Args: map[string]any{"Name": "test"}, + CallerFile: "/path/to/file.go", + CallerLine: 42, + } + + assert.Equal(t, "test.missing.key", action.Key) + assert.Equal(t, "test", action.Args["Name"]) + assert.Equal(t, "/path/to/file.go", action.CallerFile) + assert.Equal(t, 42, action.CallerLine) +} + +func TestSetActionHandler(t *testing.T) { + // Reset handler after test + defer SetActionHandler(nil) + + t.Run("sets handler", func(t *testing.T) { + var received MissingKeyAction + SetActionHandler(func(action MissingKeyAction) { + received = action + }) + + dispatchMissingKey("test.key", map[string]any{"foo": "bar"}) + + assert.Equal(t, "test.key", received.Key) + assert.Equal(t, "bar", received.Args["foo"]) + }) + + t.Run("nil handler", func(t *testing.T) { + SetActionHandler(nil) + // Should not panic + dispatchMissingKey("test.key", nil) + }) + + t.Run("replaces previous handler", func(t *testing.T) { + called1 := false + called2 := false + + SetActionHandler(func(action MissingKeyAction) { + called1 = true + }) + SetActionHandler(func(action MissingKeyAction) { + called2 = true + }) + + dispatchMissingKey("test.key", nil) + + assert.False(t, called1) + assert.True(t, called2) + }) +} + +func TestServiceMode(t *testing.T) { + // Reset default service after tests + originalService := defaultService + defer func() { + defaultService = originalService + }() + + t.Run("default mode is normal", func(t *testing.T) { + defaultService = nil + defaultOnce = sync.Once{} + defaultErr = nil + + svc, err := New() + require.NoError(t, err) + + assert.Equal(t, ModeNormal, svc.Mode()) + }) + + t.Run("set mode", func(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + svc.SetMode(ModeStrict) + assert.Equal(t, ModeStrict, svc.Mode()) + + svc.SetMode(ModeCollect) + assert.Equal(t, ModeCollect, svc.Mode()) + + svc.SetMode(ModeNormal) + assert.Equal(t, ModeNormal, svc.Mode()) + }) +} + +func TestModeNormal_MissingKey(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + svc.SetMode(ModeNormal) + + // Missing key should return the key itself + result := svc.T("nonexistent.key") + assert.Equal(t, "nonexistent.key", result) +} + +func TestModeStrict_MissingKey(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + svc.SetMode(ModeStrict) + + // Missing key should panic + assert.Panics(t, func() { + svc.T("nonexistent.key") + }) +} + +func TestModeCollect_MissingKey(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 key should dispatch action and return [key] + result := svc.T("nonexistent.key", map[string]any{"arg": "value"}) + + assert.Equal(t, "[nonexistent.key]", result) + assert.Equal(t, "nonexistent.key", received.Key) + assert.Equal(t, "value", received.Args["arg"]) + assert.NotEmpty(t, received.CallerFile) + 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")) + }) +}