Go 1997 lines
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestMain pins the workspace owner so Load resolves a deterministic
// username across machines instead of picking up the dev box's
// `git config user.name`. Every Load in this package then scopes the
// workspace under <root>/tester/.eeco.
func TestMain(m *testing.M) {
os.Setenv("EECO_USERNAME", "tester")
// Pin the user-global config dir to an empty temp dir so the global
// layer is a hermetic no-op and tests never read the dev box's
// ~/.config/eeco. Tests that exercise the global layer override via
// t.Setenv(GlobalConfigEnv, ...).
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
func TestFindRepoRoot_WalksUpToDotGit(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
deep := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
got, err := FindRepoRoot(deep)
if err != nil {
t.Fatalf("FindRepoRoot(%q) error: %v", deep, err)
}
wantRoot, _ := filepath.EvalSymlinks(root)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("FindRepoRoot = %q, want %q", gotRoot, wantRoot)
}
}
func TestFindRepoRoot_AcceptsGitFile(t *testing.T) {
// Worktrees use a `.git` *file* with a gitdir pointer; FindRepoRoot
// must accept that too.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: x\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := FindRepoRoot(root)
if err != nil {
t.Fatal(err)
}
wantRoot, _ := filepath.EvalSymlinks(root)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("FindRepoRoot = %q, want %q", gotRoot, wantRoot)
}
}
func TestFindRepoRoot_ErrorsOutsideRepo(t *testing.T) {
// A fresh temp directory should not be inside any git repo on any
// sane test machine; if it is, the test environment is broken.
dir := t.TempDir()
if _, err := FindRepoRoot(dir); err == nil {
t.Fatal("expected error outside repo, got nil")
}
}
// newHostWithPrivateRepo builds a host repo (<root>/.git) containing eeco's
// private workspace-history repo at <root>/tester/.git with the engine
// workspace <root>/tester/.eeco beside it — the on-disk shape `eeco init`
// leaves and the cwd the harness launches from to load the emitted cockpit.
// It returns the host root and the private workspace dir (<root>/tester).
func newHostWithPrivateRepo(t *testing.T) (host, priv string) {
t.Helper()
host = newRepo(t)
priv = filepath.Join(host, "tester")
if err := os.MkdirAll(filepath.Join(priv, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(priv, DefaultWorkspace), 0o755); err != nil {
t.Fatal(err)
}
return host, priv
}
func TestResolveProjectRoot_SkipsPrivateWorkspaceRepo(t *testing.T) {
// FIX-1: from inside <username>/ (and deeper), root detection must walk
// past the private <username>/.git and resolve the host project root.
host, priv := newHostWithPrivateRepo(t)
wantRoot, _ := filepath.EvalSymlinks(host)
for _, start := range []string{priv, filepath.Join(priv, DefaultWorkspace, "memory")} {
if err := os.MkdirAll(start, 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveProjectRoot(start)
if err != nil {
t.Fatalf("resolveProjectRoot(%q) error: %v", start, err)
}
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("resolveProjectRoot(%q) = %q, want host root %q (must skip the private <username>/.git)", start, gotRoot, wantRoot)
}
}
}
func TestResolveProjectRoot_NormalRepoUnchanged(t *testing.T) {
// A repo with no .eeco beside its .git resolves exactly like FindRepoRoot.
root := newRepo(t)
deep := filepath.Join(root, "a", "b")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveProjectRoot(deep)
if err != nil {
t.Fatal(err)
}
wantRoot, _ := filepath.EvalSymlinks(root)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("resolveProjectRoot = %q, want %q", gotRoot, wantRoot)
}
}
func TestResolveProjectRoot_PrivateOnlyFallsBack(t *testing.T) {
// A private workspace repo with no host repo above it (a shape eeco init
// never produces) falls back to the private repo rather than erroring, so
// the fix is never worse than a plain FindRepoRoot.
base := t.TempDir()
priv := filepath.Join(base, "tester")
if err := os.MkdirAll(filepath.Join(priv, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(priv, DefaultWorkspace), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveProjectRoot(priv)
if err != nil {
t.Fatalf("resolveProjectRoot fallback error: %v", err)
}
wantRoot, _ := filepath.EvalSymlinks(priv)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("resolveProjectRoot fallback = %q, want private repo %q", gotRoot, wantRoot)
}
}
func TestLoad_FromInsidePrivateWorkspaceRepo(t *testing.T) {
// The FIX-1 repro end-to-end: launched from <repo>/<username>/, Load must
// resolve the host repo root and the real workspace, not the nested
// private repo (which made <repo>/<username>/<username>/.eeco missing).
host, priv := newHostWithPrivateRepo(t)
write(t, host, "go.mod", "module x\n")
cfg, err := Load(priv, "")
if err != nil {
t.Fatal(err)
}
wantRoot, _ := filepath.EvalSymlinks(host)
if gotRoot, _ := filepath.EvalSymlinks(cfg.RepoRoot); gotRoot != wantRoot {
t.Fatalf("RepoRoot = %q, want host root %q", gotRoot, wantRoot)
}
wantUserDir, _ := filepath.EvalSymlinks(priv)
if gotUserDir, _ := filepath.EvalSymlinks(cfg.UserDir); gotUserDir != wantUserDir {
t.Fatalf("UserDir = %q, want %q", gotUserDir, wantUserDir)
}
wantWS, _ := filepath.EvalSymlinks(filepath.Join(host, "tester", DefaultWorkspace))
if gotWS, _ := filepath.EvalSymlinks(cfg.Workspace); gotWS != wantWS {
t.Fatalf("Workspace = %q, want %q", gotWS, wantWS)
}
}
func TestDetectProfile(t *testing.T) {
cases := []struct {
name string
seed map[string]string // path -> contents
want Profile
}{
{"go", map[string]string{"go.mod": "module x\n"}, ProfileGo},
{"zig", map[string]string{"build.zig": ""}, ProfileZig},
{"rust", map[string]string{"Cargo.toml": "[package]\n"}, ProfileRust},
{"node", map[string]string{"package.json": "{}"}, ProfileNode},
{"python-pyproject", map[string]string{"pyproject.toml": ""}, ProfilePython},
{"python-requirements", map[string]string{"requirements.txt": ""}, ProfilePython},
{"python-requirements-dev", map[string]string{"requirements-dev.txt": ""}, ProfilePython},
{"python-venv", map[string]string{".venv/bin/python": ""}, ProfilePython},
{"generic-empty", map[string]string{}, ProfileGeneric},
{"generic-random", map[string]string{"some-file.txt": "x"}, ProfileGeneric},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for path, content := range tc.seed {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
got := DetectProfile(dir)
if got != tc.want {
t.Fatalf("DetectProfile = %q, want %q", got, tc.want)
}
})
}
}
func TestDetectProfile_GoWinsOverPython(t *testing.T) {
// A polyglot repo with both go.mod and pyproject.toml resolves to
// the documented precedence order (Go first).
dir := t.TempDir()
write(t, dir, "go.mod", "module x\n")
write(t, dir, "pyproject.toml", "")
if got := DetectProfile(dir); got != ProfileGo {
t.Fatalf("DetectProfile polyglot = %q, want %q", got, ProfileGo)
}
}
func TestGateFor(t *testing.T) {
cases := map[Profile][][]string{
ProfileGo: {{"go", "vet", "./..."}},
ProfileZig: {{"zig", "build", "--summary", "none"}},
ProfileRust: {{"cargo", "check", "--quiet"}},
ProfileNode: {{"npm", "run", "--if-present", "typecheck"}},
ProfilePython: {{"python3", "-m", "compileall", "-q", "."}},
ProfileGeneric: nil,
}
for p, want := range cases {
got := GateFor(p)
if !reflect.DeepEqual(got, want) {
t.Errorf("GateFor(%q) = %v, want %v", p, got, want)
}
}
}
func TestGateFor_ReturnsFreshSlice(t *testing.T) {
a := GateFor(ProfileGo)
b := GateFor(ProfileGo)
a[0][0] = "MUTATED"
if b[0][0] == "MUTATED" {
t.Fatal("GateFor returned a shared backing array; expected a fresh copy")
}
}
func TestLoad_DefaultsAndRepoRoot(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if got, want := cfg.WorkspaceName, DefaultWorkspace; got != want {
t.Errorf("workspace name = %q, want %q", got, want)
}
wantWS := filepath.Join(root, "tester", DefaultWorkspace)
gotWS, _ := filepath.EvalSymlinks(filepath.Dir(cfg.Workspace))
wantWSDir, _ := filepath.EvalSymlinks(filepath.Dir(wantWS))
if gotWS != wantWSDir {
t.Errorf("workspace parent = %q, want %q", gotWS, wantWSDir)
}
if cfg.Profile != ProfileGo {
t.Errorf("profile = %q, want %q", cfg.Profile, ProfileGo)
}
if !reflect.DeepEqual(cfg.Gate, [][]string{{"go", "vet", "./..."}}) {
t.Errorf("gate = %v", cfg.Gate)
}
}
func TestLoad_RejectsBadWorkspaceName(t *testing.T) {
root := newRepo(t)
cases := []string{"..", ".", "foo/bar", "/abs", `back\slash`}
for _, name := range cases {
if _, err := Load(root, name); err == nil {
t.Errorf("Load(%q) succeeded; expected error", name)
}
}
}
func TestLoad_ConfigLocalOverride(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"# comment line",
"",
`profile = "generic"`,
"gate=make check",
"unknown_key=ignored",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Profile != ProfileGeneric {
t.Errorf("profile override = %q, want generic", cfg.Profile)
}
if !reflect.DeepEqual(cfg.Gate, [][]string{{"make", "check"}}) {
t.Errorf("gate override = %v, want [[make check]]", cfg.Gate)
}
}
func TestLoad_ConfigLocalProfileResetsGate(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "profile=rust\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Profile != ProfileRust {
t.Fatalf("profile = %q, want rust", cfg.Profile)
}
if !reflect.DeepEqual(cfg.Gate, [][]string{{"cargo", "check", "--quiet"}}) {
t.Fatalf("gate after profile override = %v", cfg.Gate)
}
}
func TestLoad_ConfigLocalEmptyGateDisablesIt(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "gate=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Gate != nil {
t.Fatalf("gate = %v, want nil", cfg.Gate)
}
}
func TestLoad_ConfigLocalMultiStepGate(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
// Three `gate=` lines: the first resets the profile default, all
// three append, so the chain runs in declared order.
write(t, wsDir, "config.local", strings.Join([]string{
"gate=go vet ./...",
"gate=staticcheck ./...",
"gate=go test ./...",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := [][]string{
{"go", "vet", "./..."},
{"staticcheck", "./..."},
{"go", "test", "./..."},
}
if !reflect.DeepEqual(cfg.Gate, want) {
t.Fatalf("gate chain = %v, want %v", cfg.Gate, want)
}
}
func TestLoad_DefaultStaleDays(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.StaleDays != DefaultStaleDays {
t.Errorf("StaleDays = %d, want %d", cfg.StaleDays, DefaultStaleDays)
}
}
func TestLoad_ConfigLocalStaleDaysOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "stale_days=7\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.StaleDays != 7 {
t.Errorf("StaleDays = %d, want 7", cfg.StaleDays)
}
}
func TestLoad_ConfigLocalStaleDaysMalformed(t *testing.T) {
cases := []string{"stale_days=abc\n", "stale_days=-3\n"}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_ConfigLocalInitDetectionThreshold(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "init_detection_threshold=0.85\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.InitDetectionThreshold != 0.85 {
t.Errorf("InitDetectionThreshold = %v, want 0.85", cfg.InitDetectionThreshold)
}
}
func TestLoad_ConfigLocalInitDetectionThresholdMalformed(t *testing.T) {
cases := []string{
"init_detection_threshold=abc\n",
"init_detection_threshold=2\n",
"init_detection_threshold=-0.1\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_ConfigLocalMalformed(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "no-equals-sign-here\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected malformed config.local to error")
}
}
func TestLoad_SessionSettingsPath(t *testing.T) {
t.Run("unset default is empty", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "")
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != "" {
t.Errorf("SessionSettingsPath = %q, want empty", cfg.SessionSettingsPath)
}
})
t.Run("env supplies the default", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "/abs/env/settings.json")
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != "/abs/env/settings.json" {
t.Errorf("SessionSettingsPath = %q, want the env value", cfg.SessionSettingsPath)
}
})
t.Run("config.local overrides env", func(t *testing.T) {
envPath := filepath.Join(t.TempDir(), "env-settings.json")
localPath := filepath.Join(t.TempDir(), "local-settings.json")
t.Setenv("EECO_SESSION_SETTINGS", envPath)
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "session_settings_path="+localPath+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != localPath {
t.Errorf("SessionSettingsPath = %q, want the config.local value", cfg.SessionSettingsPath)
}
})
t.Run("empty value clears the env default", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "/abs/env/settings.json")
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "session_settings_path=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != "" {
t.Errorf("SessionSettingsPath = %q, want empty after explicit clear", cfg.SessionSettingsPath)
}
})
t.Run("relative path is rejected", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "")
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "session_settings_path=rel/settings.json\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected a relative session_settings_path to error")
}
})
}
func TestLoad_DefaultBugReportDir(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BugReportDir != DefaultBugReportDir {
t.Errorf("BugReportDir = %q, want %q", cfg.BugReportDir, DefaultBugReportDir)
}
}
func TestLoad_ConfigLocalBugReportDirOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "bug_report_dir=my-bugs\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BugReportDir != "my-bugs" {
t.Errorf("BugReportDir = %q, want my-bugs", cfg.BugReportDir)
}
}
func TestLoad_ConfigLocalBugReportDirEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "bug_report_dir=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BugReportDir != DefaultBugReportDir {
t.Errorf("BugReportDir = %q, want default %q", cfg.BugReportDir, DefaultBugReportDir)
}
}
func TestLoad_ConfigLocalBugReportDirRejected(t *testing.T) {
cases := []string{
"bug_report_dir=/abs/path\n",
"bug_report_dir=..\n",
"bug_report_dir=../escape\n",
"bug_report_dir=sub/../../escape\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultContextPath(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextPath != DefaultContextPath {
t.Errorf("ContextPath = %q, want %q", cfg.ContextPath, DefaultContextPath)
}
}
func TestLoad_ConfigLocalContextPathOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_path=brief/project.md\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextPath != "brief/project.md" {
t.Errorf("ContextPath = %q, want brief/project.md", cfg.ContextPath)
}
}
func TestLoad_ConfigLocalContextPathEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_path=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextPath != DefaultContextPath {
t.Errorf("ContextPath = %q, want default %q", cfg.ContextPath, DefaultContextPath)
}
}
func TestLoad_ConfigLocalContextPathRejected(t *testing.T) {
cases := []string{
"context_path=/abs/path.md\n",
"context_path=..\n",
"context_path=../escape.md\n",
"context_path=sub/../../escape.md\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultContextBudget(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextBudget != DefaultContextBudget {
t.Errorf("ContextBudget = %d, want %d", cfg.ContextBudget, DefaultContextBudget)
}
}
func TestLoad_ConfigLocalContextBudgetOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_budget=800\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextBudget != 800 {
t.Errorf("ContextBudget = %d, want 800", cfg.ContextBudget)
}
}
func TestLoad_ConfigLocalContextBudgetEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_budget=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextBudget != DefaultContextBudget {
t.Errorf("ContextBudget = %d, want default %d", cfg.ContextBudget, DefaultContextBudget)
}
}
func TestLoad_ConfigLocalContextBudgetRejected(t *testing.T) {
cases := []string{
"context_budget=-1\n",
"context_budget=notanumber\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultBriefIncludeNotes(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BriefIncludeNotes != DefaultBriefIncludeNotes {
t.Errorf("BriefIncludeNotes = %v, want %v", cfg.BriefIncludeNotes, DefaultBriefIncludeNotes)
}
}
func TestLoad_ConfigLocalBriefIncludeNotesAccepted(t *testing.T) {
cases := []struct {
body string
want bool
}{
{"brief_include_notes=true\n", true},
{"brief_include_notes=false\n", false},
{"brief_include_notes=1\n", true},
{"brief_include_notes=0\n", false},
{"brief_include_notes=\n", DefaultBriefIncludeNotes},
}
for _, tc := range cases {
t.Run(tc.body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BriefIncludeNotes != tc.want {
t.Errorf("BriefIncludeNotes = %v, want %v", cfg.BriefIncludeNotes, tc.want)
}
})
}
}
func TestLoad_ConfigLocalBriefIncludeNotesRejected(t *testing.T) {
cases := []string{
"brief_include_notes=yes\n",
"brief_include_notes=no\n",
"brief_include_notes=notabool\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultSessionStartPinnedBodies(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionStartPinnedBodies != DefaultSessionStartPinnedBodies {
t.Errorf("SessionStartPinnedBodies = %v, want %v",
cfg.SessionStartPinnedBodies, DefaultSessionStartPinnedBodies)
}
}
func TestLoad_ConfigLocalSessionStartPinnedBodiesAccepted(t *testing.T) {
cases := []struct {
body string
want bool
}{
{"session_start_pinned_bodies=true\n", true},
{"session_start_pinned_bodies=false\n", false},
{"session_start_pinned_bodies=1\n", true},
{"session_start_pinned_bodies=0\n", false},
{"session_start_pinned_bodies=\n", DefaultSessionStartPinnedBodies},
}
for _, tc := range cases {
t.Run(tc.body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionStartPinnedBodies != tc.want {
t.Errorf("SessionStartPinnedBodies = %v, want %v",
cfg.SessionStartPinnedBodies, tc.want)
}
})
}
}
func TestLoad_ConfigLocalSessionStartPinnedBodiesRejected(t *testing.T) {
cases := []string{
"session_start_pinned_bodies=yes\n",
"session_start_pinned_bodies=no\n",
"session_start_pinned_bodies=notabool\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultVersionLocationsEmpty(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.VersionLocations) != 0 {
t.Errorf("VersionLocations = %v, want empty", cfg.VersionLocations)
}
}
func TestLoad_ConfigLocalVersionLocationsRepeatable(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
`version_locations=CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`version_locations=VERSION:^v(\d+\.\d+\.\d+)`,
"",
"version_locations=", // blank — ignored, no phantom entry
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
if !reflect.DeepEqual(cfg.VersionLocations, want) {
t.Fatalf("VersionLocations = %v, want %v", cfg.VersionLocations, want)
}
}
func TestLoad_ConfigLocalVersionLocationsRejected(t *testing.T) {
cases := []string{
"version_locations=no-colon-here\n",
"version_locations=:no-path\n",
"version_locations=/abs/path:v(\\d+)\n",
"version_locations=..:v(\\d+)\n",
"version_locations=../escape.md:v(\\d+)\n",
"version_locations=sub/../../escape.md:v(\\d+)\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_ConfigLocalVersionLocationsAuto(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "version_locations=auto\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"auto"}
if !reflect.DeepEqual(cfg.VersionLocations, want) {
t.Fatalf("VersionLocations = %v, want %v", cfg.VersionLocations, want)
}
}
func TestLoad_ConfigLocalVersionLocationsAutoRejectsMix(t *testing.T) {
cases := map[string]string{
"auto then explicit": "version_locations=auto\n" +
`version_locations=VERSION:v(\d+\.\d+\.\d+)` + "\n",
"explicit then auto": `version_locations=VERSION:v(\d+\.\d+\.\d+)` + "\n" +
"version_locations=auto\n",
"auto twice": "version_locations=auto\nversion_locations=auto\n",
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultVersionAnchorEmpty(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.VersionAnchor != "" {
t.Errorf("VersionAnchor default = %q, want empty", cfg.VersionAnchor)
}
}
func TestLoad_ConfigLocalVersionAnchorTag(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "version_anchor=tag\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.VersionAnchor != "tag" {
t.Errorf("VersionAnchor = %q, want %q", cfg.VersionAnchor, "tag")
}
}
func TestLoad_ConfigLocalVersionAnchorFile(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
body := `version_anchor=VERSION:^v(\d+\.\d+\.\d+)` + "\n"
write(t, wsDir, "config.local", body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := `VERSION:^v(\d+\.\d+\.\d+)`
if cfg.VersionAnchor != want {
t.Errorf("VersionAnchor = %q, want %q", cfg.VersionAnchor, want)
}
}
func TestLoad_ConfigLocalVersionAnchorEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "version_anchor=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.VersionAnchor != "" {
t.Errorf("VersionAnchor = %q, want empty", cfg.VersionAnchor)
}
}
func TestLoad_ConfigLocalVersionAnchorRejected(t *testing.T) {
cases := []string{
"version_anchor=no-colon-here\n",
"version_anchor=:no-path\n",
"version_anchor=VERSION:\n",
"version_anchor=/abs/path:v(\\d+)\n",
"version_anchor=..:v(\\d+)\n",
"version_anchor=../escape.md:v(\\d+)\n",
"version_anchor=sub/../../escape.md:v(\\d+)\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultPreCommitWorkflows(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"leak-guard", "version-sync"}
if !reflect.DeepEqual(cfg.PreCommitWorkflows, want) {
t.Errorf("PreCommitWorkflows default = %v, want %v", cfg.PreCommitWorkflows, want)
}
}
func TestLoad_ConfigLocalPreCommitWorkflowsReplacesDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"pre_commit_workflows=leak-guard",
"pre_commit_workflows=comment-hygiene",
"",
"pre_commit_workflows=", // blank — ignored, no phantom entry
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"leak-guard", "comment-hygiene"}
if !reflect.DeepEqual(cfg.PreCommitWorkflows, want) {
t.Fatalf("PreCommitWorkflows = %v, want %v", cfg.PreCommitWorkflows, want)
}
}
func TestLoad_ConfigLocalPreCommitWorkflowsEmptyDisables(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "pre_commit_workflows=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.PreCommitWorkflows) != 0 {
t.Errorf("PreCommitWorkflows = %v, want empty (default disabled)", cfg.PreCommitWorkflows)
}
}
func TestLoad_ConfigLocalPreCommitWorkflowsRejectsWhitespace(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "pre_commit_workflows=leak-guard version-sync\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected error on whitespace-containing workflow name")
}
}
func TestLoad_DefaultPostMergeWorkflows(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"memory-drift", "doc-drift", "manifest-refresh", "cockpit-sync"}
if !reflect.DeepEqual(cfg.PostMergeWorkflows, want) {
t.Errorf("PostMergeWorkflows default = %v, want %v", cfg.PostMergeWorkflows, want)
}
}
func TestLoad_ConfigLocalPostMergeWorkflowsReplacesDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"post_merge_workflows=memory-drift",
"post_merge_workflows=", // blank — ignored, no phantom entry
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"memory-drift"}
if !reflect.DeepEqual(cfg.PostMergeWorkflows, want) {
t.Fatalf("PostMergeWorkflows = %v, want %v", cfg.PostMergeWorkflows, want)
}
}
func TestLoad_ConfigLocalPostMergeWorkflowsEmptyDisables(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "post_merge_workflows=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.PostMergeWorkflows) != 0 {
t.Errorf("PostMergeWorkflows = %v, want empty (default disabled)", cfg.PostMergeWorkflows)
}
}
func TestLoad_ConfigLocalPostMergeWorkflowsRejectsWhitespace(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "post_merge_workflows=memory-drift doc-drift\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected error on whitespace-containing workflow name")
}
}
func TestLoad_DefaultSessionFiles(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.SessionFiles) != 0 {
t.Errorf("SessionFiles default = %v, want empty", cfg.SessionFiles)
}
}
func TestLoad_ConfigLocalSessionFilesRepoRelative(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"session_files=CLAUDE.md",
"session_files=AGENTS.md",
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"CLAUDE.md", "AGENTS.md"}
if !reflect.DeepEqual(cfg.SessionFiles, want) {
t.Errorf("SessionFiles = %v, want %v", cfg.SessionFiles, want)
}
}
func TestLoad_ConfigLocalSessionFilesAbsoluteAccepted(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
abs := filepath.Join(t.TempDir(), "cursor-rules.md")
write(t, wsDir, "config.local", "session_files="+abs+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.SessionFiles) != 1 || cfg.SessionFiles[0] != abs {
t.Errorf("SessionFiles = %v, want [%q]", cfg.SessionFiles, abs)
}
}
func TestLoad_ConfigLocalSessionFilesMixed(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
abs := filepath.Join(t.TempDir(), "cursor-rules.md")
write(t, wsDir, "config.local", strings.Join([]string{
"session_files=CLAUDE.md",
"session_files=" + abs,
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"CLAUDE.md", abs}
if !reflect.DeepEqual(cfg.SessionFiles, want) {
t.Errorf("SessionFiles = %v, want %v", cfg.SessionFiles, want)
}
}
func TestLoad_ConfigLocalSessionFilesRejected(t *testing.T) {
cases := []string{
"session_files=..\n",
"session_files=../escape.md\n",
"session_files=sub/../../escape.md\n",
"session_files=has space.md\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestWriteLocalKeys_UpsertPreservesAndRoundTrips(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"# keep me",
"stale_days=7",
"unknown_key=keep",
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, map[string]string{
"stale_days": "9", // replace in place
"automation": "scaffold", // append
"ai_budget": "3", // append
}); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(filepath.Join(wsDir, "config.local"))
got := string(b)
if !strings.Contains(got, "# keep me") || !strings.Contains(got, "unknown_key=keep") {
t.Errorf("comments / unknown keys not preserved:\n%s", got)
}
if strings.Contains(got, "stale_days=7") || !strings.Contains(got, "stale_days=9") {
t.Errorf("stale_days not replaced in place:\n%s", got)
}
// The override must round-trip through Load.
cfg2, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg2.StaleDays != 9 {
t.Errorf("StaleDays = %d, want 9", cfg2.StaleDays)
}
if cfg2.Automation != AutomationScaffold {
t.Errorf("Automation = %q, want scaffold", cfg2.Automation)
}
if cfg2.AIBudget != 3 {
t.Errorf("AIBudget = %d, want 3", cfg2.AIBudget)
}
}
func TestWriteLocalKeys_CreatesFileWhenMissing(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, map[string]string{"automation": "auto"}); err != nil {
t.Fatal(err)
}
cfg2, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg2.Automation != AutomationAuto {
t.Errorf("Automation = %q, want auto", cfg2.Automation)
}
}
func TestWriteLocalKeys_RequiresInitialisedWorkspace(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, map[string]string{"automation": "auto"}); err == nil {
t.Fatal("expected an error when the workspace is not initialised")
}
}
// --- helpers ---
func newRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
return root
}
func write(t *testing.T, dir, name, content string) {
t.Helper()
full := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_DefaultWorkspaceHistory(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.WorkspaceHistory != DefaultWorkspaceHistory {
t.Errorf("WorkspaceHistory = %q, want %q (default)", cfg.WorkspaceHistory, DefaultWorkspaceHistory)
}
if DefaultWorkspaceHistory != WorkspaceHistoryManual {
t.Errorf("DefaultWorkspaceHistory = %q, want manual (safe-default floor)", DefaultWorkspaceHistory)
}
}
func TestLoad_ConfigLocalWorkspaceHistory(t *testing.T) {
cases := []struct {
val string
want WorkspaceHistory
}{
{"off", WorkspaceHistoryOff},
{"manual", WorkspaceHistoryManual},
{"auto", WorkspaceHistoryAuto},
{"", WorkspaceHistoryManual}, // empty resets to default
{"nonsense", WorkspaceHistoryManual}, // unknown → default (floor)
}
for _, tc := range cases {
t.Run(tc.val, func(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "workspace_history="+tc.val+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WorkspaceHistory != tc.want {
t.Errorf("workspace_history=%q → %q, want %q", tc.val, cfg.WorkspaceHistory, tc.want)
}
})
}
}
func TestWorkspaceHistory_EnabledAuto(t *testing.T) {
cases := []struct {
h WorkspaceHistory
enabled bool
auto bool
}{
{WorkspaceHistoryOff, false, false},
{WorkspaceHistoryManual, true, false},
{WorkspaceHistoryAuto, true, true},
}
for _, tc := range cases {
if got := tc.h.Enabled(); got != tc.enabled {
t.Errorf("%q.Enabled() = %v, want %v", tc.h, got, tc.enabled)
}
if got := tc.h.Auto(); got != tc.auto {
t.Errorf("%q.Auto() = %v, want %v", tc.h, got, tc.auto)
}
}
}
// --- H1.2: branch/edge coverage deepening (test-only) ---
// Group A — pure/exported functions (no fixtures, direct in-package calls).
// TestGateSteps covers GateSteps (config.go:586-591), which was 0%: the
// nil-chain non-nil-empty contract plus single/multi-step joining.
func TestGateSteps(t *testing.T) {
t.Run("nil chain yields non-nil empty", func(t *testing.T) {
got := GateSteps(nil)
if got == nil {
t.Fatal("GateSteps(nil) = nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("GateSteps(nil) = %v, want empty", got)
}
})
cases := []struct {
name string
in [][]string
want []string
}{
{"single multi-arg step", [][]string{{"go", "vet", "./..."}}, []string{"go vet ./..."}},
{"two steps", [][]string{{"go", "vet"}, {"staticcheck", "./..."}}, []string{"go vet", "staticcheck ./..."}},
{"single word step", [][]string{{"make"}}, []string{"make"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := GateSteps(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("GateSteps(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
// TestValidateWorkspaceName covers the empty (config.go:647-649) and
// not-clean (650-652) reject arms by calling the validator directly: Load
// maps "" to DefaultWorkspace before validating, so the empty arm is only
// reachable here.
func TestValidateWorkspaceName(t *testing.T) {
cases := []struct {
name string
wantErr bool
}{
{"", true},
{"a//b", true},
{"./x", true},
{"/abs", true},
{"a/b", true},
{`a\b`, true},
{".", true},
{"..", true},
{".eeco", false},
{"workspace", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateWorkspaceName(tc.name); (err != nil) != tc.wantErr {
t.Errorf("validateWorkspaceName(%q) err = %v, wantErr = %v", tc.name, err, tc.wantErr)
}
})
}
}
// TestSlugUsername covers the space->dash arm (config.go:551-552) and the
// drop/trim edges, including a result that trims to empty.
func TestSlugUsername(t *testing.T) {
cases := []struct {
in string
want string
}{
{"Jane Doe", "Jane-Doe"},
{" ada ", "ada"},
{"a@b!c", "abc"},
{"...x...", "x"},
{"日本語", ""},
{".", ""},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := slugUsername(tc.in); got != tc.want {
t.Errorf("slugUsername(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// Group B — per-key config.local edge tables (driven via Load). This is
// the "garbage in config.local -> documented default/error, never crash"
// exit, one function per typed key not already covered.
// TestLoad_ConfigLocalAICommand covers the ai_command split/empty arms
// (config.go:760-762).
func TestLoad_ConfigLocalAICommand(t *testing.T) {
cases := []struct {
name string
body string
want []string
}{
{"multi-arg", "ai_command=my tool --flag\n", []string{"my", "tool", "--flag"}},
{"empty leaves nil", "ai_command=\n", nil},
{"single", "ai_command=solo\n", []string{"solo"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg.AICommand, tc.want) {
t.Errorf("AICommand = %v, want %v", cfg.AICommand, tc.want)
}
})
}
}
// TestLoad_ConfigLocalAIProvider covers the ai_provider passthrough
// (config.go:778), including the floor invariant that an unknown value is
// stored verbatim without error.
func TestLoad_ConfigLocalAIProvider(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"cli", "ai_provider=cli\n", "cli"},
{"anthropic", "ai_provider=anthropic\n", "anthropic"},
{"empty", "ai_provider=\n", ""},
{"unknown tolerated", "ai_provider=galaxy-brain\n", "galaxy-brain"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AIProvider != tc.want {
t.Errorf("AIProvider = %q, want %q", cfg.AIProvider, tc.want)
}
})
}
}
// TestLoad_ConfigLocalAIModel covers the ai_model passthrough
// (config.go:782): an opaque identifier is stored verbatim, empty resets.
func TestLoad_ConfigLocalAIModel(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"identifier", "ai_model=claude-x\n", "claude-x"},
{"empty", "ai_model=\n", ""},
{"opaque chars", "ai_model=anything/with:chars\n", "anything/with:chars"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AIModel != tc.want {
t.Errorf("AIModel = %q, want %q", cfg.AIModel, tc.want)
}
})
}
}
// TestLoad_ConfigLocalAIAPIKeyEnv covers ai_api_key_env (config.go:786-790):
// a name is taken verbatim, empty falls back to the default env-var name.
func TestLoad_ConfigLocalAIAPIKeyEnv(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"custom name", "ai_api_key_env=MY_VAR\n", "MY_VAR"},
{"empty falls back to default", "ai_api_key_env=\n", DefaultAIAPIKeyEnv},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AIAPIKeyEnv != tc.want {
t.Errorf("AIAPIKeyEnv = %q, want %q", cfg.AIAPIKeyEnv, tc.want)
}
})
}
}
// TestLoad_ConfigLocalSessionStartDocs covers session_start_docs accept
// (config.go:809-810 empty-skip, 815 clean, 819 append) and reject
// (812 absolute, 816 escape) arms.
func TestLoad_ConfigLocalSessionStartDocs(t *testing.T) {
t.Run("accepted with empty-skip and clean", func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"session_start_docs=docs/a.md",
"session_start_docs=b.md",
"session_start_docs=", // empty value: skipped, no phantom entry
"session_start_docs=sub/./c.md",
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"docs/a.md", "b.md", "sub/c.md"}
if !reflect.DeepEqual(cfg.SessionStartDocs, want) {
t.Errorf("SessionStartDocs = %v, want %v", cfg.SessionStartDocs, want)
}
})
for _, body := range []string{
"session_start_docs=/abs/x.md\n",
"session_start_docs=..\n",
"session_start_docs=../escape.md\n",
"session_start_docs=sub/../../escape.md\n",
} {
t.Run("rejected "+body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
// TestLoad_ConfigLocalSessionFilesEmptyAndSlash covers session_files
// empty-skip (config.go:828-829) and the backslash-prefix reject (837-839),
// which is reachable on unix because filepath.IsAbs(`\x`) is false there
// but the HasPrefix(`\`) guard still fires.
func TestLoad_ConfigLocalSessionFilesEmptyAndSlash(t *testing.T) {
t.Run("empty value skipped", func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"session_files=CLAUDE.md",
"session_files=",
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.SessionFiles) != 1 || cfg.SessionFiles[0] != "CLAUDE.md" {
t.Errorf("SessionFiles = %v, want [CLAUDE.md]", cfg.SessionFiles)
}
})
t.Run("backslash-prefix rejected", func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", `session_files=\x`+"\n")
if _, err := Load(root, ""); err == nil {
t.Fatal(`expected error for session_files=\x`)
}
})
}
// TestLoad_ConfigLocalSessionStartMailbox covers session_start_mailbox
// accept/empty (config.go:849-850 disable, 858 clean) and reject
// (851 absolute, 855 escape) arms.
func TestLoad_ConfigLocalSessionStartMailbox(t *testing.T) {
accept := []struct {
name string
body string
want string
}{
{"simple name", "session_start_mailbox=Inbox.md\n", "Inbox.md"},
{"empty disables", "session_start_mailbox=\n", ""},
{"clean relative", "session_start_mailbox=sub/./M.md\n", "sub/M.md"},
}
for _, tc := range accept {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionStartMailbox != tc.want {
t.Errorf("SessionStartMailbox = %q, want %q", cfg.SessionStartMailbox, tc.want)
}
})
}
for _, body := range []string{
"session_start_mailbox=/abs/M.md\n",
"session_start_mailbox=..\n",
"session_start_mailbox=../escape.md\n",
} {
t.Run("rejected "+body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
// TestLoad_ConfigLocalSessionStartRoadmapGlob covers the
// session_start_roadmap_glob passthrough (config.go:865): the glob is
// stored verbatim, empty disables discovery.
func TestLoad_ConfigLocalSessionStartRoadmapGlob(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"glob verbatim", "session_start_roadmap_glob=plan*.md\n", "plan*.md"},
{"empty disables", "session_start_roadmap_glob=\n", ""},
{"other glob verbatim", "session_start_roadmap_glob=ROADMAP-*.md\n", "ROADMAP-*.md"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.SessionStartRoadmapGlob != tc.want {
t.Errorf("SessionStartRoadmapGlob = %q, want %q", cfg.SessionStartRoadmapGlob, tc.want)
}
})
}
}
// TestLoad_ConfigLocalInitDetectionThresholdEmpty covers the empty-value
// arm of init_detection_threshold (config.go:1061-1063); the 0.85 and
// malformed cases are covered elsewhere.
func TestLoad_ConfigLocalInitDetectionThresholdEmpty(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "init_detection_threshold=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.InitDetectionThreshold != 0 {
t.Errorf("InitDetectionThreshold = %v, want 0", cfg.InitDetectionThreshold)
}
}
// Group C — nil guards + IsInitialized depth (local.go arms; the init.go
// nil guards live in init_test.go).
// TestWriteLocalKeys_NilConfig covers local.go:25-27.
func TestWriteLocalKeys_NilConfig(t *testing.T) {
if err := WriteLocalKeys(nil, map[string]string{"x": "y"}); err == nil {
t.Fatal("WriteLocalKeys(nil, ...) = nil, want error")
}
}
// TestWriteLocalKeys_EmptyMap covers local.go:28-30: an empty map is a
// no-op that returns nil and creates no config.local.
func TestWriteLocalKeys_EmptyMap(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, nil); err != nil {
t.Fatalf("WriteLocalKeys(cfg, nil) = %v, want nil", err)
}
if _, err := os.Stat(filepath.Join(wsDir, LocalFilename)); !os.IsNotExist(err) {
t.Errorf("config.local stat err = %v, want IsNotExist", err)
}
}
// Group D — filesystem I/O error branches, NO seam (dir-/file-in-the-way).
// Assertions target the package's own wrap text, never the OS errno, so
// they are portable (EISDIR/ENOTDIR are both non-os.ErrNotExist).
// TestApplyLocal_ErrorsWhenConfigLocalIsADirectory covers the applyLocal
// ReadFile non-NotExist arm (config.go:687-688) and the Load read wrap
// (638-639): the workspace stat succeeds (IsDir true), then ReadFile on a
// directory fails.
func TestApplyLocal_ErrorsWhenConfigLocalIsADirectory(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(wsDir, "config.local"), 0o755); err != nil {
t.Fatal(err)
}
_, err := Load(root, "")
if err == nil {
t.Fatal("expected Load to error when config.local is a directory")
}
if !strings.Contains(err.Error(), "read config.local") {
t.Errorf("error = %q, want it to contain %q", err.Error(), "read config.local")
}
}
// TestWriteLocalKeys_ErrorsWhenConfigLocalIsADirectory covers
// local.go:38-40 (ReadFile non-NotExist). WriteLocalKeys reads only
// cfg.Workspace, so a minimal hand-built Config suffices.
func TestWriteLocalKeys_ErrorsWhenConfigLocalIsADirectory(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(wsDir, LocalFilename), 0o755); err != nil {
t.Fatal(err)
}
cfg := &Config{Workspace: wsDir}
err := WriteLocalKeys(cfg, map[string]string{"automation": "auto"})
if err == nil {
t.Fatal("expected WriteLocalKeys to error when config.local is a directory")
}
if !strings.Contains(err.Error(), "read config.local") {
t.Errorf("error = %q, want it to contain %q", err.Error(), "read config.local")
}
}
// TestWriteLocalKeys_PreservesRawNonKVLine covers the no-'=' raw
// passthrough in WriteLocalKeys (local.go:52-54): a malformed line and a
// comment survive an upsert untouched.
func TestWriteLocalKeys_PreservesRawNonKVLine(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, LocalFilename, strings.Join([]string{
"# a comment",
"raw-line-without-equals",
"stale_days=7",
}, "\n")+"\n")
// WriteLocalKeys reads only cfg.Workspace and parses config.local
// itself; a hand-built Config avoids Load rejecting the malformed line.
cfg := &Config{Workspace: wsDir}
if err := WriteLocalKeys(cfg, map[string]string{"automation": "manual"}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(wsDir, LocalFilename))
if err != nil {
t.Fatal(err)
}
got := string(b)
if !strings.Contains(got, "raw-line-without-equals") {
t.Errorf("raw non-kv line not preserved:\n%s", got)
}
if !strings.Contains(got, "# a comment") {
t.Errorf("comment not preserved:\n%s", got)
}
}
// Group E — resolveUsername fallback + empty-username Init (the Init half
// lives in init_test.go).
// TestResolveUsername_FallbackWhenNoIdentity covers the final fallback
// return (config.go:536). Every identity source is nulled: EECO_USERNAME
// slugs to empty, USER/USERNAME are empty, and GIT_CONFIG_GLOBAL/SYSTEM
// point at nonexistent files so the host's own git user.name cannot leak
// (mirrors the H1.1 gitx isolation). The bare-.git fixture makes
// gitx.UserName return ("", nil).
func TestResolveUsername_FallbackWhenNoIdentity(t *testing.T) {
t.Setenv(UsernameEnv, "!!!") // slugs to "" -> candidate skipped (overrides TestMain "tester")
t.Setenv("USER", "")
t.Setenv("USERNAME", "")
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "nope-global"))
t.Setenv("GIT_CONFIG_SYSTEM", filepath.Join(t.TempDir(), "nope-system"))
root := newRepo(t)
if got := resolveUsername(root); got != FallbackUsername {
t.Errorf("resolveUsername = %q, want %q", got, FallbackUsername)
}
}