package hooks import ( "bytes" "os" "path/filepath" "strings" "testing" "time" "github.com/ajhahnde/eeco/internal/config" ) // newEmitCfg builds a config rooted at a fresh repo root (no .git // needed — Emit never shells out) with an .eeco workspace beside it. // Tests populate the repo with whichever fixture files they need. func newEmitCfg(t *testing.T) *config.Config { t.Helper() root := t.TempDir() 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, SessionStartMailbox: config.DefaultSessionStartMailbox, SessionStartRoadmapGlob: config.DefaultSessionStartRoadmapGlob, } } func writeFile(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } func TestEmit_EmptyProjectIsSilent(t *testing.T) { cfg := newEmitCfg(t) var buf bytes.Buffer Emit(cfg, &buf) if buf.Len() != 0 { t.Errorf("expected silent output, got %q", buf.String()) } } func TestEmit_AutoDetectsReadmeOnly(t *testing.T) { cfg := newEmitCfg(t) writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "# Hi\n") var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "[eeco session start]") { t.Errorf("missing reading-routine header: %q", out) } if !strings.Contains(out, "- README.md") { t.Errorf("README not surfaced: %q", out) } if strings.Contains(out, "PUBLIC_API.md") || strings.Contains(out, "ARCHITECTURE.md") { t.Errorf("unrelated docs surfaced: %q", out) } } func TestEmit_AutoDetectIncludesAllPresentDocsAndLiveRoadmap(t *testing.T) { cfg := newEmitCfg(t) writeFile(t, filepath.Join(cfg.RepoRoot, "docs/PUBLIC_API.md"), "x") writeFile(t, filepath.Join(cfg.RepoRoot, "CHANGELOG.md"), "x") older := filepath.Join(cfg.RepoRoot, "roadmap_to_v1.0.0.md") newer := filepath.Join(cfg.RepoRoot, "roadmap_v1.x.md") writeFile(t, older, "x") writeFile(t, newer, "x") // Force the newer file's mtime to be strictly later than the older // file's, independent of how fast the writes ran. past := time.Now().Add(-time.Hour) if err := os.Chtimes(older, past, past); err != nil { t.Fatal(err) } var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() for _, want := range []string{"docs/PUBLIC_API.md", "CHANGELOG.md", "roadmap_v1.x.md"} { if !strings.Contains(out, want) { t.Errorf("missing %q in output: %q", want, out) } } if strings.Contains(out, "roadmap_to_v1.0.0.md") { t.Errorf("older roadmap surfaced (should pick newest only): %q", out) } if !strings.Contains(out, "(live planning surface)") { t.Errorf("missing roadmap suffix: %q", out) } } func TestEmit_MailboxTemplateIsSkipped(t *testing.T) { cfg := newEmitCfg(t) template := "# Ideas\n\n\n" writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), template) var buf bytes.Buffer Emit(cfg, &buf) if strings.Contains(buf.String(), "[Ideas mailbox]") { t.Errorf("empty template should not trigger mailbox block: %q", buf.String()) } } func TestEmit_MailboxWithContentSurfaces(t *testing.T) { cfg := newEmitCfg(t) body := "# Ideas\n\n\nRefactor the loader.\n" writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), body) var buf bytes.Buffer Emit(cfg, &buf) if !strings.Contains(buf.String(), "[Ideas mailbox]") { t.Errorf("missing mailbox block: %q", buf.String()) } if !strings.Contains(buf.String(), "Ideas.md has unprocessed ideas") { t.Errorf("mailbox block does not name the file: %q", buf.String()) } } func TestEmit_ConfigDocsOverrideAutoDetect(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartDocs = []string{"docs/CUSTOM.md"} cfg.SessionStartRoadmapGlob = "" // disable roadmap discovery for a clean assertion writeFile(t, filepath.Join(cfg.RepoRoot, "docs/CUSTOM.md"), "x") // Auto-detect entries also exist; the override should still win. writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "x") writeFile(t, filepath.Join(cfg.RepoRoot, "CHANGELOG.md"), "x") var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "docs/CUSTOM.md") { t.Errorf("override doc not surfaced: %q", out) } if strings.Contains(out, "README.md") || strings.Contains(out, "CHANGELOG.md") { t.Errorf("auto-detect leaked when override was set: %q", out) } } func TestEmit_ConfigDocsFilterMissingFiles(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartDocs = []string{"docs/EXISTS.md", "docs/MISSING.md"} cfg.SessionStartRoadmapGlob = "" writeFile(t, filepath.Join(cfg.RepoRoot, "docs/EXISTS.md"), "x") var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "docs/EXISTS.md") { t.Errorf("existing override doc not surfaced: %q", out) } if strings.Contains(out, "docs/MISSING.md") { t.Errorf("missing override doc surfaced: %q", out) } } func TestEmit_CustomMailboxFilename(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartMailbox = "INBOX.md" body := "# Inbox\n\nAn idea.\n" writeFile(t, filepath.Join(cfg.RepoRoot, "INBOX.md"), body) // Ideas.md (the default) should be ignored when the override is set. writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), "# Ideas\n\nLeak me.\n") var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "INBOX.md has unprocessed ideas") { t.Errorf("custom mailbox name not surfaced: %q", out) } if strings.Contains(out, "Ideas.md has unprocessed ideas") { t.Errorf("default mailbox leaked when override was set: %q", out) } } func TestEmit_EmptyMailboxOverrideDisables(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartMailbox = "" writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), "# Ideas\n\nReal content.\n") var buf bytes.Buffer Emit(cfg, &buf) if strings.Contains(buf.String(), "[Ideas mailbox]") { t.Errorf("mailbox emitted with empty override: %q", buf.String()) } } func TestEmit_QueueAndRoutineSeparatedByBlankLine(t *testing.T) { cfg := newEmitCfg(t) writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "x") queuePath := filepath.Join(cfg.Workspace, "state", "queue.md") writeFile(t, queuePath, "- [ ] **decision** — a _(proj, 2026-05-21)_\n detail line\n"+ "- [ ] **decision** — b _(proj, 2026-05-21)_\n detail line\n") var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "[eeco session start]") { t.Errorf("missing routine block: %q", out) } if !strings.Contains(out, "2 items awaiting a decision") { t.Errorf("missing queue reminder: %q", out) } if !strings.Contains(out, "\n\neeco: 2 items") { t.Errorf("blocks not separated by blank line: %q", out) } } func TestEmit_SingleItemQueueUsesSingularNoun(t *testing.T) { cfg := newEmitCfg(t) queuePath := filepath.Join(cfg.Workspace, "state", "queue.md") writeFile(t, queuePath, "- [ ] **decision** — a _(proj, 2026-05-21)_\n detail\n") var buf bytes.Buffer Emit(cfg, &buf) if !strings.Contains(buf.String(), "1 item awaiting") { t.Errorf("singular noun not used: %q", buf.String()) } } func TestEmit_NilConfigIsSafe(t *testing.T) { var buf bytes.Buffer Emit(nil, &buf) if buf.Len() != 0 { t.Errorf("nil config should yield no output, got %q", buf.String()) } } // --- pinned memory bodies ------------------------------------------ // writePinnedFact installs a memory fact file in cfg.Workspace/memory // with the given name, description, pin flag, and body. The frontmatter // shape matches what eeco's memory parser writes; created/last_used are // fixed dates so the test is deterministic. func writePinnedFact(t *testing.T, cfg *config.Config, name, description, body string, pin bool) { t.Helper() dir := filepath.Join(cfg.Workspace, "memory") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } pinVal := "false" if pin { pinVal = "true" } content := "---\n" + "name: " + name + "\n" + "description: " + description + "\n" + "type: feedback\n" + "created: 2026-05-24\n" + "last_used: 2026-05-24\n" + "pin: " + pinVal + "\n" + "---\n" + body path := filepath.Join(dir, name+".md") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestEmit_PinnedBodiesDefaultOffStaysSilent(t *testing.T) { cfg := newEmitCfg(t) writePinnedFact(t, cfg, "policy-x", "an important policy", "body of policy X", true) var buf bytes.Buffer Emit(cfg, &buf) if strings.Contains(buf.String(), "pinned memories") { t.Errorf("default-off must omit the pinned-memories block, got: %q", buf.String()) } } func TestEmit_PinnedBodiesOnEmitsBlock(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartPinnedBodies = true writePinnedFact(t, cfg, "policy-x", "an important policy", "policy X body line 1\npolicy X body line 2", true) var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "[eeco pinned memories") { t.Errorf("missing pinned-memories block header: %q", out) } if !strings.Contains(out, "## policy-x") { t.Errorf("missing fact name heading: %q", out) } if !strings.Contains(out, "an important policy") { t.Errorf("missing fact description: %q", out) } if !strings.Contains(out, "policy X body line 1") { t.Errorf("missing fact body: %q", out) } } func TestEmit_PinnedBodiesOnNoPinnedFactsStaysSilent(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartPinnedBodies = true writePinnedFact(t, cfg, "unpinned", "ordinary fact", "body", false) var buf bytes.Buffer Emit(cfg, &buf) if strings.Contains(buf.String(), "pinned memories") { t.Errorf("with no pinned facts the block must be omitted, got: %q", buf.String()) } } func TestEmit_PinnedBodiesMultipleAreSeparatedByDivider(t *testing.T) { cfg := newEmitCfg(t) cfg.SessionStartPinnedBodies = true writePinnedFact(t, cfg, "policy-a", "first", "alpha body", true) writePinnedFact(t, cfg, "policy-b", "second", "beta body", true) var buf bytes.Buffer Emit(cfg, &buf) out := buf.String() if !strings.Contains(out, "## policy-a") || !strings.Contains(out, "## policy-b") { t.Errorf("missing one of the fact headings: %q", out) } if !strings.Contains(out, "\n---\n") { t.Errorf("multiple facts must be separated by a markdown divider: %q", out) } }