ajhahn.de
← eeco
Go 1013 lines
package hooks

import (
	"encoding/json"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/ajhahnde/eeco/internal/config"
)

// newCfg builds a config rooted at a fresh temp repo (with a .git
// directory so pre-commit wiring is supported) and a workspace beside
// it. settings, when non-empty, becomes SessionSettingsPath.
func newCfg(t *testing.T, settings string) *config.Config {
	t.Helper()
	root := t.TempDir()
	if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	ws := filepath.Join(root, ".eeco")
	if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
		t.Fatal(err)
	}
	return &config.Config{
		RepoRoot:            root,
		WorkspaceName:       ".eeco",
		Workspace:           ws,
		SessionSettingsPath: settings,
		PreCommitWorkflows:  config.DefaultPreCommitWorkflows(),
		PostMergeWorkflows:  config.DefaultPostMergeWorkflows(),
	}
}

func postMergePath(cfg *config.Config) string {
	return filepath.Join(cfg.RepoRoot, ".git", "hooks", "post-merge")
}

func preCommitPath(cfg *config.Config) string {
	return filepath.Join(cfg.RepoRoot, ".git", "hooks", "pre-commit")
}

func TestPreCommit_EnableWritesExecutableMarkedScript(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX exec bit is not represented on Windows filesystems")
	}
	cfg := newCfg(t, "")
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatalf("EnablePreCommit: %v", err)
	}
	p := preCommitPath(cfg)
	info, err := os.Stat(p)
	if err != nil {
		t.Fatalf("stat pre-commit: %v", err)
	}
	if info.Mode().Perm()&0o100 == 0 {
		t.Errorf("pre-commit not executable: %v", info.Mode())
	}
	b, _ := os.ReadFile(p)
	if !strings.Contains(string(b), preCommitMarker) {
		t.Errorf("script missing marker line:\n%s", b)
	}
	if !strings.Contains(string(b), "run leak-guard") {
		t.Errorf("script does not invoke leak-guard:\n%s", b)
	}
	if !strings.Contains(string(b), "run version-sync") {
		t.Errorf("script does not invoke version-sync (default list):\n%s", b)
	}
	if !strings.Contains(string(b), "set -e") {
		t.Errorf("script missing `set -e` for fail-fast chain:\n%s", b)
	}
}

func TestPreCommit_EnableHonoursCustomWorkflows(t *testing.T) {
	cfg := newCfg(t, "")
	cfg.PreCommitWorkflows = []string{"comment-hygiene", "leak-guard"}
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatalf("EnablePreCommit: %v", err)
	}
	b, _ := os.ReadFile(preCommitPath(cfg))
	got := string(b)
	if !strings.Contains(got, "run comment-hygiene") {
		t.Errorf("script does not invoke comment-hygiene:\n%s", got)
	}
	if !strings.Contains(got, "run leak-guard") {
		t.Errorf("script does not invoke leak-guard:\n%s", got)
	}
	if strings.Contains(got, "run version-sync") {
		t.Errorf("custom list must not include the default version-sync step:\n%s", got)
	}
	chSeen := strings.Index(got, "run comment-hygiene")
	lgSeen := strings.Index(got, "run leak-guard")
	if chSeen < 0 || lgSeen < 0 || chSeen > lgSeen {
		t.Errorf("workflows out of declared order:\n%s", got)
	}
}

func TestPreCommit_EnableRefusesEmptyWorkflowList(t *testing.T) {
	cfg := newCfg(t, "")
	cfg.PreCommitWorkflows = nil
	if _, err := EnablePreCommit(cfg); err == nil {
		t.Fatal("expected EnablePreCommit to refuse an empty workflow list")
	}
	if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
		t.Errorf("hook should not exist after refused install (err=%v)", err)
	}
}

func TestPreCommit_EnableIsIdempotent(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatal(err)
	}
	msg, err := EnablePreCommit(cfg)
	if err != nil {
		t.Fatalf("second EnablePreCommit errored: %v", err)
	}
	if !strings.Contains(msg, "already enabled") {
		t.Errorf("msg = %q, want already-enabled", msg)
	}
}

func TestPreCommit_EnableRefusesForeignHook(t *testing.T) {
	cfg := newCfg(t, "")
	hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		t.Fatal(err)
	}
	foreign := "#!/bin/sh\necho someone elses hook\n"
	if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(foreign), 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := EnablePreCommit(cfg); err == nil {
		t.Fatal("expected EnablePreCommit to refuse a foreign hook")
	}
	b, _ := os.ReadFile(preCommitPath(cfg))
	if string(b) != foreign {
		t.Errorf("foreign hook was modified:\n%s", b)
	}
}

