ajhahn.de
← eeco
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)
	}
}