Vectrum
Sign In
245 lines · 7.0 KB · Go
package config

import (
	"harness/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/harness.
func isolate(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	t.Setenv("XDG_CONFIG_HOME", dir)
	return filepath.Join(dir, "harness", Filename)
}

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 != (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 != 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 != (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")
	}
}