func TestPreCommit_DisableRemovesOnlyEecoHook(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := DisablePreCommit(cfg); err != nil {
		t.Fatalf("DisablePreCommit: %v", err)
	}
	if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
		t.Errorf("pre-commit still present after disable (err=%v)", err)
	}
	// Disabling again is a clean no-op.
	if msg, err := DisablePreCommit(cfg); err != nil || !strings.Contains(msg, "not enabled") {
		t.Errorf("re-disable: msg=%q err=%v", msg, err)
	}
}

func TestPreCommit_DisableLeavesForeignHook(t *testing.T) {
	cfg := newCfg(t, "")
	hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		t.Fatal(err)
	}
	foreign := "#!/bin/sh\nmake lint\n"
	fp := filepath.Join(hooksDir, "pre-commit")
	if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := DisablePreCommit(cfg); err == nil {
		t.Fatal("expected DisablePreCommit to refuse a foreign hook")
	}
	if b, _ := os.ReadFile(fp); string(b) != foreign {
		t.Errorf("foreign hook was touched:\n%s", b)
	}
}

func TestPreCommit_DisableViaMarkerWhenLedgerLost(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatal(err)
	}
	// Simulate a lost ledger: removing it must not strand the hook,
	// because the marker line still identifies it as eeco's.
	if err := os.Remove(ledgerPath(cfg)); err != nil {
		t.Fatal(err)
	}
	if _, err := DisablePreCommit(cfg); err != nil {
		t.Fatalf("DisablePreCommit with lost ledger: %v", err)
	}
	if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
		t.Error("hook not removed via marker fallback")
	}
}

func TestPostMerge_EnableWritesNonBlockingMarkedScript(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX exec bit is not represented on Windows filesystems")
	}
	cfg := newCfg(t, "")
	if _, err := EnablePostMerge(cfg); err != nil {
		t.Fatalf("EnablePostMerge: %v", err)
	}
	p := postMergePath(cfg)
	info, err := os.Stat(p)
	if err != nil {
		t.Fatalf("stat post-merge: %v", err)
	}
	if info.Mode().Perm()&0o100 == 0 {
		t.Errorf("post-merge not executable: %v", info.Mode())
	}
	got := string(mustRead(t, p))
	if !strings.Contains(got, postMergeMarker) {
		t.Errorf("script missing marker line:\n%s", got)
	}
	if !strings.Contains(got, "run memory-drift || true") {
		t.Errorf("script does not invoke memory-drift with swallowed exit:\n%s", got)
	}
	if !strings.Contains(got, "run doc-drift || true") {
		t.Errorf("script does not invoke doc-drift with swallowed exit (default list):\n%s", got)
	}
	// A post-merge runs after the merge has completed, so a drift finding
	// must not abort the hook: no `set -e`.
	if strings.Contains(got, "set -e") {
		t.Errorf("post-merge must not use `set -e`:\n%s", got)
	}
}

func TestPostMerge_EnableRefusesEmptyWorkflowList(t *testing.T) {
	cfg := newCfg(t, "")
	cfg.PostMergeWorkflows = nil
	if _, err := EnablePostMerge(cfg); err == nil {
		t.Fatal("expected EnablePostMerge to refuse an empty workflow list")
	}
	if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
		t.Errorf("hook should not exist after refused install (err=%v)", err)
	}
}

func TestPostMerge_EnableRefusesForeignHook(t *testing.T) {
	cfg := newCfg(t, "")
	hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		t.Fatal(err)
	}
	foreign := "#!/bin/sh\necho someone elses post-merge\n"
	if err := os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(foreign), 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := EnablePostMerge(cfg); err == nil {
		t.Fatal("expected EnablePostMerge to refuse a foreign hook")
	}
	if b := mustRead(t, postMergePath(cfg)); string(b) != foreign {
		t.Errorf("foreign hook was modified:\n%s", b)
	}
}

func TestPostMerge_DisableRemovesOnlyEecoHook(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePostMerge(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := DisablePostMerge(cfg); err != nil {
		t.Fatalf("DisablePostMerge: %v", err)
	}
	if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
		t.Errorf("post-merge still present after disable (err=%v)", err)
	}
	if msg, err := DisablePostMerge(cfg); err != nil || !strings.Contains(msg, "not enabled") {
		t.Errorf("re-disable: msg=%q err=%v", msg, err)
	}
}

