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