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")
}
}