func TestPostMerge_DisableViaMarkerWhenLedgerLost(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePostMerge(cfg); err != nil {
		t.Fatal(err)
	}
	if err := os.Remove(ledgerPath(cfg)); err != nil {
		t.Fatal(err)
	}
	if _, err := DisablePostMerge(cfg); err != nil {
		t.Fatalf("DisablePostMerge with lost ledger: %v", err)
	}
	if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
		t.Error("hook not removed via marker fallback")
	}
}

func TestPreCommit_RefreshIsNoOpWhenCurrent(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshPreCommit(cfg)
	if err != nil {
		t.Fatalf("RefreshPreCommit: %v", err)
	}
	if !strings.Contains(msg, "already current") {
		t.Errorf("refresh msg = %q, want already-current", msg)
	}
}

func TestPreCommit_RefreshNotEnabledIsNoOp(t *testing.T) {
	cfg := newCfg(t, "")
	msg, err := RefreshPreCommit(cfg)
	if err != nil {
		t.Fatalf("RefreshPreCommit: %v", err)
	}
	if !strings.Contains(msg, "not enabled") {
		t.Errorf("refresh msg = %q, want not-enabled", msg)
	}
	if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
		t.Errorf("refresh created a hook where none existed (err=%v)", err)
	}
}

func TestPreCommit_RefreshRewritesStaleBinaryPath(t *testing.T) {
	cfg := newCfg(t, "")
	hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		t.Fatal(err)
	}
	// A marker-carrying script with a stale absolute binary path — the
	// post-`brew upgrade eeco` / post-workspace-move state the self-heal fixes.
	stale := "#!/bin/sh\n" +
		"# " + preCommitMarker + "\n" +
		"set -e\n" +
		"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n" +
		"\"$EECO\" run leak-guard\n"
	fp := preCommitPath(cfg)
	if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshPreCommit(cfg)
	if err != nil {
		t.Fatalf("RefreshPreCommit: %v", err)
	}
	if !strings.Contains(msg, "refreshed") {
		t.Errorf("refresh msg = %q, want refreshed", msg)
	}
	b := mustRead(t, fp)
	if strings.Contains(string(b), "Cellar/eeco/2.0.0") {
		t.Errorf("stale binary path survived refresh:\n%s", b)
	}
	if string(b) != preCommitScript(cfg.PreCommitWorkflows) {
		t.Errorf("refreshed script is not the current desired script:\n%s", b)
	}
}

func TestPreCommit_RefreshRefusesForeignHook(t *testing.T) {
	cfg := newCfg(t, "")
	hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		t.Fatal(err)
	}
	foreign := "#!/bin/sh\necho someone elses hook\n"
	fp := preCommitPath(cfg)
	if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := RefreshPreCommit(cfg); err == nil {
		t.Fatal("expected RefreshPreCommit to refuse a foreign hook")
	}
	if b := mustRead(t, fp); string(b) != foreign {
		t.Errorf("foreign hook was modified by refresh:\n%s", b)
	}
}

func TestPostMerge_RefreshIsNoOpWhenCurrent(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnablePostMerge(cfg); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshPostMerge(cfg)
	if err != nil {
		t.Fatalf("RefreshPostMerge: %v", err)
	}
	if !strings.Contains(msg, "already current") {
		t.Errorf("refresh msg = %q, want already-current", msg)
	}
}

func TestPostMerge_RefreshRewritesStaleBinaryPath(t *testing.T) {
	cfg := newCfg(t, "")
	hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		t.Fatal(err)
	}
	stale := "#!/bin/sh\n" +
		"# " + postMergeMarker + "\n" +
		"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n" +
		"\"$EECO\" run memory-drift || true\n"
	fp := postMergePath(cfg)
	if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshPostMerge(cfg)
	if err != nil {
		t.Fatalf("RefreshPostMerge: %v", err)
	}
	if !strings.Contains(msg, "refreshed") {
		t.Errorf("refresh msg = %q, want refreshed", msg)
	}
	b := mustRead(t, fp)
	if strings.Contains(string(b), "Cellar/eeco/2.0.0") {
		t.Errorf("stale binary path survived refresh:\n%s", b)
	}
	if string(b) != postMergeScript(cfg.PostMergeWorkflows) {
		t.Errorf("refreshed script is not the current desired script:\n%s", b)
	}
}

