ajhahn.de
← eeco
Go 248 lines
package config

import (
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
)

func TestGlobalConfigDir_EnvPrecedence(t *testing.T) {
	// EECO_CONFIG_HOME wins outright.
	t.Setenv(GlobalConfigEnv, "/explicit/dir")
	t.Setenv("XDG_CONFIG_HOME", "/xdg")
	if got := GlobalConfigDir(); got != "/explicit/dir" {
		t.Fatalf("GlobalConfigDir() = %q, want /explicit/dir", got)
	}

	// With EECO_CONFIG_HOME empty, XDG_CONFIG_HOME/eeco is used.
	t.Setenv(GlobalConfigEnv, "")
	t.Setenv("XDG_CONFIG_HOME", "/xdg")
	if got, want := GlobalConfigDir(), filepath.Join("/xdg", "eeco"); got != want {
		t.Fatalf("GlobalConfigDir() = %q, want %q", got, want)
	}

	// With neither, fall back to <home>/.config/eeco. os.UserHomeDir reads
	// $HOME on unix but %USERPROFILE% on Windows, so set the platform's var.
	t.Setenv(GlobalConfigEnv, "")
	t.Setenv("XDG_CONFIG_HOME", "")
	homeEnv := "HOME"
	if runtime.GOOS == "windows" {
		homeEnv = "USERPROFILE"
	}
	home := filepath.FromSlash("/home/tester")
	t.Setenv(homeEnv, home)
	if got, want := GlobalConfigDir(), filepath.Join(home, ".config", "eeco"); got != want {
		t.Fatalf("GlobalConfigDir() = %q, want %q", got, want)
	}
}

func TestLoad_ThreeLayerPrecedence(t *testing.T) {
	root := newRepo(t)
	write(t, root, "go.mod", "module x\n")

	gdir := t.TempDir()
	t.Setenv(GlobalConfigEnv, gdir)
	write(t, gdir, LocalFilename, strings.Join([]string{
		"automation=auto",
		"ai_budget=5",
		"stale_days=99",
	}, "\n"))

	wsDir := filepath.Join(root, "tester", DefaultWorkspace)
	if err := os.MkdirAll(wsDir, 0o755); err != nil {
		t.Fatal(err)
	}
	// Local overrides one global key; the others fall through from global.
	write(t, wsDir, LocalFilename, strings.Join([]string{
		"automation=manual",
		"context_path=ctx.md",
	}, "\n"))

	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Automation != AutomationManual {
		t.Errorf("automation = %q, want manual (local wins)", cfg.Automation)
	}
	if cfg.AIBudget != 5 {
		t.Errorf("ai_budget = %d, want 5 (from global)", cfg.AIBudget)
	}
	if cfg.StaleDays != 99 {
		t.Errorf("stale_days = %d, want 99 (from global)", cfg.StaleDays)
	}
	if cfg.ContextPath != "ctx.md" {
		t.Errorf("context_path = %q, want ctx.md (local only)", cfg.ContextPath)
	}
	if cfg.BugReportDir != DefaultBugReportDir {
		t.Errorf("bug_report_dir = %q, want default %q", cfg.BugReportDir, DefaultBugReportDir)
	}
}

func TestLoad_GlobalAppliesWithoutWorkspace(t *testing.T) {
	root := newRepo(t)
	write(t, root, "go.mod", "module x\n")
	gdir := t.TempDir()
	t.Setenv(GlobalConfigEnv, gdir)
	write(t, gdir, LocalFilename, "automation=scaffold\n")

	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Automation != AutomationScaffold {
		t.Errorf("automation = %q, want scaffold (global applies pre-init)", cfg.Automation)
	}
}

func TestKnownKeysAndEffectiveValue(t *testing.T) {
	if !KnownKey("automation") || KnownKey("definitely_not_a_key") {
		t.Fatal("KnownKey mis-classified a key")
	}
	if len(KnownKeys()) < 20 {
		t.Fatalf("KnownKeys() returned %d keys, expected the full set", len(KnownKeys()))
	}
	root := newRepo(t)
	write(t, root, "go.mod", "module x\n")
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if v, ok := EffectiveValue(cfg, "automation"); !ok || v != string(DefaultAutomation) {
		t.Errorf("EffectiveValue(automation) = %q,%v, want %q,true", v, ok, DefaultAutomation)
	}
	if _, ok := EffectiveValue(cfg, "nope"); ok {
		t.Error("EffectiveValue returned ok for an unknown key")
	}
}

