Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,9 @@ linters:
desc: Use github.com/go-resty/resty/v2 instead
- pkg: github.com/aws/smithy-go/ptr$
desc: Use github.com/aws/aws-sdk-go-v2/aws instead

exclusions:
rules:
- path: _test\.go
linters:
- goconst
2 changes: 2 additions & 0 deletions .serena/memories/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ navigation and code-level detail that AGENTS.md does not spell out.
```
cmd/mint/main.go # entry; cobra root via newRootCmd() factory; viper wiring; target-lang resolution
internal/llm/llm.go # Completer interface: Complete(ctx, system, user string, w io.Writer) (Usage, error); Usage = token counts
internal/llm/writer.go # TrailingNewlineWriter: wraps io.Writer, guarantees exactly one trailing '\n'; providers stream tokens through it + call Done()
internal/httpx/httpx.go # New() *http.Client — shared tuned transport (proxy-from-env, HTTP/2, conn pool); every provider uses it instead of http.DefaultClient
internal/provider/config.go # Config struct; Config.ValidateConfig(); provider-name constants
internal/provider/provider.go # NewCompleter(cfg) factory — dispatches on cfg.Provider
internal/provider/googlegenai/ # Google Gemini HTTP client (implements Completer)
Expand Down
36 changes: 33 additions & 3 deletions cmd/mint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func newRootCmd() *cobra.Command {
// than treated as already-target text.
sourceLang := resolveSourceLang(sourceLangFlag)
if sourceLang != "" {
logv("source language: %s", sourceLang)
logv("source language: %s", canonicalLangTag(sourceLang))
}

var totalUsage llm.Usage
Expand Down Expand Up @@ -180,7 +180,7 @@ func newRootCmd() *cobra.Command {
return fmt.Errorf("language detection failed: %w", err)
}

logv("detected input language: %q", inputLang)
logv("detected input language: %q", canonicalLangTag(inputLang))

// Language-neutral content (numbers, symbols): output unchanged,
// no rewrite call needed.
Expand All @@ -198,7 +198,7 @@ func newRootCmd() *cobra.Command {
actualTargetLang = determineActualTargetLang(inputLang, targetLangs)
}

logv("target language: %s", actualTargetLang)
logv("target language: %s", canonicalLangTag(actualTargetLang))

// Rewrite the input in the target language, correcting grammar and
// spelling along the way. Anchoring on the target tag also pins the
Expand Down Expand Up @@ -264,6 +264,36 @@ func resolveInput(args []string) (string, error) {
// normLang lowercases and trims whitespace from a language tag.
func normLang(s string) string { return strings.ToLower(strings.TrimSpace(s)) }

// canonicalLangTag reformats a normalized BCP-47 tag into its conventional
// subtag casing for display: a lowercase primary language subtag, an uppercase
// two-letter region subtag (e.g. "zh-tw" → "zh-TW"), and a title-case
// four-letter script subtag (e.g. "zh-hant" → "zh-Hant"). Tags are lowercased
// internally for case-insensitive matching (see normLang); this is purely a
// presentation helper for verbose diagnostics and does not affect matching.
func canonicalLangTag(tag string) string {
if tag == "" {
return tag
}

parts := strings.Split(tag, "-")
for i, p := range parts {
switch {
case i == 0:
parts[i] = strings.ToLower(p)
case len(p) == 2:
// Region subtag (e.g. TW, US).
parts[i] = strings.ToUpper(p)
case len(p) == 4:
// Script subtag (e.g. Hant) — title case.
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
default:
parts[i] = strings.ToLower(p)
}
}

return strings.Join(parts, "-")
}

// resolveTargetLangs resolves target languages based on priority:
// 1. Flag Lang Code (--target / -t) - single language only
// 2. Config Lang Code (MINT_TARGET_LANG) - single or comma-separated languages
Expand Down
87 changes: 52 additions & 35 deletions cmd/mint/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,16 @@ func (m *mockCompleter) Complete(_ context.Context, _, _ string, w io.Writer) (l
return llm.Usage{}, nil
}

const (
langZhTW = "zh-TW"
langZhTw = "zh-tw"
argTarget = "--target"
inputHello = "Hello"
inputhello = "hello"
)

func TestLangMatches(t *testing.T) {
tests := []struct {
a, b string
want bool
}{
{"en", "en", true},
{"en", "fr", false},
{langZhTW, "zh-HK", true},
{langZhTW, "zh", true},
{"zh", langZhTW, true},
{"zh-TW", "zh-HK", true},
{"zh-TW", "zh", true},
{"zh", "zh-TW", true},
{"en-US", "en-GB", true},
{"en", "en-US", true},
{"", "", true},
Expand All @@ -72,11 +64,11 @@ func TestDetermineActualTargetLang(t *testing.T) {
{"empty target list defaults to en", "fr", nil, "en"},
{"single target returns it directly", "fr", []string{"en"}, "en"},
{"single target same as input (correction mode)", "en", []string{"en"}, "en"},
{"input matches first returns second", "en", []string{"en", langZhTW}, langZhTW},
{"input matches middle returns next", langZhTW, []string{"en", langZhTW, "ja"}, "ja"},
{"input matches last wraps to first", "ja", []string{"en", langZhTW, "ja"}, "en"},
{"input not in list returns first", "fr", []string{"en", langZhTW}, "en"},
{"match by primary subtag zh-HK → zh-TW slot", "zh-HK", []string{"en", langZhTW, "ja"}, "ja"},
{"input matches first returns second", "en", []string{"en", "zh-TW"}, "zh-TW"},
{"input matches middle returns next", "zh-TW", []string{"en", "zh-TW", "ja"}, "ja"},
{"input matches last wraps to first", "ja", []string{"en", "zh-TW", "ja"}, "en"},
{"input not in list returns first", "fr", []string{"en", "zh-TW"}, "en"},
{"match by primary subtag zh-HK → zh-TW slot", "zh-HK", []string{"en", "zh-TW", "ja"}, "ja"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -97,13 +89,13 @@ func TestResolveTargetLangs(t *testing.T) {
}{
{"flag takes priority over config", "ja", "en,zh-TW", []string{"ja"}},
{"flag with comma uses first part only", "en,zh-TW", "", []string{"en"}},
{"flag normalized to lowercase", "ZH-TW", "", []string{langZhTw}},
{"flag normalized to lowercase", "ZH-TW", "", []string{"zh-tw"}},
{"flag trimmed of whitespace", " fr ", "", []string{"fr"}},
{"config single lang", "", "fr", []string{"fr"}},
{"config multiple langs", "", "en,zh-TW,ja", []string{"en", langZhTw, "ja"}},
{"config langs trimmed and lowercased", "", " EN , ZH-TW ", []string{"en", langZhTw}},
{"config multiple langs", "", "en,zh-TW,ja", []string{"en", "zh-tw", "ja"}},
{"config langs trimmed and lowercased", "", " EN , ZH-TW ", []string{"en", "zh-tw"}},
{"config trailing comma ignored", "", "en,", []string{"en"}},
{"config double comma ignored", "", "en,,zh-TW", []string{"en", langZhTw}},
{"config double comma ignored", "", "en,,zh-TW", []string{"en", "zh-tw"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -142,7 +134,7 @@ func TestNormalizeDetectedLang(t *testing.T) {
want string
}{
{"plain tag", "en", "en"},
{"trims whitespace", " zh-TW \n", langZhTw},
{"trims whitespace", " zh-TW \n", "zh-tw"},
{"lowercases", "JA", "ja"},
{"strips quotes", `"fr"`, "fr"},
{"strips backticks", "`de`", "de"},
Expand All @@ -160,15 +152,40 @@ func TestNormalizeDetectedLang(t *testing.T) {
}
}

func TestCanonicalLangTag(t *testing.T) {
tests := []struct {
name string
tag string
want string
}{
{"empty passes through", "", ""},
{"language only", "en", "en"},
{"language only ja", "ja", "ja"},
{"region uppercased", "zh-tw", "zh-TW"},
{"region uppercased zh-hk", "zh-hk", "zh-HK"},
{"region uppercased zh-cn", "zh-cn", "zh-CN"},
{"script title-cased", "zh-hant", "zh-Hant"},
{"already canonical is idempotent", "zh-TW", "zh-TW"},
{"mixed case normalized", "ZH-Tw", "zh-TW"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := canonicalLangTag(tt.tag); got != tt.want {
t.Errorf("canonicalLangTag(%q) = %q, want %q", tt.tag, got, tt.want)
}
})
}
}

func TestResolveInput(t *testing.T) {
t.Run("positional arg used directly", func(t *testing.T) {
got, err := resolveInput([]string{inputhello})
got, err := resolveInput([]string{"hello"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if got != inputhello {
t.Errorf("got %q, want %q", got, inputhello)
if got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
})

Expand Down Expand Up @@ -362,7 +379,7 @@ func TestNewRootCmdLangNeutral(t *testing.T) {
flush := captureStdout(t)

cmd := newRootCmd()
cmd.SetArgs([]string{argTarget, "en", "12345"})
cmd.SetArgs([]string{"--target", "en", "12345"})

if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -391,7 +408,7 @@ func TestNewRootCmdTranslation(t *testing.T) {
flush := captureStdout(t)

cmd := newRootCmd()
cmd.SetArgs([]string{argTarget, "fr", "--verbose", inputHello})
cmd.SetArgs([]string{"--target", "fr", "--verbose", "Hello"})

if err := cmd.ExecuteContext(context.Background()); err != nil {
_ = flush()
Expand All @@ -418,7 +435,7 @@ func TestNewRootCmdTranslationError(t *testing.T) {
t.Setenv("MINT_MODEL_NAME", "test-model")

cmd := newRootCmd()
cmd.SetArgs([]string{argTarget, "fr", inputHello})
cmd.SetArgs([]string{"--target", "fr", "Hello"})

err := cmd.ExecuteContext(context.Background())
if err == nil {
Expand Down Expand Up @@ -535,7 +552,7 @@ func TestNewRootCmdDetectLangError(t *testing.T) {
t.Setenv("MINT_TARGET_LANG", "en,fr")

cmd := newRootCmd()
cmd.SetArgs([]string{inputHello})
cmd.SetArgs([]string{"Hello"})

err := cmd.ExecuteContext(context.Background())
if err == nil {
Expand All @@ -552,7 +569,7 @@ func TestNewRootCmdProviderError(t *testing.T) {
t.Setenv("MINT_API_KEY", "test")

cmd := newRootCmd()
cmd.SetArgs([]string{inputHello})
cmd.SetArgs([]string{"Hello"})

if err := cmd.ExecuteContext(context.Background()); err == nil {
t.Error("expected error for invalid provider, got nil")
Expand All @@ -577,7 +594,7 @@ func TestNewRootCmdNoInput(t *testing.T) {
defer func() { os.Stdin = old; _ = r.Close() }()

cmd := newRootCmd()
cmd.SetArgs([]string{argTarget, "en"})
cmd.SetArgs([]string{"--target", "en"})

if err := cmd.ExecuteContext(context.Background()); err == nil {
t.Error("expected error for no input, got nil")
Expand All @@ -590,7 +607,7 @@ func TestRunSuccess(t *testing.T) {

old := os.Args

os.Args = []string{"mint", argTarget, "en", "12345"}
os.Args = []string{"mint", "--target", "en", "12345"}
defer func() { os.Args = old }()

flush := captureStdout(t)
Expand All @@ -606,7 +623,7 @@ func TestRunError(t *testing.T) {

old := os.Args

os.Args = []string{"mint", argTarget, "en", inputhello}
os.Args = []string{"mint", "--target", "en", "hello"}
defer func() { os.Args = old }()

// Suppress stderr to keep test output clean.
Expand Down Expand Up @@ -642,7 +659,7 @@ func TestResolveSourceLang(t *testing.T) {
{"trimmed of whitespace", " ja ", "ja"},
{"comma uses first part only", "fr,en", "fr"},
{"comma with whitespace trimmed", " fr , en ", "fr"},
{"bcp-47 variant preserved", "zh-TW", langZhTw},
{"bcp-47 variant preserved", "zh-TW", "zh-tw"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -712,7 +729,7 @@ func TestBuildRewritePrompt(t *testing.T) {

// Distinct tags sharing a primary subtag are a deliberate script
// conversion (zh-CN → zh-TW) and must keep the source anchor.
scriptSys, _, _ := buildRewritePrompt("zh-cn", langZhTw, "汉字")
scriptSys, _, _ := buildRewritePrompt("zh-cn", "zh-tw", "汉字")
if !strings.Contains(scriptSys, "written in zh-cn") {
t.Errorf("expected source anchor for script conversion, got: %q", scriptSys)
}
Expand Down Expand Up @@ -952,7 +969,7 @@ func TestNewRootCmdSourceLang(t *testing.T) {
flush := captureStdout(t)

cmd := newRootCmd()
cmd.SetArgs([]string{"--source", "fr", argTarget, "en", "chat"})
cmd.SetArgs([]string{"--source", "fr", "--target", "en", "chat"})

if err := cmd.ExecuteContext(context.Background()); err != nil {
_ = flush()
Expand Down
36 changes: 33 additions & 3 deletions docs/manual-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,12 @@ mint "這係一個蘋果" # 這是一個蘋果
mint "这是一个苹果" # 這是一個蘋果
```