func mustRead(t *testing.T, path string) []byte {
	t.Helper()
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	return b
}

func TestSessionStart_NotConfigured(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := EnableSessionStart(cfg); err != ErrSessionNotConfigured {
		t.Errorf("err = %v, want ErrSessionNotConfigured", err)
	}
	if _, err := DisableSessionStart(cfg); err != ErrSessionNotConfigured {
		t.Errorf("disable err = %v, want ErrSessionNotConfigured", err)
	}
}

func TestSessionStart_EnableCreatesValidGroup(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)

	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatalf("EnableSessionStart: %v", err)
	}
	b, err := os.ReadFile(sp)
	if err != nil {
		t.Fatal(err)
	}
	var root map[string]any
	if err := json.Unmarshal(b, &root); err != nil {
		t.Fatalf("settings not valid JSON: %v", err)
	}
	if !sessionInstalled(root) {
		t.Errorf("session group not present:\n%s", b)
	}
	// Idempotent.
	msg, err := EnableSessionStart(cfg)
	if err != nil || !strings.Contains(msg, "already enabled") {
		t.Errorf("re-enable: msg=%q err=%v", msg, err)
	}
}

func TestSessionStart_BacksUpAndPreservesForeignKeys(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	original := `{
  "model": "x",
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "other-tool run" } ] }
    ]
  }
}`
	if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}

	msg, err := EnableSessionStart(cfg)
	if err != nil {
		t.Fatalf("EnableSessionStart: %v", err)
	}
	if !strings.Contains(msg, "backup ") {
		t.Errorf("expected a backup path in msg, got %q", msg)
	}
	// A backup of the exact original bytes lives inside the workspace.
	backups, _ := os.ReadDir(filepath.Join(cfg.Workspace, "state", backupSubdir))
	if len(backups) != 1 {
		t.Fatalf("want 1 backup, got %d", len(backups))
	}
	bb, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", backupSubdir, backups[0].Name()))
	if string(bb) != original {
		t.Errorf("backup is not the exact original:\n%s", bb)
	}

	var root map[string]any
	b, _ := os.ReadFile(sp)
	if err := json.Unmarshal(b, &root); err != nil {
		t.Fatal(err)
	}
	if root["model"] != "x" {
		t.Errorf("foreign top-level key lost: %v", root["model"])
	}
	groups := sessionGroups(root)
	if len(groups) != 2 {
		t.Fatalf("want 2 SessionStart groups (foreign + eeco), got %d", len(groups))
	}
}

func TestSessionStart_RefusesMalformedJSON(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	bad := "{ not valid json"
	if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := EnableSessionStart(cfg); err == nil {
		t.Fatal("expected refusal on malformed settings")
	}
	if b, _ := os.ReadFile(sp); string(b) != bad {
		t.Errorf("malformed settings file was modified:\n%s", b)
	}
}

func TestSessionStart_DisableRemovesOnlyEecoGroup(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	original := `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep-me"}]}]}}`
	if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := DisableSessionStart(cfg); err != nil {
		t.Fatalf("DisableSessionStart: %v", err)
	}
	var root map[string]any
	b, _ := os.ReadFile(sp)
	if err := json.Unmarshal(b, &root); err != nil {
		t.Fatal(err)
	}
	if sessionInstalled(root) {
		t.Error("eeco group still present after disable")
	}
	groups := sessionGroups(root)
	if len(groups) != 1 {
		t.Fatalf("foreign group not preserved, groups=%d", len(groups))
	}
	gm := groups[0].(map[string]any)
	hs := gm["hooks"].([]any)
	h0 := hs[0].(map[string]any)
	if h0["command"] != "keep-me" {
		t.Errorf("wrong group survived: %v", h0["command"])
	}
}

