Go 562 lines
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)
}
}