package memory import ( "fmt" "os" "path/filepath" "strings" "testing" "time" ) // gcStore returns a store seeded with the given facts and a fixed // clock at 2026-05-19. func gcStore(t *testing.T, facts ...*Fact) *Store { t.Helper() s := newStore(t) for _, f := range facts { if err := s.Save(f); err != nil { t.Fatalf("seed %s: %v", f.Name, err) } } return s } func withExpires(d time.Time) func(*Fact) { return func(f *Fact) { f.Expires = &d } } func withRef(r string) func(*Fact) { return func(f *Fact) { f.Ref = r } } func withStatus(s string) func(*Fact) { return func(f *Fact) { f.Status = s } } func withPin(p bool) func(*Fact) { return func(f *Fact) { f.Pin = p } } func withDisabled(d bool) func(*Fact) { return func(f *Fact) { f.Disabled = d } } func withLastUsed(d time.Time) func(*Fact) { return func(f *Fact) { f.LastUsed = d } } func assertAction(t *testing.T, res GCResult, name, action string) { t.Helper() for _, a := range res.Actions { if a.Name == name { if a.Action != action { t.Errorf("%s action = %s, want %s (reason=%q)", name, a.Action, action, a.Reason) } return } } t.Errorf("no action recorded for %s", name) } // --- pin skip --- func TestGC_DisabledKeptDespiteTriggers(t *testing.T) { // A disabled fact with every trigger pulled must still be kept: the // operator deliberately turned it off and may turn it back on. past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("disabled-feedback", "x", TypeFeedback, withRef("does-not-exist.go"), withExpires(past), withDisabled(true)), makeFact("disabled-user", "x", TypeUser, withRef("does-not-exist.go"), withExpires(past), withDisabled(true)), ) res, err := s.GC() if err != nil { t.Fatal(err) } if res.Archived != 0 || res.Queued != 0 { t.Errorf("disabled should suppress all actions: %+v", res) } if res.Kept != 2 { t.Errorf("Kept = %d, want 2", res.Kept) } assertAction(t, res, "disabled-feedback", "kept") assertAction(t, res, "disabled-user", "kept") } func TestGC_PinAlwaysKept(t *testing.T) { // A fact with every trigger pulled, but pinned, must be kept. past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("pinned-ref", "x", TypeReference, withRef("does-not-exist.go"), withExpires(past), withPin(true)), makeFact("pinned-user", "x", TypeUser, withRef("does-not-exist.go"), withExpires(past), withPin(true)), ) res, err := s.GC() if err != nil { t.Fatal(err) } if res.Archived != 0 || res.Queued != 0 { t.Errorf("pin should suppress all actions: %+v", res) } if res.Kept != 2 { t.Errorf("Kept = %d, want 2", res.Kept) } } // --- ref missing × type bucket --- func TestGC_RefMissing_TypeBuckets(t *testing.T) { missing := "internal/does-not-exist.go" s := gcStore(t, makeFact("ref-ref", "x", TypeReference, withRef(missing)), makeFact("ref-finding", "x", TypeFinding, withRef(missing), withStatus("open")), makeFact("ref-project", "x", TypeProject, withRef(missing)), makeFact("ref-feedback", "x", TypeFeedback, withRef(missing)), makeFact("ref-user", "x", TypeUser, withRef(missing)), ) res, err := s.GC() if err != nil { t.Fatal(err) } assertAction(t, res, "ref-ref", "archived") assertAction(t, res, "ref-finding", "archived") assertAction(t, res, "ref-project", "queued") assertAction(t, res, "ref-feedback", "queued") assertAction(t, res, "ref-user", "queued") if res.Archived != 2 || res.Queued != 3 || res.Kept != 0 { t.Errorf("counts: archived=%d queued=%d kept=%d", res.Archived, res.Queued, res.Kept) } } func TestGC_RefPresent_NoTrigger(t *testing.T) { s := newStore(t) // Create the file the ref points to so the trigger does NOT fire. if err := os.WriteFile(filepath.Join(s.RepoRoot, "real.go"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := s.Save(makeFact("ref-ok", "x", TypeReference, withRef("real.go"))); err != nil { t.Fatal(err) } res, err := s.GC() if err != nil { t.Fatal(err) } if res.Kept != 1 || res.Archived != 0 { t.Errorf("expected ref present to keep: %+v", res) } } // --- expires past × type bucket --- func TestGC_ExpiresPast_TypeBuckets(t *testing.T) { past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("exp-ref", "x", TypeReference, withExpires(past)), makeFact("exp-finding", "x", TypeFinding, withExpires(past), withStatus("open")), makeFact("exp-project", "x", TypeProject, withExpires(past)), makeFact("exp-feedback", "x", TypeFeedback, withExpires(past)), makeFact("exp-user", "x", TypeUser, withExpires(past)), ) res, err := s.GC() if err != nil { t.Fatal(err) } assertAction(t, res, "exp-ref", "archived") assertAction(t, res, "exp-finding", "archived") assertAction(t, res, "exp-project", "queued") assertAction(t, res, "exp-feedback", "queued") assertAction(t, res, "exp-user", "queued") } func TestGC_ExpiresFuture_NoTrigger(t *testing.T) { future := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("exp-future", "x", TypeUser, withExpires(future))) res, err := s.GC() if err != nil { t.Fatal(err) } if res.Kept != 1 { t.Errorf("future expiry should keep: %+v", res) } } // --- finding+resolved --- func TestGC_FindingResolvedArchived(t *testing.T) { s := gcStore(t, makeFact("done", "x", TypeFinding, withStatus("resolved")), makeFact("open", "x", TypeFinding, withStatus("open")), ) res, err := s.GC() if err != nil { t.Fatal(err) } assertAction(t, res, "done", "archived") assertAction(t, res, "open", "kept") } // --- reference + last_used > N days --- func TestGC_ReferenceStaleByLastUsed(t *testing.T) { old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // ~139 days before fixed clock recent := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("stale-ref", "x", TypeReference, withLastUsed(old)), makeFact("fresh-ref", "x", TypeReference, withLastUsed(recent)), ) res, err := s.GC() if err != nil { t.Fatal(err) } assertAction(t, res, "stale-ref", "archived") assertAction(t, res, "fresh-ref", "kept") } func TestGC_ReferenceStale_HonoursStaleDays(t *testing.T) { s := newStore(t) s.StaleDays = 1 // very aggressive twoDaysAgo := time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC) if err := s.Save(makeFact("borderline", "x", TypeReference, withLastUsed(twoDaysAgo))); err != nil { t.Fatal(err) } res, err := s.GC() if err != nil { t.Fatal(err) } assertAction(t, res, "borderline", "archived") } func TestGC_StaleDoesNotApplyToOtherTypes(t *testing.T) { old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("old-user", "x", TypeUser, withLastUsed(old)), makeFact("old-feedback", "x", TypeFeedback, withLastUsed(old)), makeFact("old-project", "x", TypeProject, withLastUsed(old)), makeFact("old-finding", "x", TypeFinding, withLastUsed(old), withStatus("open")), ) res, err := s.GC() if err != nil { t.Fatal(err) } if res.Kept != 4 { t.Errorf("non-reference types should not stale: %+v", res) } } // --- "none" → keep --- func TestGC_NoTriggerKept(t *testing.T) { s := gcStore(t, makeFact("clean", "x", TypeUser), makeFact("clean2", "x", TypeProject), makeFact("clean3", "x", TypeFeedback), ) res, err := s.GC() if err != nil { t.Fatal(err) } if res.Kept != 3 || res.Archived != 0 || res.Queued != 0 { t.Errorf("clean facts should be kept: %+v", res) } } // --- side effects --- func TestGC_ArchiveMovesFileToAttic(t *testing.T) { s := gcStore(t, makeFact("doomed", "x", TypeFinding, withStatus("resolved"))) srcPath := filepath.Join(s.MemoryDir, "doomed.md") if _, err := os.Stat(srcPath); err != nil { t.Fatalf("source not present pre-GC: %v", err) } if _, err := s.GC(); err != nil { t.Fatal(err) } if _, err := os.Stat(srcPath); !os.IsNotExist(err) { t.Errorf("source not removed: %v", err) } if _, err := os.Stat(filepath.Join(s.AtticDir, "doomed.md")); err != nil { t.Errorf("attic copy missing: %v", err) } } func TestGC_LogAppended(t *testing.T) { s := gcStore(t, makeFact("a", "x", TypeFinding, withStatus("resolved")), makeFact("b", "x", TypeUser, withRef("missing.go")), ) if _, err := s.GC(); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(s.StateDir, "gc.log")) if err != nil { t.Fatal(err) } if !strings.Contains(string(b), "archived a") || !strings.Contains(string(b), "queued b") { t.Errorf("log missing entries:\n%s", string(b)) } } func TestGC_QueueEntryWritten(t *testing.T) { s := gcStore(t, makeFact("user-stale", "user notes", TypeUser, withRef("missing.go"))) if _, err := s.GC(); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(s.StateDir, "queue.md")) if err != nil { t.Fatal(err) } got := string(b) if !strings.Contains(got, "- [ ] **gc-review**") { t.Errorf("queue entry missing kind:\n%s", got) } if !strings.Contains(got, "user-stale") { t.Errorf("queue entry missing fact name:\n%s", got) } } func TestGC_QueueDedupOnRerun(t *testing.T) { // A second GC pass over the same unresolved finding must not pile up // a duplicate gc-review row. queueReview routes through // queue.AppendUnique (kind+title key), so two consecutive runs file // one open row, not two. This unblocks running gc from the // post-merge hook chain without spamming the queue. s := gcStore(t, makeFact("user-stale", "user notes", TypeUser, withRef("missing.go"))) if _, err := s.GC(); err != nil { t.Fatalf("first GC: %v", err) } if _, err := s.GC(); err != nil { t.Fatalf("second GC: %v", err) } b, err := os.ReadFile(filepath.Join(s.StateDir, "queue.md")) if err != nil { t.Fatal(err) } got := string(b) n := strings.Count(got, "- [ ] **gc-review**") if n != 1 { t.Errorf("open gc-review rows = %d, want 1\n%s", n, got) } } func TestGC_RegeneratesIndex(t *testing.T) { s := gcStore(t, makeFact("survivor", "stays put", TypeUser), makeFact("doomed", "to attic", TypeFinding, withStatus("resolved")), ) if _, err := s.GC(); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(s.MemoryDir, IndexFilename)) if err != nil { t.Fatal(err) } got := string(b) if !strings.Contains(got, "**survivor**") { t.Errorf("index missing survivor:\n%s", got) } if strings.Contains(got, "**doomed**") { t.Errorf("index should not list archived fact:\n%s", got) } } func TestGC_EmptyStore(t *testing.T) { s := newStore(t) res, err := s.GC() if err != nil { t.Fatal(err) } if res.Archived != 0 || res.Queued != 0 || res.Kept != 0 { t.Errorf("empty store should be no-op: %+v", res) } if _, err := os.Stat(filepath.Join(s.MemoryDir, IndexFilename)); err != nil { t.Errorf("empty index not written: %v", err) } } func TestGC_RefPrecedesExpires(t *testing.T) { // Both ref-missing and expires-past trigger on the same fact; the // reported reason should be the first row (ref-missing) per spec // order. past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) s := gcStore(t, makeFact("dual", "x", TypeFinding, withRef("missing.go"), withExpires(past), withStatus("open"))) res, err := s.GC() if err != nil { t.Fatal(err) } for _, a := range res.Actions { if a.Name == "dual" { if !strings.HasPrefix(a.Reason, "ref missing") { t.Errorf("expected ref-missing to win, got reason %q", a.Reason) } return } } t.Error("no action for dual") } // --- I/O-fault error paths (target a: malformed/I/O → clean error) --- func TestGC_LoadAllFail_Malformed(t *testing.T) { // A malformed fact file makes the entry LoadAll fail; GC must surface a // clean wrapped error, never panic (pins malformed→error at the GC entry). s := newStore(t) if err := os.WriteFile(filepath.Join(s.MemoryDir, "broken.md"), []byte("not a fact"), 0o644); err != nil { t.Fatal(err) } _, err := s.GC() if err == nil { t.Fatal("expected malformed fact to fail GC") } if !strings.Contains(err.Error(), "gc:") { t.Errorf("err = %v, want wrap gc:", err) } } func TestGC_ArchiveFail_AtticIsFile(t *testing.T) { s := gcStore(t, makeFact("done", "x", TypeFinding, withStatus("resolved"))) // Attic path is a regular file → archive's MkdirAll(AtticDir) returns // ENOTDIR, surfaced as the gc archive wrap. if err := os.WriteFile(s.AtticDir, []byte("x"), 0o644); err != nil { t.Fatal(err) } _, err := s.GC() if err == nil { t.Fatal("expected archive to fail when attic is a file") } if !strings.Contains(err.Error(), "gc archive done") { t.Errorf("err = %v, want wrap gc archive done", err) } } func TestGC_QueueFail_StateDirIsFile(t *testing.T) { s := gcStore(t, makeFact("u", "x", TypeUser, withRef("missing.go"))) // StateDir is a regular file → queue.AppendUnique's MkdirAll(stateDir) // returns ENOTDIR, surfaced as the gc queue wrap. if err := os.RemoveAll(s.StateDir); err != nil { t.Fatal(err) } if err := os.WriteFile(s.StateDir, []byte("x"), 0o644); err != nil { t.Fatal(err) } _, err := s.GC() if err == nil { t.Fatal("expected queueReview to fail when state dir is a file") } if !strings.Contains(err.Error(), "gc queue u") { t.Errorf("err = %v, want wrap gc queue u", err) } } func TestGC_WriteIndexFail_IndexIsDir(t *testing.T) { s := gcStore(t, makeFact("kept-user", "x", TypeUser)) // MEMORY.md is a directory. LoadAll skips it (e.IsDir() runs before the // IndexFilename check), so both load passes succeed; WriteIndex's // os.WriteFile then fails with EISDIR. if err := os.Mkdir(filepath.Join(s.MemoryDir, IndexFilename), 0o755); err != nil { t.Fatal(err) } _, err := s.GC() if err == nil { t.Fatal("expected WriteIndex to fail when MEMORY.md is a dir") } if !strings.Contains(err.Error(), "gc: write index") { t.Errorf("err = %v, want wrap gc: write index", err) } } func TestGC_ArchiveCollisionSuffix(t *testing.T) { s := gcStore(t, makeFact("dup", "x", TypeFinding, withStatus("resolved"))) if err := os.MkdirAll(s.AtticDir, 0o755); err != nil { t.Fatal(err) } // A pre-existing attic file with the same name forces the suffix branch; // the clock is fixed, so now.Unix() is deterministic. existing := filepath.Join(s.AtticDir, "dup.md") if err := os.WriteFile(existing, []byte("prior archive"), 0o644); err != nil { t.Fatal(err) } res, err := s.GC() if err != nil { t.Fatal(err) } if res.Archived != 1 { t.Errorf("Archived = %d, want 1", res.Archived) } suffixed := filepath.Join(s.AtticDir, fmt.Sprintf("dup.%d.md", s.Now().UTC().Unix())) if _, err := os.Stat(suffixed); err != nil { t.Errorf("suffixed archive missing: %v", err) } if _, err := os.Stat(existing); err != nil { t.Errorf("pre-existing attic file clobbered: %v", err) } } func TestLogGC_OpenFileFail_LogIsDir(t *testing.T) { s := newStore(t) if err := os.MkdirAll(s.StateDir, 0o755); err != nil { t.Fatal(err) } // gc.log is a directory → OpenFile(O_APPEND|O_CREATE|O_WRONLY) fails with // EISDIR. No O_EXCL, so this is not the ErrExist path. logGC returns the // raw os error with no wrap of its own, so assert err != nil only. if err := os.Mkdir(filepath.Join(s.StateDir, "gc.log"), 0o755); err != nil { t.Fatal(err) } if err := s.logGC(s.Now(), "archived", "x", "r"); err == nil { t.Fatal("expected logGC to fail when gc.log is a dir") } } // --- tombstone idempotency (target b) --- func TestGC_TombstoneRestoreIdempotent(t *testing.T) { s := gcStore(t, makeFact("ref1", "x", TypeReference, withRef("gone.go"))) atticPath := filepath.Join(s.AtticDir, "ref1.md") memPath := filepath.Join(s.MemoryDir, "ref1.md") res1, err := s.GC() if err != nil { t.Fatalf("first GC: %v", err) } if res1.Archived != 1 { t.Fatalf("first pass Archived = %d, want 1", res1.Archived) } if _, err := os.Stat(atticPath); err != nil { t.Errorf("attic copy missing after first pass: %v", err) } if _, err := os.Stat(memPath); !os.IsNotExist(err) { t.Errorf("source not removed after first pass: %v", err) } // Restore the fact to the live dir and re-run: GC must re-archive it // cleanly and idempotently, with no panic. data, err := os.ReadFile(atticPath) if err != nil { t.Fatal(err) } if err := os.WriteFile(memPath, data, 0o644); err != nil { t.Fatal(err) } if err := os.Remove(atticPath); err != nil { t.Fatal(err) } res2, err := s.GC() if err != nil { t.Fatalf("second GC: %v", err) } if res2.Archived != 1 { t.Errorf("second pass Archived = %d, want 1", res2.Archived) } if _, err := os.Stat(atticPath); err != nil { t.Errorf("attic copy missing after restore + re-GC: %v", err) } } func TestGC_RepeatedStability(t *testing.T) { s := gcStore(t, makeFact("clean-a", "x", TypeUser), makeFact("clean-b", "x", TypeProject), ) var firstKept int for i := range 3 { res, err := s.GC() if err != nil { t.Fatalf("GC pass %d: %v", i, err) } if i == 0 { firstKept = res.Kept } else if res.Kept != firstKept { t.Errorf("pass %d Kept = %d, want stable %d", i, res.Kept, firstKept) } } if firstKept != 2 { t.Errorf("Kept = %d, want 2", firstKept) } }