func TestStatusReflectsOnDisk(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)

	got := strings.Join(Status(cfg), "\n")
	for _, want := range []string{"pre-commit: off", "post-merge: off", "session-start: off", "commit-msg: off", "commit-guard: off"} {
		if !strings.Contains(got, want) {
			t.Errorf("fresh status = %q, want %q", got, want)
		}
	}
	if _, err := EnablePreCommit(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := EnablePostMerge(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := EnableCommitMsg(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := EnableCommitGuard(cfg); err != nil {
		t.Fatal(err)
	}
	got = strings.Join(Status(cfg), "\n")
	for _, want := range []string{"pre-commit: on", "post-merge: on", "session-start: on", "commit-msg: on", "commit-guard: on"} {
		if !strings.Contains(got, want) {
			t.Errorf("enabled status = %q, want %q", got, want)
		}
	}
	if ss := ShortState(cfg); ss != "pre-commit:on post-merge:on session:on commit-msg:on commit-guard:on" {
		t.Errorf("ShortState = %q", ss)
	}
}

func TestSessionStart_NotConfiguredStatus(t *testing.T) {
	cfg := newCfg(t, "")
	if s := sessionStatus(cfg); s != "not configured" {
		t.Errorf("sessionStatus = %q, want 'not configured'", s)
	}
}

func TestSessionStart_FileOnlyEnablesAndDisables(t *testing.T) {
	cfg := newCfg(t, "")
	cfg.SessionFiles = []string{"CLAUDE.md"}
	msg, err := EnableSessionStart(cfg)
	if err != nil {
		t.Fatalf("EnableSessionStart: %v", err)
	}
	if !strings.Contains(msg, "files") {
		t.Errorf("msg = %q, want a files mention", msg)
	}
	if s := sessionStatus(cfg); s != "on" {
		t.Errorf("status after enable = %q, want on", s)
	}
	if _, derr := DisableSessionStart(cfg); derr != nil {
		t.Fatalf("DisableSessionStart: %v", derr)
	}
	if s := sessionStatus(cfg); s != "off" {
		t.Errorf("status after disable = %q, want off", s)
	}
	if _, err := os.Stat(filepath.Join(cfg.RepoRoot, "CLAUDE.md")); !os.IsNotExist(err) {
		t.Errorf("CLAUDE.md still present after disable (err=%v)", err)
	}
}

func TestSessionStart_BothChannelsCompose(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	cfg.SessionFiles = []string{"AGENTS.md"}
	msg, err := EnableSessionStart(cfg)
	if err != nil {
		t.Fatalf("EnableSessionStart: %v", err)
	}
	if !strings.Contains(msg, sp) || !strings.Contains(msg, "files") {
		t.Errorf("msg = %q, want both JSON path and files mention", msg)
	}
	if s := sessionStatus(cfg); s != "on" {
		t.Errorf("status = %q, want on", s)
	}
	// The JSON file got the eeco group.
	jb, _ := os.ReadFile(sp)
	var root map[string]any
	if err := json.Unmarshal(jb, &root); err != nil {
		t.Fatalf("settings not valid JSON: %v", err)
	}
	if !sessionInstalled(root) {
		t.Errorf("session group missing in JSON channel")
	}
	// The file got the marker block.
	fb, _ := os.ReadFile(filepath.Join(cfg.RepoRoot, "AGENTS.md"))
	if !strings.Contains(string(fb), sessionStartMarker) {
		t.Errorf("AGENTS.md missing marker block:\n%s", fb)
	}
	if _, derr := DisableSessionStart(cfg); derr != nil {
		t.Fatalf("DisableSessionStart: %v", derr)
	}
	if s := sessionStatus(cfg); s != "off" {
		t.Errorf("status after disable = %q, want off", s)
	}
}

func TestSessionStart_RefreshUpdatesBlock(t *testing.T) {
	cfg := newCfg(t, "")
	cfg.SessionFiles = []string{"CLAUDE.md"}
	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatal(err)
	}
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	// Adding a README.md should change the auto-detected reading routine.
	if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "README.md"), []byte("# x\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "refreshed") {
		t.Errorf("msg = %q, want 'refreshed' mention", msg)
	}
	b, _ := os.ReadFile(path)
	if !strings.Contains(string(b), "README.md") {
		t.Errorf("refresh did not pick up README.md:\n%s", b)
	}
}

func TestSessionStart_RefreshNoFilesIsNoOp(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "nothing to refresh") {
		t.Errorf("msg = %q, want 'nothing to refresh' for JSON-only", msg)
	}
}

func TestSessionStart_RefreshUnconfiguredErrors(t *testing.T) {
	cfg := newCfg(t, "")
	if _, err := RefreshSessionStart(cfg); err != ErrSessionNotConfigured {
		t.Errorf("err = %v, want ErrSessionNotConfigured", err)
	}
}

