package index
import (
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
)
// The event stream is a bounded live tail: 1 GiB, one hour,
// discard-old. A fold that keeps failing lets its own messages age out
// unacked, and the publisher's cursor then advances past them on a
// later healthy round — so the artifacts are never republished and
// NOTHING errors at the moment of loss. The index simply ends up short
// and serves the hole as if it were the truth: a missing check-in makes
// the timeline draw two branches where there is one, and marks a
// mid-history commit as a leaf.
//
// Gaps is the proof that this has happened, and it must be exact: a
// false positive would trigger a needless full rebuild of a large
// store, and a false negative is the silence this exists to end.
func TestGapsDetectsAMissingCheckin(t *testing.T) {
ix := openIndex(t)
a, b, c := strings.Repeat("a1", 32), strings.Repeat("b2", 32), strings.Repeat("c3", 32)
ci := func(hash string, parents ...string) Parsed {
return Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Manifest, Date: "2026-08-18T10:00:00", User: "dan",
Comment: "c", Parents: parents}}
}
if err := ix.FoldNow([]Parsed{ci(a), ci(b, a), ci(c, b)}); err != nil {
t.Fatal(err)
}
if d, err := ix.DanglingParents(50); err != nil || len(d) != 0 {
t.Fatalf("a complete chain reports %d dangling parents (err %v)", len(d), err)
}
// Delete the middle check-in the way a lost artifact leaves it:
// the row is gone, the edge that referenced it is not.
if _, err := ix.db.Exec(`DELETE FROM checkin WHERE hash = ?`, b); err != nil {
t.Fatal(err)
}
d, err := ix.DanglingParents(50)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 || d[0] != b {
t.Errorf("dangling parents = %v, want exactly the deleted check-in", d)
}
}
// A root check-in has no parent and must not be mistaken for a gap, or
// every healthy repository would rebuild itself forever.
func TestGapsIgnoresTheRoot(t *testing.T) {
ix := openIndex(t)
root := strings.Repeat("d4", 32)
if err := ix.FoldNow([]Parsed{{Hash: root, Art: &artifact.Artifact{
Type: artifact.Manifest, Date: "2026-08-18T10:00:00", User: "dan", Comment: "root"}}}); err != nil {
t.Fatal(err)
}
if d, err := ix.DanglingParents(50); err != nil || len(d) != 0 {
t.Errorf("a root-only index reports %v as dangling (err %v)", d, err)
}
}
// A dangling parent the STORE never held is not damage and must not be
// reported as one: fossil-born histories reference shunned ancestors,
// and treating those as loss would rebuild five of this corpus's
// repositories on every poll, forever. The index cannot make that
// distinction — it has no store access — so it must hand back the
// hashes and let the caller ask. This pins that contract.
func TestDanglingParentsReportsHashesNotAVerdict(t *testing.T) {
ix := openIndex(t)
a, b := strings.Repeat("e5", 32), strings.Repeat("f6", 32)
if err := ix.FoldNow([]Parsed{{Hash: b, Art: &artifact.Artifact{
Type: artifact.Manifest, Date: "2026-08-18T10:00:00", User: "dan",
Comment: "child of an ancestor this store never held", Parents: []string{a}}}}); err != nil {
t.Fatal(err)
}
d, err := ix.DanglingParents(50)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 || d[0] != a {
t.Fatalf("dangling = %v, want the unheld ancestor %s", d, a[:12])
}
}
// Both of quarry's neighbours refuse to answer from a derivation whose
// shape they do not recognise — zit treats a format-version mismatch as
// a cache miss and rebuilds; fossil stops and demands "fossil rebuild".
// quarry follows the same rule with the remedy its disposability
// allows. Without this an index keeps serving rows folded under rules
// that no longer apply: a column added later reads empty for every old
// row, and nothing says so.
func TestOpenReportsAForeignSchemaAsStale(t *testing.T) {
dir := t.TempDir()
path := dir + "/index.db"
fresh, err := Open(path)
if err != nil {
t.Fatal(err)
}
// A new file has no recorded shape, so it is stale by the same rule
// — one rebuild of an empty index, and no exception to carve.
if !fresh.Stale() {
t.Error("a brand-new index does not report itself as needing derivation")
}
fresh.Close()
// Re-opening the SAME file must now be clean: Open stamped it.
again, err := Open(path)
if err != nil {
t.Fatal(err)
}
if again.Stale() {
t.Error("an index this code just wrote reports as a foreign schema")
}
// Simulate a file written by different code.
if _, err := again.RawDB().Exec(`PRAGMA user_version = 99`); err != nil {
t.Fatal(err)
}
again.Close()
old, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer old.Close()
if !old.Stale() {
t.Error("an index written under a different schema is served as if current")
}
}