Go 316 lines
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<!--\nDrop loose ideas below this comment block.\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<!--\ntemplate hint\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)
}
}