// fakeBrewCellar lays down a tmpdir-rooted brew layout
// (<prefix>/Cellar/eeco/<version>/bin/eeco) plus a stable bin shim
// (<prefix>/bin/eeco). Returns the prefix, the versioned cellar binary
// path, and the stable shim path.
func fakeBrewCellar(t *testing.T, version string) (prefix, cellarBin, shim string) {
	t.Helper()
	prefix = t.TempDir()
	binDir := filepath.Join(prefix, "bin")
	cellarDir := filepath.Join(prefix, "Cellar", "eeco", version, "bin")
	if err := os.MkdirAll(binDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(cellarDir, 0o755); err != nil {
		t.Fatal(err)
	}
	cellarBin = filepath.Join(cellarDir, "eeco")
	if err := os.WriteFile(cellarBin, []byte("real-bin"), 0o755); err != nil {
		t.Fatal(err)
	}
	shim = filepath.Join(binDir, "eeco")
	if err := os.WriteFile(shim, []byte("#!/bin/sh\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	return prefix, cellarBin, shim
}

func TestStableBrewBin_CellarPathReturnsShim(t *testing.T) {
	// Homebrew is macOS/Linux only; the cellar-path heuristic is keyed
	// on the unix `/Cellar/eeco/` substring so a Windows tempdir
	// (backslash-separated) cannot exercise this path.
	if runtime.GOOS == "windows" {
		t.Skip("stableBrewBin matches a unix /Cellar/eeco/ substring; brew is not a Windows install path")
	}
	_, cellarBin, shim := fakeBrewCellar(t, "2.0.0")
	got := stableBrewBin(cellarBin)
	if got != shim {
		t.Errorf("stableBrewBin(%q) = %q, want %q", cellarBin, got, shim)
	}
}

func TestStableBrewBin_MissingShimReturnsEmpty(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("stableBrewBin matches a unix /Cellar/eeco/ substring; brew is not a Windows install path")
	}
	_, cellarBin, shim := fakeBrewCellar(t, "2.0.0")
	if err := os.Remove(shim); err != nil {
		t.Fatal(err)
	}
	if got := stableBrewBin(cellarBin); got != "" {
		t.Errorf("stableBrewBin without shim = %q, want \"\"", got)
	}
}

func TestStableBrewBin_NonCellarPathReturnsEmpty(t *testing.T) {
	cases := []string{
		"/usr/local/bin/eeco",
		"/opt/homebrew/bin/eeco",
		"/Users/anyone/go/bin/eeco",
		"eeco",
		"",
	}
	for _, p := range cases {
		if got := stableBrewBin(p); got != "" {
			t.Errorf("stableBrewBin(%q) = %q, want \"\"", p, got)
		}
	}
}

// staleSessionSettings writes a settings.json carrying an eeco
// SessionStart group whose command embeds a fake versioned cellar path
// that does not match the current sessionCommand() value.
func staleSessionSettings(t *testing.T, path, stale string) {
	t.Helper()
	body := map[string]any{
		"hooks": map[string]any{
			"SessionStart": []any{
				map[string]any{
					"hooks": []any{
						map[string]any{
							"type":    "command",
							"command": stale + " " + sessionToken,
						},
					},
				},
			},
		},
	}
	b, err := json.MarshalIndent(body, "", "  ")
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil {
		t.Fatal(err)
	}
}

// firstSessionCommand parses path and returns the command string of
// the first SessionStart group whose command carries the eeco
// namespace token. Empty string when nothing matches.
func firstSessionCommand(t *testing.T, path string) string {
	t.Helper()
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read settings: %v", err)
	}
	var root map[string]any
	if err := json.Unmarshal(b, &root); err != nil {
		t.Fatalf("parse settings: %v", err)
	}
	for _, g := range sessionGroups(root) {
		gm, ok := g.(map[string]any)
		if !ok {
			continue
		}
		hs, ok := gm["hooks"].([]any)
		if !ok {
			continue
		}
		for _, h := range hs {
			hm, ok := h.(map[string]any)
			if !ok {
				continue
			}
			cmd, ok := hm["command"].(string)
			if !ok {
				continue
			}
			if strings.Contains(cmd, sessionToken) {
				return cmd
			}
		}
	}
	return ""
}

func TestSessionStart_RefreshRewritesStaleJSONCommand(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	stale := `"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco"`
	staleSessionSettings(t, sp, stale)

	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "refreshed") {
		t.Errorf("msg = %q, want 'refreshed' on stale rewrite", msg)
	}
	got := firstSessionCommand(t, sp)
	want := sessionCommand()
	if got != want {
		t.Errorf("command after refresh = %q, want %q", got, want)
	}
	staleCmd := stale + " " + sessionToken
	if got == staleCmd {
		t.Errorf("stale command still present after refresh: %q", got)
	}
}

