package config import ( "github.com/johannSo/codey/internal/llm" "os" "path/filepath" "strings" "testing" ) // isolate points the config at a temp directory, so a test never reads or // writes the developer's real ~/.config/codey. func isolate(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) return filepath.Join(dir, "codey", Filename) } // legacyPath is the pre-rename location, alongside the isolated new one. func legacyPath(t *testing.T) string { t.Helper() dir := os.Getenv("XDG_CONFIG_HOME") if dir == "" { t.Fatal("legacyPath called outside isolate") } return filepath.Join(dir, legacyDir, Filename) } func writeConfig(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } // Someone who set Codey up before the rename should not be shown the // wizard again: with no config at the new path, the old one still counts. func TestLoadFallsBackToTheLegacyDirectory(t *testing.T) { isolate(t) writeConfig(t, legacyPath(t), `{"model":"claude-sonnet-5","setup":"done"}`) cfg, found, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if !found { t.Fatal("a config under the old name was not found") } if cfg.Model != "claude-sonnet-5" || !cfg.Done() { t.Errorf("the old config did not survive the fallback: %+v", cfg) } } // The fallback is a read, not a migration: the old file is left exactly where // it was, so an older build still finds the settings it wrote. func TestLoadLeavesTheLegacyFileAlone(t *testing.T) { path := isolate(t) old := legacyPath(t) writeConfig(t, old, `{"model":"claude-sonnet-5"}`) if _, _, err := Load(); err != nil { t.Fatalf("Load: %v", err) } if _, err := os.Stat(old); err != nil { t.Errorf("the old config was moved or removed by a read: %v", err) } if _, err := os.Stat(path); err == nil { t.Error("Load wrote to the new path; only Save may do that") } } // Once there is a config under the new name it wins outright, even if a stale // one is still sitting in the old directory. func TestNewConfigBeatsTheLegacyOne(t *testing.T) { path := isolate(t) writeConfig(t, path, `{"model":"claude-opus-5"}`) writeConfig(t, legacyPath(t), `{"model":"claude-sonnet-5"}`) cfg, found, err := Load() if err != nil || !found { t.Fatalf("Load: %v found=%v", err, found) } if cfg.Model != "claude-opus-5" { t.Errorf("the stale config won: %+v", cfg) } } // A corrupt file under the old name is still the user's settings, so it is // reported rather than skipped over as if nothing were there. func TestLegacyConfigThatCannotBeParsedIsReported(t *testing.T) { isolate(t) writeConfig(t, legacyPath(t), "{not json") _, found, err := Load() if err == nil { t.Fatal("a corrupt config under the old name was read as absent") } if found { t.Error("found=true alongside an error") } } func TestLoadReportsMissingWithoutError(t *testing.T) { isolate(t) cfg, found, err := Load() if err != nil { t.Fatalf("a missing config is the first-run signal, not an error: %v", err) } if found { t.Error("reported a config that does not exist") } if !cfg.Equal(Config{}) { t.Errorf("a missing config should come back zeroed, got %+v", cfg) } } func TestSaveThenLoadRoundTrips(t *testing.T) { path := isolate(t) want := Config{Model: "claude-sonnet-5", Effort: "high", Approvals: ApprovalsAcceptAll, Theme: "iris"} if err := Save(want); err != nil { t.Fatalf("Save: %v", err) } if _, err := os.Stat(path); err != nil { t.Fatalf("Save did not write %s: %v", path, err) } got, found, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if !found { t.Fatal("Load did not find the config Save just wrote") } want.Version = Version // stamped on write if !got.Equal(want) { t.Errorf("round trip changed the config:\n got %+v\nwant %+v", got, want) } } // A file we cannot parse must not be silently replaced: the user's settings // are still in there, and the next Save would destroy them without the error // ever being shown. func TestLoadRejectsMalformedWithoutDiscardingIt(t *testing.T) { path := isolate(t) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { t.Fatal(err) } cfg, found, err := Load() if err == nil { t.Fatal("a malformed config loaded cleanly") } if found { t.Error("a malformed config was reported as usable") } if !cfg.Equal(Config{}) { t.Errorf("a malformed config produced values: %+v", cfg) } if b, _ := os.ReadFile(path); string(b) != "{not json" { t.Errorf("Load rewrote the file it could not parse: %q", b) } if !strings.Contains(err.Error(), path) { t.Errorf("the error does not say which file to fix: %v", err) } } func TestSaveCreatesTheDirectory(t *testing.T) { path := isolate(t) if err := Save(Config{Model: "claude-opus-5"}); err != nil { t.Fatalf("Save into a directory that does not exist yet: %v", err) } if _, err := os.Stat(path); err != nil { t.Fatal(err) } } // Unknown values are how a config written by a newer build, or edited by // hand, reaches an older one. They are dropped rather than passed through to // the API, which would fail the request with something unhelpful. func TestLoadDropsValuesItDoesNotRecognise(t *testing.T) { path := isolate(t) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } body := `{"version":1,"model":"claude-opus-5","effort":"extreme","approvals":"sometimes","theme":"neon"}` if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } cfg, found, err := Load() if err != nil || !found { t.Fatalf("Load(found=%v): %v", found, err) } if cfg.Model != "claude-opus-5" { t.Errorf("a recognised field was dropped: %q", cfg.Model) } if cfg.Effort != "" || cfg.Approvals != "" || cfg.Theme != "" { t.Errorf("unrecognised values survived: %+v", cfg) } } // A level the API would reject must not survive a round trip, however it got // into the file. func TestLoadDropsAnEffortTheAPIWouldReject(t *testing.T) { for _, level := range llm.EffortLevels { if err := Save(Config{Effort: level}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.Effort != level { t.Errorf("%q did not survive a round trip: got %q", level, got.Effort) } } } // The config is only ever a default: a flag says what this run should do, and // it has to win. func TestFlagsOverrideTheConfig(t *testing.T) { cfg := Config{Model: "claude-sonnet-5", Effort: "low"} if got := Or("claude-opus-5", cfg.Model); got != "claude-opus-5" { t.Errorf("the flag lost to the config: %q", got) } if got := Or("", cfg.Model); got != "claude-sonnet-5" { t.Errorf("an unset flag did not fall through to the config: %q", got) } if got := Or("", ""); got != "" { t.Errorf("two empties produced %q", got) } } // The marker is what suppresses the wizard, so it has to survive the file // unchanged — and a config without it has to keep reading as unfinished. func TestSetupMarkerRoundTrips(t *testing.T) { isolate(t) if (Config{Model: "claude-opus-5"}).Done() { t.Error("a config with no marker reported the setup as done") } want := Config{Model: "claude-opus-5", Theme: "mono", Setup: SetupDone} if err := Save(want); err != nil { t.Fatal(err) } got, found, err := Load() if err != nil || !found { t.Fatalf("Load(found=%v): %v", found, err) } if !got.Done() { t.Errorf("the setup marker did not survive the round trip: %+v", got) } } // A config that exists but was never finished is the case the marker exists // for: the file is there, and the wizard still has questions to ask. func TestAnUnfinishedConfigIsNotDone(t *testing.T) { path := isolate(t) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } // A marker of the wrong type — "setup": true, the obvious wrong guess for // someone editing by hand — is not in this list: that is a parse failure // for the whole file, which Load already reports rather than papering over. for _, body := range []string{ `{"version":1,"theme":"mint"}`, `{"version":1,"theme":"mint","setup":"maybe"}`, `{"version":1,"theme":"mint","setup":""}`, } { if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } cfg, _, _ := Load() if cfg.Done() { t.Errorf("%s reported the setup as done", body) } if cfg.Theme != "mint" { t.Errorf("%s lost the settings it did have: %+v", body, cfg) } } } func TestAutoCompactRoundTripsAndSanitizes(t *testing.T) { isolate(t) for _, v := range []string{AutoCompactOn, AutoCompactOff} { if err := Save(Config{AutoCompact: v}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.AutoCompact != v { t.Errorf("%q did not survive a round trip: got %q", v, got.AutoCompact) } } if err := Save(Config{AutoCompact: "sometimes"}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.AutoCompact != "" { t.Errorf("an unrecognised value survived: %q", got.AutoCompact) } } // Empty means "no preference", and no preference has to mean on: a config // written before this setting existed must not silently stop the agent at 97%. func TestAutoCompactDefaultsToOn(t *testing.T) { if !(Config{}).AutoCompactEnabled() { t.Error("an empty config should leave auto-compaction on") } if !(Config{AutoCompact: AutoCompactOn}).AutoCompactEnabled() { t.Error("an explicit on should be on") } if (Config{AutoCompact: AutoCompactOff}).AutoCompactEnabled() { t.Error("an explicit off should be off") } } // Every mode the wizard offers has to survive being written and read back, // and a mode this build does not know has to fall back to none rather than to // whatever sorts first — this is the one setting where guessing wrong sends // the model's words to a third party. func TestWebSearchRoundTripsAndSanitizes(t *testing.T) { isolate(t) for _, v := range WebSearchModes { if err := Save(Config{WebSearch: v}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.WebSearch != v { t.Errorf("%q did not survive a round trip: got %q", v, got.WebSearch) } if got.WebSearchMode() != v { t.Errorf("WebSearchMode() = %q, want %q", got.WebSearchMode(), v) } } if err := Save(Config{WebSearch: "google"}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.WebSearch != "" { t.Errorf("an unrecognised mode survived: %q", got.WebSearch) } if got.WebSearchMode() != WebSearchNone { t.Errorf("an unrecognised mode resolved to %q, want %q", got.WebSearchMode(), WebSearchNone) } } // A config written before web search existed must read as "no network", not // as "whichever mode is first in the list". func TestWebSearchDefaultsToNone(t *testing.T) { if got := (Config{}).WebSearchMode(); got != WebSearchNone { t.Errorf("an empty config means %q, want %q", got, WebSearchNone) } } // The search command is free text, so the only rules are that it survives and // that "no preference" resolves to the shipped default rather than to an empty // argv nobody can run. func TestSearchCommandFallsBackToTheDefault(t *testing.T) { isolate(t) if got := (Config{}).SearchCommandLine(); got != DefaultSearchCommand { t.Errorf("empty resolved to %q, want the default %q", got, DefaultSearchCommand) } const custom = "mysearch --json {{query}}" if err := Save(Config{WebSearch: WebSearchCustom, SearchCommand: " " + custom + " "}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.SearchCommand != custom { t.Errorf("surrounding space was not trimmed: %q", got.SearchCommand) } if got.SearchCommandLine() != custom { t.Errorf("SearchCommandLine() = %q, want %q", got.SearchCommandLine(), custom) } } func TestHooksSurviveASaveAndLoad(t *testing.T) { isolate(t) want := Config{ Model: "claude-sonnet-5", Hooks: map[string][]string{ "blocked": {"notify-send codey \"$CODEY_MESSAGE\""}, "run_end": {"true", "echo done"}, }, } if err := Save(want); err != nil { t.Fatalf("Save: %v", err) } got, _, err := Load() if err != nil { t.Fatalf("Load: %v", err) } want.Version = Version // stamped on write if !got.Equal(want) { t.Errorf("round trip changed the hooks:\n got %+v\nwant %+v", got.Hooks, want.Hooks) } } // The settings panel decides whether to rewrite the file by asking whether // anything changed, and it must not lose a hook it never knew about. func TestEqual(t *testing.T) { base := Config{Model: "m", Theme: "iris", Hooks: map[string][]string{"run_end": {"a"}}} cases := []struct { name string a, b Config equal bool }{ {"the same config", base, base, true}, {"a changed setting", base, Config{Model: "n", Theme: "iris", Hooks: base.Hooks}, false}, { name: "the same hooks in another map", a: base, b: Config{Model: "m", Theme: "iris", Hooks: map[string][]string{"run_end": {"a"}}}, equal: true, }, { name: "a changed hook", a: base, b: Config{Model: "m", Theme: "iris", Hooks: map[string][]string{"run_end": {"b"}}}, equal: false, }, { name: "a hook dropped", a: base, b: Config{Model: "m", Theme: "iris"}, equal: false, }, { name: "an event with no commands is no event at all", a: Config{Model: "m"}, b: Config{Model: "m", Hooks: map[string][]string{"run_end": {}}}, // A panel that rewrote the file over this would be saving nothing. equal: true, }, {"two zero values", Config{}, Config{}, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := c.a.Equal(c.b); got != c.equal { t.Errorf("Equal = %v, want %v", got, c.equal) } if got := c.b.Equal(c.a); got != c.equal { t.Errorf("Equal is not symmetric: reversed gave %v, want %v", got, c.equal) } }) } } func TestMCPServersSurviveASaveAndLoad(t *testing.T) { isolate(t) want := Config{ MCPServers: map[string]MCPServer{ "docs": {Command: "npx", Args: []string{"-y", "@example/docs-mcp"}, Env: map[string]string{"DOCS_KEY": "k"}}, "db": {Command: "/usr/local/bin/db-mcp"}, }, } if err := Save(want); err != nil { t.Fatalf("Save: %v", err) } got, _, err := Load() if err != nil { t.Fatalf("Load: %v", err) } want.Version = Version if !got.Equal(want) { t.Errorf("round trip changed the servers:\n got %+v\nwant %+v", got.MCPServers, want.MCPServers) } } func TestEqualSeesMCPServers(t *testing.T) { base := Config{MCPServers: map[string]MCPServer{"db": {Command: "db-mcp", Args: []string{"--ro"}}}} cases := []struct { name string b Config equal bool }{ {"the same server in another map", Config{MCPServers: map[string]MCPServer{"db": {Command: "db-mcp", Args: []string{"--ro"}}}}, true}, {"a changed argument", Config{MCPServers: map[string]MCPServer{"db": {Command: "db-mcp", Args: []string{"--rw"}}}}, false}, {"a changed environment", Config{MCPServers: map[string]MCPServer{"db": {Command: "db-mcp", Args: []string{"--ro"}, Env: map[string]string{"A": "1"}}}}, false}, {"a server dropped", Config{}, false}, {"a server renamed", Config{MCPServers: map[string]MCPServer{"sql": {Command: "db-mcp", Args: []string{"--ro"}}}}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := base.Equal(c.b); got != c.equal { t.Errorf("Equal = %v, want %v", got, c.equal) } if got := c.b.Equal(base); got != c.equal { t.Errorf("Equal is not symmetric: reversed gave %v, want %v", got, c.equal) } }) } // Written to the file, a missing map and an empty one are the same // nothing, as are no args and an empty args list. if !(Config{}).Equal(Config{MCPServers: map[string]MCPServer{}}) { t.Error("an empty server map should equal a missing one") } if !(Config{MCPServers: map[string]MCPServer{"x": {Command: "x"}}}).Equal(Config{MCPServers: map[string]MCPServer{"x": {Command: "x", Args: []string{}}}}) { t.Error("no args should equal an empty args list") } } // The Claude Code login is borrowed only on an explicit "on". A config written // before the setting existed, and one with a word this build does not know, // both read as off — and both read as never asked, which is what lets the // startup path ask someone upgrading from the silent fallback exactly once. func TestClaudeCodeAuthRoundTripsAndDefaultsToOff(t *testing.T) { isolate(t) for _, v := range []string{ClaudeCodeAuthOn, ClaudeCodeAuthOff} { if err := Save(Config{ClaudeCodeAuth: v}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.ClaudeCodeAuth != v { t.Errorf("%q did not survive a round trip: got %q", v, got.ClaudeCodeAuth) } if got.ClaudeCodeAuthEnabled() != (v == ClaudeCodeAuthOn) { t.Errorf("%q: enabled = %v", v, got.ClaudeCodeAuthEnabled()) } } if err := Save(Config{ClaudeCodeAuth: "yes"}); err != nil { t.Fatal(err) } got, _, err := Load() if err != nil { t.Fatal(err) } if got.ClaudeCodeAuth != "" { t.Errorf("an unrecognised value survived: %q", got.ClaudeCodeAuth) } if got.ClaudeCodeAuthEnabled() { t.Error("an unrecognised value turned the Claude Code login on") } if (Config{}).ClaudeCodeAuthEnabled() { t.Error("an empty config borrows the Claude Code login; it must be off until asked for") } } // The key is spelled the way the README and the error message spell it. func TestClaudeCodeAuthKeyName(t *testing.T) { path := isolate(t) if err := Save(Config{ClaudeCodeAuth: ClaudeCodeAuthOn}); err != nil { t.Fatal(err) } raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"claude_code_auth": "on"`) { t.Errorf("the file does not say \"claude_code_auth\": \"on\":\n%s", raw) } }