func TestValidateSetValue(t *testing.T) {
	cases := []struct {
		key, val string
		wantErr  bool
	}{
		{"automation", "auto", false},
		{"ai_budget", "3", false},
		{"context_path", "x.md", false},
		// Floor-invariant enum keys tolerate any value (normalized at load),
		// so set mirrors load and does NOT reject them.
		{"automation", "bogus", false},
		// Strictly-typed keys reject malformed values at parse time.
		{"ai_budget", "notanint", true},
		{"stale_days", "notanint", true},
		{"init_detection_threshold", "2", true}, // out of [0,1]
		{"no_such_key", "x", true},              // unknown key
	}
	for _, c := range cases {
		err := ValidateSetValue(c.key, c.val)
		if (err != nil) != c.wantErr {
			t.Errorf("ValidateSetValue(%q,%q) err=%v, wantErr=%v", c.key, c.val, err, c.wantErr)
		}
	}
}

func TestWriteGlobalKeys_UpsertsAndCreatesDir(t *testing.T) {
	gdir := filepath.Join(t.TempDir(), "nested", "eeco") // does not exist yet
	t.Setenv(GlobalConfigEnv, gdir)

	if err := WriteGlobalKeys(map[string]string{"automation": "auto"}); err != nil {
		t.Fatal(err)
	}
	if err := WriteGlobalKeys(map[string]string{"ai_budget": "3"}); err != nil {
		t.Fatal(err)
	}
	// Overwrite an existing key in place.
	if err := WriteGlobalKeys(map[string]string{"automation": "manual"}); err != nil {
		t.Fatal(err)
	}

	b, err := os.ReadFile(filepath.Join(gdir, LocalFilename))
	if err != nil {
		t.Fatal(err)
	}
	got := string(b)
	if !strings.Contains(got, "automation=manual") {
		t.Errorf("global file missing automation=manual:\n%s", got)
	}
	if !strings.Contains(got, "ai_budget=3") {
		t.Errorf("global file missing ai_budget=3:\n%s", got)
	}
	if strings.Contains(got, "automation=auto") {
		t.Errorf("global file kept stale automation=auto:\n%s", got)
	}

	// And the value resolves through Load.
	root := newRepo(t)
	write(t, root, "go.mod", "module x\n")
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Automation != AutomationManual {
		t.Errorf("automation = %q, want manual after WriteGlobalKeys", cfg.Automation)
	}
}

func TestParseLocalFile(t *testing.T) {
	if m, err := ParseLocalFile(""); err != nil || len(m) != 0 {
		t.Fatalf("ParseLocalFile(empty) = %v,%v want empty,nil", m, err)
	}
	dir := t.TempDir()
	p := filepath.Join(dir, LocalFilename)
	content := "# comment\n\nautomation=auto\nai_budget = \"3\"\ngate=go build\ngate=go vet\nattribution_pattern=a=b\nbroken_line_without_eq\n"
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	m, err := ParseLocalFile(p)
	if err != nil {
		t.Fatal(err)
	}
	if m["automation"] != "auto" {
		t.Errorf("automation = %q", m["automation"])
	}
	if m["ai_budget"] != "3" {
		t.Errorf("ai_budget = %q, want 3 (quotes stripped)", m["ai_budget"])
	}
	if m["gate"] != "go vet" {
		t.Errorf("gate = %q, want last-wins go vet", m["gate"])
	}
	if m["attribution_pattern"] != "a=b" {
		t.Errorf("attribution_pattern = %q, want a=b (split on first =)", m["attribution_pattern"])
	}
	if _, ok := m["broken_line_without_eq"]; ok {
		t.Error("a line without '=' should be skipped")
	}
	if m2, err := ParseLocalFile(filepath.Join(dir, "missing")); err != nil || len(m2) != 0 {
		t.Fatalf("ParseLocalFile(missing) = %v,%v want empty,nil", m2, err)
	}
	// TestMain pins EECO_CONFIG_HOME, so the global path resolves non-empty.
	if GlobalConfigLocalPath() == "" {
		t.Error("GlobalConfigLocalPath() empty despite EECO_CONFIG_HOME set")
	}
}

func TestDeclaredKeys(t *testing.T) {
	if keys, err := DeclaredKeys(""); err != nil || len(keys) != 0 {
		t.Fatalf("DeclaredKeys(empty) = %v,%v, want empty,nil", keys, err)
	}
	dir := t.TempDir()
	path := filepath.Join(dir, LocalFilename)
	if err := os.WriteFile(path, []byte("# c\n\nautomation=auto\nai_budget=2\nunknown_key=x\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	keys, err := DeclaredKeys(path)
	if err != nil {
		t.Fatal(err)
	}
	for _, want := range []string{"automation", "ai_budget", "unknown_key"} {
		if !keys[want] {
			t.Errorf("DeclaredKeys missing %q", want)
		}
	}
	if keys["nonexistent"] {
		t.Error("DeclaredKeys reported a key not in the file")
	}
}