diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index d0d1826e..4fc3170b 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -343,3 +343,56 @@ func TestRawBypassesI18nNamespace(t *testing.T) { result = svc.T("i18n.label.status") assert.Equal(t, "Status:", result) } + +func TestFormalityMessageSelection(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + // Add test messages with formality variants + svc.AddMessages("en-GB", map[string]string{ + "greeting": "Hello", + "greeting._formal": "Good morning, sir", + "greeting._informal": "Hey there", + "farewell": "Goodbye", + "farewell._formal": "Farewell", + }) + + t.Run("neutral formality uses base key", func(t *testing.T) { + svc.SetFormality(FormalityNeutral) + assert.Equal(t, "Hello", svc.T("greeting")) + assert.Equal(t, "Goodbye", svc.T("farewell")) + }) + + t.Run("formal uses ._formal variant", func(t *testing.T) { + svc.SetFormality(FormalityFormal) + assert.Equal(t, "Good morning, sir", svc.T("greeting")) + assert.Equal(t, "Farewell", svc.T("farewell")) + }) + + t.Run("informal uses ._informal variant", func(t *testing.T) { + svc.SetFormality(FormalityInformal) + assert.Equal(t, "Hey there", svc.T("greeting")) + // No informal variant for farewell, falls back to base + assert.Equal(t, "Goodbye", svc.T("farewell")) + }) + + t.Run("subject formality overrides service formality", func(t *testing.T) { + svc.SetFormality(FormalityNeutral) + + // Subject with formal overrides neutral service + result := svc.T("greeting", S("user", "test").Formal()) + assert.Equal(t, "Good morning, sir", result) + + // Subject with informal overrides neutral service + result = svc.T("greeting", S("user", "test").Informal()) + assert.Equal(t, "Hey there", result) + }) + + t.Run("subject formality overrides service formal", func(t *testing.T) { + svc.SetFormality(FormalityFormal) + + // Subject with informal overrides formal service + result := svc.T("greeting", S("user", "test").Informal()) + assert.Equal(t, "Hey there", result) + }) +} diff --git a/pkg/i18n/service.go b/pkg/i18n/service.go index 9fd45855..c199df93 100644 --- a/pkg/i18n/service.go +++ b/pkg/i18n/service.go @@ -414,6 +414,24 @@ func (s *Service) resolveWithFallback(messageID string, data any) string { // Returns empty string if not found. // Must be called with s.mu.RLock held. func (s *Service) tryResolve(lang, key string, data any) string { + // Determine effective formality + formality := s.getEffectiveFormality(data) + + // Try formality-specific key first (key._formal or key._informal) + if formality != FormalityNeutral { + formalityKey := key + "._" + formality.String() + if text := s.resolveMessage(lang, formalityKey, data); text != "" { + return text + } + } + + // Fall back to base key + return s.resolveMessage(lang, key, data) +} + +// resolveMessage resolves a single message key without formality fallback. +// Must be called with s.mu.RLock held. +func (s *Service) resolveMessage(lang, key string, data any) string { msg, ok := s.getMessage(lang, key) if !ok { return "" @@ -438,6 +456,28 @@ func (s *Service) tryResolve(lang, key string, data any) string { return text } +// getEffectiveFormality returns the formality to use for translation. +// Priority: Subject.formality > Service.formality > FormalityNeutral +// Must be called with s.mu.RLock held. +func (s *Service) getEffectiveFormality(data any) Formality { + // Check if data is a Subject with explicit formality + if subj, ok := data.(*Subject); ok && subj != nil { + if subj.formality != FormalityNeutral { + return subj.formality + } + } + + // Check if data is a map with Formality field + if m, ok := data.(map[string]any); ok { + if f, ok := m["Formality"].(Formality); ok && f != FormalityNeutral { + return f + } + } + + // Fall back to service default + return s.formality +} + // 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 {