ajhahn.de
← eeco
Go 256 lines
package workflow

import (
	"os/exec"
	"strings"
	"testing"
	"time"

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

// ymd parses a YYYY-MM-DD date into UTC midnight, panicking on a bad
// literal (test inputs are constants).
func ymd(s string) time.Time {
	t, err := time.Parse("2006-01-02", s)
	if err != nil {
		panic(err)
	}
	return t
}

// seedFact writes one reference fact carrying ref and created into the
// workspace memory store.
func seedFact(t *testing.T, cfg *config.Config, name, ref string, created time.Time) {
	t.Helper()
	store, err := memory.Open(cfg)
	if err != nil {
		t.Fatal(err)
	}
	f := &memory.Fact{
		Name:        name,
		Description: "desc " + name,
		Type:        memory.TypeReference,
		Created:     created,
		LastUsed:    created,
		Ref:         ref,
		Body:        "body",
	}
	if err := store.Save(f); err != nil {
		t.Fatalf("seed fact %s: %v", name, err)
	}
}

// stubCommitDates overrides memoryDriftCommitDate for the test: a path
// present in m resolves to its date (ok=true); a path absent from m
// resolves to ok=false (no commit history).
func stubCommitDates(t *testing.T, m map[string]time.Time) {
	t.Helper()
	old := memoryDriftCommitDate
	memoryDriftCommitDate = func(_ string, path string) (time.Time, bool, error) {
		d, ok := m[path]
		return d, ok, nil
	}
	t.Cleanup(func() { memoryDriftCommitDate = old })
}

func TestMemoryDrift_NoFacts(t *testing.T) {
	cfg := newCfg(t)
	stubCommitDates(t, nil)
	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("Code = %d, want %d", res.Code, CodeClean)
	}
	if res.Summary != "no memory facts carry a ref to check" {
		t.Errorf("Summary = %q", res.Summary)
	}
}

func TestMemoryDrift_RefCurrent(t *testing.T) {
	cfg := newCfg(t)
	seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-20"))
	writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
	// Same calendar day as the fact's created date — not drift.
	stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if res.Summary != "1 memory fact(s) with a ref are current" {
		t.Errorf("Summary = %q", res.Summary)
	}
	if q := queueBody(t, cfg); q != "" {
		t.Errorf("queue should be empty, got:\n%s", q)
	}
}

func TestMemoryDrift_RefChangedAfter(t *testing.T) {
	cfg := newCfg(t)
	seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
	writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
	stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 1 {
		t.Fatalf("Findings = %d, want 1", len(res.Findings))
	}
	if res.Findings[0].Path != "internal/a.go" {
		t.Errorf("Finding.Path = %q", res.Findings[0].Path)
	}
	q := queueBody(t, cfg)
	if !strings.Contains(q, "**memory-drift**") || !strings.Contains(q, `"alpha"`) {
		t.Errorf("queue missing memory-drift item for alpha:\n%s", q)
	}
}

func TestMemoryDrift_RepeatedRunDedupsQueue(t *testing.T) {
	cfg := newCfg(t)
	seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
	writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
	stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})

	for i := range 2 {
		res, err := memoryDrift{}.Run(Env{Config: cfg})
		if err != nil {
			t.Fatal(err)
		}
		// The finding is real on every run — only the queue write dedups.
		if res.Code != CodeFinding {
			t.Fatalf("run %d: Code = %d, want %d (%q)", i, res.Code, CodeFinding, res.Summary)
		}
	}
	if got := queueBody(t, cfg); strings.Count(got, "**memory-drift**") != 1 {
		t.Errorf("repeated runs should queue exactly one open item:\n%s", got)
	}
}

func TestMemoryDrift_RefMissingOnDisk(t *testing.T) {
	cfg := newCfg(t)
	// The ref file is never written — a missing ref is eeco gc's job.
	seedFact(t, cfg, "alpha", "internal/gone.go", ymd("2026-05-10"))
	stubCommitDates(t, map[string]time.Time{"internal/gone.go": ymd("2026-05-20")})

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if res.Summary != "no memory facts carry a ref to check" {
		t.Errorf("Summary = %q", res.Summary)
	}
}

func TestMemoryDrift_RefUntracked(t *testing.T) {
	cfg := newCfg(t)
	seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
	writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
	// No entry in the map → ok=false → no commit history to compare.
	stubCommitDates(t, map[string]time.Time{})

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if res.Summary != "no memory facts carry a ref to check" {
		t.Errorf("Summary = %q", res.Summary)
	}
}

func TestMemoryDrift_FactWithoutRef(t *testing.T) {
	cfg := newCfg(t)
	seedFact(t, cfg, "alpha", "", ymd("2026-05-10"))
	stubCommitDates(t, nil)

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean || res.Summary != "no memory facts carry a ref to check" {
		t.Errorf("Code = %d, Summary = %q", res.Code, res.Summary)
	}
}

func TestMemoryDrift_MixedFacts(t *testing.T) {
	cfg := newCfg(t)
	seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10")) // stale
	seedFact(t, cfg, "beta", "internal/b.go", ymd("2026-05-20"))  // current
	seedFact(t, cfg, "gamma", "", ymd("2026-05-01"))              // no ref
	writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
	writeRepoFile(t, cfg.RepoRoot, "internal/b.go", "package b\n")
	stubCommitDates(t, map[string]time.Time{
		"internal/a.go": ymd("2026-05-20"),
		"internal/b.go": ymd("2026-05-20"),
	})

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 1 {
		t.Fatalf("Findings = %d, want 1: %+v", len(res.Findings), res.Findings)
	}
	if res.Findings[0].Path != "internal/a.go" {
		t.Errorf("stale fact = %q, want internal/a.go", res.Findings[0].Path)
	}
	if got := queueBody(t, cfg); strings.Count(got, "**memory-drift**") != 1 {
		t.Errorf("want exactly one queued item:\n%s", got)
	}
}

// TestMemoryDrift_RealGit exercises the real gitx.LastCommitDate wiring
// (no stub) against an actual commit, so the integration is covered end
// to end alongside the table-driven stub cases above.
func TestMemoryDrift_RealGit(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
	gitInit(t, cfg.RepoRoot)
	runGit(t, cfg.RepoRoot, "commit", "-q", "-m", "add a.go")
	// The commit lands now; the fact claims to predate it by years.
	seedFact(t, cfg, "alpha", "internal/a.go", ymd("2020-01-01"))

	res, err := memoryDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 1 || res.Findings[0].Path != "internal/a.go" {
		t.Errorf("Findings = %+v", res.Findings)
	}
}

func runGit(t *testing.T, root string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = root
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %v: %v\n%s", args, err, out)
	}
}