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) } }