func TestSessionStart_RefreshCurrentJSONIsNoOp(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatal(err)
	}
	before, err := os.ReadFile(sp)
	if err != nil {
		t.Fatal(err)
	}
	beforeInfo, err := os.Stat(sp)
	if err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "nothing to refresh") {
		t.Errorf("msg = %q, want 'nothing to refresh' when JSON is current", msg)
	}
	after, _ := os.ReadFile(sp)
	if string(before) != string(after) {
		t.Errorf("settings file bytes changed on no-op refresh:\nbefore:\n%s\nafter:\n%s", before, after)
	}
	afterInfo, _ := os.Stat(sp)
	if !beforeInfo.ModTime().Equal(afterInfo.ModTime()) {
		t.Errorf("settings file mtime changed on no-op refresh: %v -> %v", beforeInfo.ModTime(), afterInfo.ModTime())
	}
}

func TestSessionStart_RefreshIgnoresForeignSessionEntries(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	body := `{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "other-tool run" } ] }
    ]
  }
}`
	if err := os.WriteFile(sp, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "nothing to refresh") {
		t.Errorf("msg = %q, want 'nothing to refresh' when no eeco group present", msg)
	}
	after, _ := os.ReadFile(sp)
	if string(after) != body {
		t.Errorf("foreign settings file modified by refresh:\n%s", after)
	}
}

func TestSessionStart_RefreshMissingSettingsFileIsNoOp(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "nothing to refresh") {
		t.Errorf("msg = %q, want 'nothing to refresh' when settings file absent", msg)
	}
	if _, err := os.Stat(sp); !os.IsNotExist(err) {
		t.Errorf("settings file created by refresh; want absent (err=%v)", err)
	}
}

func TestSessionStart_RefreshMalformedJSONErrors(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	bad := "{ not valid json"
	if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := RefreshSessionStart(cfg); err == nil {
		t.Fatal("expected refusal on malformed settings")
	}
	if b, _ := os.ReadFile(sp); string(b) != bad {
		t.Errorf("malformed settings file was modified:\n%s", b)
	}
}

func TestSessionStart_RefreshHandlesBothChannels(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)
	cfg.SessionFiles = []string{"CLAUDE.md"}
	stale := `"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco"`
	staleSessionSettings(t, sp, stale)
	// Pre-write a marker block so refresh has something to update.
	if _, err := EnableSessionStart(cfg); err == nil {
		// Already-installed JSON entry blocks Enable from writing a new
		// JSON group; the file channel still wires. Either path is fine
		// for this fixture — we only need both channels present.
		_ = err
	}

	msg, err := RefreshSessionStart(cfg)
	if err != nil {
		t.Fatalf("RefreshSessionStart: %v", err)
	}
	if !strings.Contains(msg, "refreshed") {
		t.Errorf("msg = %q, want 'refreshed'", msg)
	}
	if !strings.Contains(msg, sp) {
		t.Errorf("msg = %q, want JSON path mention", msg)
	}
	got := firstSessionCommand(t, sp)
	if got != sessionCommand() {
		t.Errorf("JSON command after refresh = %q, want %q", got, sessionCommand())
	}
	fb, _ := os.ReadFile(filepath.Join(cfg.RepoRoot, "CLAUDE.md"))
	if !strings.Contains(string(fb), sessionStartMarker) {
		t.Errorf("file channel not refreshed:\n%s", fb)
	}
}

// TestSessionStart_InstalledCommandIsInitGated guards the install side of
// the briefer-gating fix: the command wired into the settings file must
// carry --if-initialized so the bundled hook stays silent outside an eeco
// workspace, in every repo the user opens.
func TestSessionStart_InstalledCommandIsInitGated(t *testing.T) {
	dir := t.TempDir()
	sp := filepath.Join(dir, "settings.json")
	cfg := newCfg(t, sp)

	if _, err := EnableSessionStart(cfg); err != nil {
		t.Fatalf("EnableSessionStart: %v", err)
	}
	got := firstSessionCommand(t, sp)
	if !strings.Contains(got, "--if-initialized") {
		t.Errorf("installed session command = %q, want it to contain --if-initialized", got)
	}
}