`-v` shows `single target — skipping language detection` and `target language: zh-tw` — the
`-v` shows `single target — skipping language detection` and `target language: zh-TW` — the
rewrite prompt handles standardization without needing to detect the input language first.

> **Rotation note:** in a multi-language list (e.g. `zh-TW,en`), zh-HK input occupies
> the zh-TW slot and rotates to `en`, not to `zh-TW`. In multi-target mode `-v` confirms:
> `detected input language: "zh-hk"` → `target language: en`. See section 9 below.
> `detected input language: "zh-HK"` → `target language: en`. See section 9 below.

## 7. Language-neutral pass-through

Expand Down Expand Up @@ -160,7 +160,7 @@ mint "這是一顆蘋果。" # This is an apple. (zh-TW matched at index

> **zh-HK edge case:** zh-HK input matches the zh-TW slot (same primary subtag `zh`) and
> therefore rotates to `en`. Use `-v` to confirm:
> `detected input language: "zh-hk"` → `target language: en`.
> `detected input language: "zh-HK"` → `target language: en`.

## 10. Language rotation — three languages (wrap-around)

Expand Down Expand Up @@ -230,6 +230,36 @@ MINT_PROVIDER=invalid mint -t zh-TW "apple"
# Missing API key (no MINT_BASE_URL set)
unset MINT_API_KEY
mint -t zh-TW "apple" # Error: MINT_API_KEY is required for provider: <provider>

# MINT_BASE_URL set: API key is optional (proxy handles auth)
export MINT_PROVIDER=openai
export MINT_BASE_URL=http://localhost:11434
export MINT_MODEL_NAME=translategemma:4b
unset MINT_API_KEY
mint -t zh-TW "hello" # 你好 (no API key required)
```

Ctrl+C / SIGTERM cancels any in-flight request and exits with code 130.
This applies when the signal arrives while an HTTP request is in progress;
a signal sent before the request starts is handled by the OS default (exit 143 for SIGTERM).

## 14. `MINT_VERBOSE` environment variable

`MINT_VERBOSE=true` is equivalent to the `-v` / `--verbose` flag.

```sh
MINT_VERBOSE=true mint -t zh-TW "apple" # 蘋果 (diagnostics on stderr)
```

Verbose stderr output (single-target mode):
```
[mint] provider: google-genai
[mint] model: gemini-3.1-flash-lite
[mint] single target — skipping language detection
[mint] target language: zh-TW
[mint] tokens: 125 in / 8 out
```

The `model:` line appears only when `MINT_MODEL_NAME` is set (likewise `base_url:` for
`MINT_BASE_URL`); with provider defaults both are omitted, as in the abbreviated example at
the top of this file.
Loading
Loading