Skip to content
zit Artifact [68823fd7f9]
Login

Artifact [68823fd7f9]

Artifact 68823fd7f991feaa99fc28b30a384c1fd5ab4df34b8c046e766074bfa14cd199:


//! Public-surface tests for the `Repository` facade.
//!
//! Every fixture builds a real on-disk `.zit` (a writable `LogStore` seeded with
//! artifacts assembled by `artifact.create`) plus, where a checkout matters, a
//! `.zit-checkout` record — then opens it through `Repository.open` exactly as
//! the CLI does. Expected artifact names are the artifact §7.2 hash-over-
//! uncompressed of the fixture's own bytes; expected derivations come from the
//! repo-state RFC (§5-9), never from Fossil.

const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const logstore = @import("logstore.zig");
const repocache = @import("repocache.zig");
const repoview = @import("repoview.zig");
const storage = @import("storage.zig");
const checkout = @import("checkout.zig");
const Repository = @import("repository.zig").Repository;
const submanifest = @import("repository.zig").submanifest;
const repository = @import("repository.zig");
const testing = std.testing;

const hex_digits = "0123456789abcdef";

/// A tmp-backed repository builder: seeds a writable `.zit` log, then closes it
/// so `Repository.open` reads a settled on-disk repository. The tmp dir persists
/// until `cleanup`, so `Repository` can reopen `.zit` for lazy content.
const Repo = struct {
    tmp: std.testing.TmpDir,
    fs: storage.FileStorage,
    log: logstore.LogStore,
    file: std.Io.File,
    io: std.Io,

    fn init(self: *Repo, io: std.Io) !void {
        self.io = io;
        self.tmp = testing.tmpDir(.{});
        self.file = try self.tmp.dir.createFile(io, ".zit", .{ .read = true });
        self.fs = storage.FileStorage.init(io, self.file, 0);
        self.log = try logstore.LogStore.open(testing.allocator, self.fs.storageFile(), 0);
    }

    /// Commit the seeded frames and release the writable handle; the `.zit` file
    /// stays on disk under `tmp` for `Repository.open` to read.
    fn seal(self: *Repo) !void {
        try self.log.commit();
        self.log.deinit(testing.allocator);
        self.file.close(self.io);
    }

    fn cleanup(self: *Repo) void {
        self.tmp.cleanup();
    }

    fn config(self: *Repo, default_branch: []const u8) !void {
        try self.log.putConfig(.{ .hash_policy = 0, .default_branch = default_branch, .project_id = "p" });
    }

    /// Serialize `m`, store it under its own sha3-256 digest, return its 64-hex
    /// artifact name (caller frees).
    fn put(self: *Repo, m: *const artifact.Manifest) ![]u8 {
        const bytes = try artifact.create(m, testing.allocator);
        defer testing.allocator.free(bytes);
        return self.putRaw(bytes);
    }

    /// Store arbitrary `bytes` under their sha3-256 digest, return the 64-hex
    /// name (caller frees). Used for both artifacts and plain file blobs.
    fn putRaw(self: *Repo, bytes: []const u8) ![]u8 {
        var digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
        _ = try self.log.put(&digest, bytes);
        return hexName(&digest);
    }
};

/// Whether a `.zit-cache` sidecar exists beside the repo's `.zit` — the on-disk
/// signal that the derivation cache was materialized (its name is a repocache
/// internal, so it is spelled literally here rather than imported).
fn cacheExists(repo: *Repo, io: std.Io) bool {
    var f = repo.tmp.dir.openFile(io, ".zit-cache", .{}) catch return false;
    f.close(io);
    return true;
}

fn hexName(raw: []const u8) ![]u8 {
    const out = try testing.allocator.alloc(u8, raw.len * 2);
    for (raw, 0..) |b, i| {
        out[i * 2] = hex_digits[b >> 4];
        out[i * 2 + 1] = hex_digits[b & 0x0f];
    }
    return out;
}

/// A skeleton manifest: a root carries the propagating `branch` tag (plus any
/// `extra_tags`); a child names one `parent`. Files are irrelevant to the
/// derivations under test, so there are none.
const Spec = struct {
    ts: []const u8,
    parent: ?[]const u8 = null,
    branch_value: []const u8 = "trunk",
    extra_tags: []const artifact.TCard = &.{},
};

fn manifestOf(spec: Spec, tag_buf: []artifact.TCard, parent_buf: []artifact.PCard) artifact.Manifest {
    var n: usize = 0;
    if (spec.parent == null) {
        tag_buf[n] = .{ .sign = .propagate, .name = "branch", .target = null, .value = spec.branch_value };
        n += 1;
    }
    for (spec.extra_tags) |t| {
        tag_buf[n] = t;
        n += 1;
    }
    var p_slice: []const artifact.PCard = &.{};
    if (spec.parent) |parent| {
        parent_buf[0] = .{ .parents = parent, .count = 1 };
        p_slice = parent_buf[0..1];
    }
    return baseManifest(.manifest, spec.ts, p_slice, tag_buf[0..n]);
}

fn baseManifest(kind: artifact.ArtifactType, ts: []const u8, p: []const artifact.PCard, t: []const artifact.TCard) artifact.Manifest {
    return .{
        .arena_state = std.heap.ArenaAllocator.init(testing.allocator),
        .kind = kind,
        .a = null,
        .b = null,
        .c = if (kind == .manifest) .{ .comment = "c" } else null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = p,
        .q = &.{},
        .t = t,
    };
}

fn putCheckin(repo: *Repo, spec: Spec) ![]u8 {
    var tag_buf: [8]artifact.TCard = undefined;
    var parent_buf: [1]artifact.PCard = undefined;
    var m = manifestOf(spec, &tag_buf, &parent_buf);
    defer m.deinit();
    return repo.put(&m);
}

fn putControl(repo: *Repo, ts: []const u8, tags: []const artifact.TCard) ![]u8 {
    var m = baseManifest(.control, ts, &.{}, tags);
    defer m.deinit();
    return repo.put(&m);
}

/// One F card (§4.7.6): a present `hash` is a file at that blob id, a null hash
/// is a delete marker. Perm/old-name are irrelevant to the file-set walk.
fn fcard(name: []const u8, hash: ?[]const u8) artifact.FCard {
    return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}

/// A file-bearing check-in: a baseline manifest carrying `files` (root unless a
/// `parent` is given), or a delta manifest when `baseline` names a B-card hash.
/// `files` MUST already be sorted by decoded filename (§8.1); the writer emits
/// them verbatim and the parser re-checks the order.
const FSpec = struct {
    ts: []const u8,
    parent: ?[]const u8 = null,
    baseline: ?[]const u8 = null,
    files: []const artifact.FCard = &.{},
};

fn putFiles(repo: *Repo, spec: FSpec) ![]u8 {
    var tag_buf: [1]artifact.TCard = undefined;
    var n: usize = 0;
    if (spec.parent == null) {
        tag_buf[n] = .{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" };
        n += 1;
    }
    var parent_buf: [1]artifact.PCard = undefined;
    var p_slice: []const artifact.PCard = &.{};
    if (spec.parent) |parent| {
        parent_buf[0] = .{ .parents = parent, .count = 1 };
        p_slice = parent_buf[0..1];
    }
    var m = baseManifest(.manifest, spec.ts, p_slice, tag_buf[0..n]);
    defer m.deinit();
    m.f = spec.files;
    if (spec.baseline) |bh| m.b = .{ .hash = bh };
    return repo.put(&m);
}

/// Drain a `FileList` into `out` (caller-owned) so a test can assert the whole
/// effective set at once — names, order, and blob ids.
fn collectFiles(list: *const Repository.FileList, out: *std.ArrayList(artifact.FCard)) !void {
    var it = list.iterator();
    while (it.next()) |f| try out.append(testing.allocator, f);
}

/// Open `repo` through the facade exactly as the CLI does.
fn openRepo(out: *Repository, repo: *Repo, io: std.Io) !void {
    try Repository.open(out, testing.allocator, io, repo.tmp.dir, .{});
}

test "repository §8.3: resolve a symbolic name to its check-in rid" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A root, then a child tagged with the symbolic name `sym-feature` (§8.3
    // resolves a plain name through its active sym- tag, §5.1).
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const sym = [_]artifact.TCard{.{ .sign = .propagate, .name = "sym-feature", .target = null, .value = null }};
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .extra_tags = &sym });
    defer testing.allocator.free(child);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    // §8.3: "feature" resolves to the check-in carrying its active sym- tag.
    const rid = try facade.resolve("feature", .checkin);
    try testing.expectEqualStrings(child, facade.hashOf(rid));
    // The full artifact id resolves to the same rid (§8.3 hash-prefix form).
    try testing.expectEqual(rid, try facade.resolve(child, .checkin));
}

test "repository §8.2: resolve is recency-based — names a closed non-leaf that leaves omits" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A root that both bears a sym- name and is later closed AND superseded by a
    // child: it is neither a leaf nor open, yet §8.2 resolution (max-mtime among
    // sym-tagged) still names it — resolution never filters on leaf/closed. The
    // sym tag is a singleton (§5.1 `+`), so it names this check-in alone — a
    // propagating tag would flow to the child and resolve there instead.
    const named = [_]artifact.TCard{.{ .sign = .add, .name = "sym-rel", .target = null, .value = null }};
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &named });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
    const ctl = try putControl(&repo, "2020-01-03T00:00:00", &close);
    defer testing.allocator.free(ctl);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    const rid = try facade.resolve("rel", .checkin);
    try testing.expectEqualStrings(root, facade.hashOf(rid));
    // §8.2: the resolved check-in is a non-leaf (has a same-branch child) —
    // exactly what leaf enumeration omits, proving resolve ≠ leaf listing.
    try testing.expect(!facade.state().isLeaf(rid));
}

test "repository §8.3: an unresolvable name is NotFound; the closed ResolveError forwards RepoState.resolve" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    // §8.3: a name matching no id, tag, or keyword is NotFound.
    try testing.expectError(error.NotFound, facade.resolve("zzzz-nope", .checkin));
    // A hex-shaped prefix (≥4 digits) that names no stored id is also NotFound.
    try testing.expectError(error.NotFound, facade.resolve("deadbeef", .checkin));

    // The other arm, error.Ambiguous, is raised only by the §8.3 hash-prefix
    // form when a ≥4-hex prefix matches two ids — impossible to force
    // hermetically without colliding sha3-256 digests on 16 bits. `resolve`
    // forwards `RepoState.resolve`'s closed `{ NotFound, Ambiguous, OutOfMemory }`
    // verbatim (no added logic), so its Ambiguous arm is covered by repostate's
    // own §8.3 resolver tests; the CLI switches the same closed set
    // exhaustively. `OutOfMemory` joined the set when equal-`event.mtime` ties
    // started being settled by DAG ancestry (`MtimeMaxPick`), which allocates.
    comptime {
        const E = @typeInfo(@typeInfo(@TypeOf(Repository.resolve)).@"fn".return_type.?).error_union.error_set;
        const names = @typeInfo(E).error_set.?;
        std.debug.assert(names.len == 3); // exactly { NotFound, Ambiguous, OutOfMemory }
    }
}

test "repository §7.1/§7.4: leaves lists open leaves; a closed leaf surfaces only under the all predicate" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Two independent roots on trunk; close one. Both are leaf-table members
    // (§7.1), but only the open one passes the §7.4 open predicate.
    const open_root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(open_root);
    const closed_root = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00" });
    defer testing.allocator.free(closed_root);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = closed_root, .value = null }};
    const ctl = try putControl(&repo, "2020-01-03T00:00:00", &close);
    defer testing.allocator.free(ctl);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    const open_rid = facade.lookup(open_root).?;
    const closed_rid = facade.lookup(closed_root).?;

    // §7.1: both are leaf-table members.
    try testing.expect(facade.state().isLeaf(open_rid));
    try testing.expect(facade.state().isLeaf(closed_rid));
    // §7.4: only the open leaf passes the open predicate.
    try testing.expect(facade.state().isOpenLeaf(open_rid));
    try testing.expect(!facade.state().isOpenLeaf(closed_rid));

    // Enumerating "open only" yields exactly the open leaf; "all" yields both.
    var open_only: usize = 0;
    var all: usize = 0;
    var it = facade.state().leaves();
    while (it.next()) |rid| {
        all += 1;
        if (facade.state().isOpenLeaf(rid)) open_only += 1;
    }
    try testing.expectEqual(@as(usize, 2), all);
    try testing.expectEqual(@as(usize, 1), open_only);
}

test "repository §7.5: currentCheckoutRid marks the checkout leaf; null when unborn/absent" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    try repo.seal();

    // (a) No `.zit-checkout`: nothing to mark.
    {
        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        try testing.expect(facade.currentCheckoutRid() == null);
    }

    // (b) A checkout naming the root: currentCheckoutRid resolves to its rid.
    try checkout.write(io, repo.tmp.dir, .{ .version = root, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    {
        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        const cur = facade.currentCheckoutRid() orelse return error.NothingMarked;
        try testing.expectEqualStrings(root, facade.hashOf(cur));
    }

    // (c) A checkout naming a version this repo never interned: null (§7.5 — a
    // version the view never saw marks nothing).
    try checkout.write(io, repo.tmp.dir, .{ .version = "f" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    {
        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        try testing.expect(facade.currentCheckoutRid() == null);
    }
}

test "repository §6.1: branchOf uses the config main-branch as fallback, else trunk" {
    const io = testing.io;

    // (a) No config frame, a tagless resolution falls back to "trunk".
    {
        var repo: Repo = undefined;
        try repo.init(io);
        defer repo.cleanup();
        const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
        defer testing.allocator.free(root);
        try repo.seal();

        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        try testing.expectEqualStrings("trunk", facade.mainBranch()); // §6.1 unset ⇒ trunk
        try testing.expectEqualStrings("trunk", facade.branchOf(facade.lookup(root).?));
    }

    // (b) A config frame naming "mainline": branchOf threads the configured
    // fallback (§6.1 MUST read the configured value).
    {
        var repo: Repo = undefined;
        try repo.init(io);
        defer repo.cleanup();
        try repo.config("mainline");
        const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00", .branch_value = "mainline" });
        defer testing.allocator.free(root);
        try repo.seal();

        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        try testing.expectEqualStrings("mainline", facade.mainBranch());
        try testing.expectEqualStrings("mainline", facade.branchOf(facade.lookup(root).?));
    }
}

test "repository §5.1/§5.2: tagsOf returns active tags on a resolved check-in, valued and valueless" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A root carrying the valued `branch` tag (§5.2 value = "trunk", added by
    // manifestOf) plus a valueless singleton sym- tag.
    const extra = [_]artifact.TCard{.{ .sign = .add, .name = "sym-v1", .target = null, .value = null }};
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &extra });
    defer testing.allocator.free(root);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    const rid = facade.lookup(root).?;
    const active = try facade.state().tagsOf(testing.allocator, rid);
    defer testing.allocator.free(active);

    // §5.1/§5.2: the `branch` tag carries its value; the valueless sym tag
    // carries none. Both are surfaced as active.
    var saw_valued = false;
    var saw_valueless = false;
    for (active) |t| {
        if (std.mem.eql(u8, t.name, "branch")) {
            saw_valued = true;
            try testing.expectEqualStrings("trunk", t.value.?);
        }
        if (std.mem.eql(u8, t.name, "sym-v1")) {
            saw_valueless = true;
            try testing.expect(t.value == null);
        }
    }
    try testing.expect(saw_valued and saw_valueless);
}

test "repository: an unborn repository has no leaves — empty result, no error" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();
    try repo.seal(); // an empty log: header only, no check-ins

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var count: usize = 0;
    var it = facade.state().leaves();
    while (it.next()) |_| count += 1;
    try testing.expectEqual(@as(usize, 0), count);
}

test "repository open: not-a-repo → NotARepo; a .zit that cannot be read → CannotRead; a corrupt log → Corrupt" {
    const io = testing.io;

    // NotARepo: a directory with no `.zit`.
    {
        var tmp = testing.tmpDir(.{});
        defer tmp.cleanup();
        var facade: Repository = undefined;
        try testing.expectError(error.NotARepo, Repository.open(&facade, testing.allocator, io, tmp.dir, .{}));
    }

    // CannotRead: `.zit` exists but is a directory, so the open cannot read it.
    {
        var tmp = testing.tmpDir(.{});
        defer tmp.cleanup();
        try tmp.dir.createDir(io, ".zit", .default_dir);
        var facade: Repository = undefined;
        try testing.expectError(error.CannotRead, Repository.open(&facade, testing.allocator, io, tmp.dir, .{}));
    }

    // Corrupt: a `.zit` whose bytes are not a valid log (short garbage header).
    {
        var tmp = testing.tmpDir(.{});
        defer tmp.cleanup();
        var f = try tmp.dir.createFile(io, ".zit", .{});
        try f.writePositionalAll(io, "not a log", 0);
        f.close(io);
        var facade: Repository = undefined;
        try testing.expectError(error.Corrupt, Repository.open(&facade, testing.allocator, io, tmp.dir, .{}));
    }
}

test "repository open: no leak or crash under injected OOM at every derive allocation" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    try repo.seal();

    // A manual `FailingAllocator` sweep rather than `checkAllAllocationFailures`:
    // `open` deliberately performs a best-effort `.zit-cache` write that SWALLOWS
    // OOM (the cache is advisory — a failed write is not an open failure), which
    // the "never swallow" checker cannot model. This sweep still proves the real
    // obligation at every allocation point: `open` returns ok or OutOfMemory,
    // never crashes, and never leaks (the wrapped testing.allocator asserts no
    // leak at test end). `--no-cache` forces the full openReadOnly + derive path.
    var i: usize = 0;
    while (true) : (i += 1) {
        var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = i });
        const a = fa.allocator();
        var facade: Repository = undefined;
        if (Repository.open(&facade, a, io, repo.tmp.dir, .{ .bypass_cache = true })) |_| {
            facade.deinit(a);
            // The injected fail point lay at or past the last allocation, so no
            // failure was forced this round — every allocation site is covered.
            if (!fa.has_induced_failure) break;
        } else |err| {
            try testing.expectEqual(error.OutOfMemory, err);
        }
    }
}

test "repository cache parity: a .zit-cache fast-path view equals a from-scratch derive" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A root, a same-branch child, a fork, and a control close — shape enough to
    // exercise leaves, propagation, and tags.
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(fork);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = fork, .value = null }};
    const ctl = try putControl(&repo, "2020-01-04T00:00:00", &close);
    defer testing.allocator.free(ctl);
    try repo.seal();

    // First open with --no-cache derives from scratch AND writes a fresh cache.
    var scratch: Repository = undefined;
    try Repository.open(&scratch, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true });
    defer scratch.deinit(testing.allocator);

    // Second open takes the O(1) `.zit-cache` fast path.
    var cached: Repository = undefined;
    try Repository.open(&cached, testing.allocator, io, repo.tmp.dir, .{});
    defer cached.deinit(testing.allocator);

    // The two views are observably identical (§1 memo == §7.3 rebuild).
    try testing.expectEqualStrings(scratch.mainBranch(), cached.mainBranch());
    var ls = scratch.state().leaves();
    var lc = cached.state().leaves();
    while (ls.next()) |rs| {
        const rc = lc.next() orelse return error.LeafCountMismatch;
        try testing.expectEqual(rs, rc);
        try testing.expectEqualStrings(scratch.hashOf(rs), cached.hashOf(rc));
        try testing.expectEqual(scratch.state().isOpenLeaf(rs), cached.state().isOpenLeaf(rc));
        try testing.expectEqualStrings(scratch.branchOf(rs), cached.branchOf(rc));
    }
    try testing.expect(lc.next() == null);
}

test "repository warmCache §1/§7.1: eagerly warming a cold repo writes a .zit-cache whose .fast_read image load is query-equivalent to a from-scratch derive" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A root, a same-branch child, and a fork — shape enough to exercise the
    // leaf table (§7.1), the open predicate (§7.4), and branch-of (§6.1).
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(fork);
    try repo.seal();

    // A fresh checkout is cold: no `.zit-cache` yet, so the first read would pay
    // the full derive. Eagerly warm it — exactly what runClone does at clone-end.
    try testing.expect(!cacheExists(&repo, io));
    Repository.warmCache(testing.allocator, io, repo.tmp.dir);
    try testing.expect(cacheExists(&repo, io));

    // The next read verb now takes the zero-parse image path (backing == .image),
    // NOT a cold derive, and its freed queries answer identically to a from-
    // scratch derive (repo-state §1 memo: derived state is a function of the log).
    var warm: Repository = undefined;
    try Repository.open(&warm, testing.allocator, io, repo.tmp.dir, .{ .mode = .fast_read });
    defer warm.deinit(testing.allocator);
    try testing.expect(warm.state().backing == .image);

    var scratch: Repository = undefined;
    try Repository.open(&scratch, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true });
    defer scratch.deinit(testing.allocator);

    var ls = scratch.state().leaves();
    var lw = warm.state().leaves();
    while (ls.next()) |rs| {
        const rw = lw.next() orelse return error.LeafCountMismatch;
        try testing.expectEqual(rs, rw); // §7.1: same leaf set and order
        try testing.expectEqualStrings(scratch.hashOf(rs), warm.hashOf(rw)); // §7.2: same names
        try testing.expectEqual(scratch.state().isOpenLeaf(rs), warm.state().isOpenLeaf(rw)); // §7.4
        try testing.expectEqualStrings(scratch.branchOf(rs), warm.branchOf(rw)); // §6.1
    }
    try testing.expect(lw.next() == null);
}

test "repository warmCacheFromLog: one open committed log produces a fast-read cache equivalent to a cold derive" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(fork);
    try repo.log.commit();

    try testing.expect(!cacheExists(&repo, io));
    Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log);
    try testing.expect(cacheExists(&repo, io));
    repo.log.deinit(testing.allocator);
    repo.file.close(io);

    var warm: Repository = undefined;
    try Repository.open(&warm, testing.allocator, io, repo.tmp.dir, .{ .mode = .fast_read });
    defer warm.deinit(testing.allocator);
    try testing.expect(warm.state().backing == .image);

    var scratch: Repository = undefined;
    try Repository.open(&scratch, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true });
    defer scratch.deinit(testing.allocator);

    var ls = scratch.state().leaves();
    var lw = warm.state().leaves();
    while (ls.next()) |rs| {
        const rw = lw.next() orelse return error.LeafCountMismatch;
        try testing.expectEqual(rs, rw);
        try testing.expectEqualStrings(scratch.hashOf(rs), warm.hashOf(rw));
        try testing.expectEqual(scratch.state().isOpenLeaf(rs), warm.state().isOpenLeaf(rw));
        try testing.expectEqualStrings(scratch.branchOf(rs), warm.branchOf(rw));
    }
    try testing.expect(lw.next() == null);
}

test "repository cache extension: an appended log is healed by the next open, equal to a cold derive" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Prefix: two check-ins, cache written for exactly this state.
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    try repo.log.commit();
    Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log);
    try testing.expect(cacheExists(&repo, io));

    // Tail: a fork and a control close appended AFTER the cache -- shapes that
    // exercise linkage, leaf recheck and tag ingestion in the fold, not just a
    // linear child.
    const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(fork);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = fork, .value = null }};
    const ctl = try putControl(&repo, "2020-01-04T00:00:00", &close);
    defer testing.allocator.free(ctl);
    try repo.seal();

    // The invariant: an open over (stale cache + grown log) equals a cold
    // derive over the whole log, view for view.
    var extended: Repository = undefined;
    try Repository.open(&extended, testing.allocator, io, repo.tmp.dir, .{});
    defer extended.deinit(testing.allocator);
    var cold: Repository = undefined;
    try Repository.open(&cold, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true });
    defer cold.deinit(testing.allocator);
    try expectRepoViewsEqual(&cold, &extended);

    // The extension REWROTE the cache for the grown log, so a further open is
    // an exact-tail hit serving the same view again.
    var rehit: Repository = undefined;
    try Repository.open(&rehit, testing.allocator, io, repo.tmp.dir, .{});
    defer rehit.deinit(testing.allocator);
    try expectRepoViewsEqual(&cold, &rehit);
}

test "repository cache extension: the fold really starts from the stored body, not a silent re-derive" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A body captured over the ONE-check-in prefix...
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    try repo.log.commit();
    var short_body: std.ArrayList(u8) = .empty;
    defer short_body.deinit(testing.allocator);
    var short_fp: u32 = undefined;
    var short_branch: []u8 = undefined;
    {
        var ro = try logstore.LogStore.openReadOnly(testing.allocator, repo.fs.storageFile());
        defer ro.deinit(testing.allocator);
        var v: repoview.RepoView = undefined;
        try v.loadCapturing(testing.allocator, &ro, &short_body);
        short_branch = try testing.allocator.dupe(u8, v.main_branch);
        v.deinit(testing.allocator);
        short_fp = ro.shunFingerprint();
    }
    defer testing.allocator.free(short_branch);

    // ...stored as covering the TWO-check-in log: a forged cache whose key is
    // honest about the log but whose body omits `child`.
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    try repo.log.commit();
    var mid_key: logstore.LogStore.LogKey = undefined;
    {
        var ro = try logstore.LogStore.openReadOnly(testing.allocator, repo.fs.storageFile());
        defer ro.deinit(testing.allocator);
        mid_key = try ro.logKey();
    }
    repocache.store(testing.allocator, io, repo.tmp.dir, mid_key, short_fp, short_branch, short_body.items, "");

    // Grow past the forged coverage and open.
    const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(fork);
    try repo.seal();

    var extended: Repository = undefined;
    try Repository.open(&extended, testing.allocator, io, repo.tmp.dir, .{});
    defer extended.deinit(testing.allocator);

    // The fold trusted the CRC-validated body -- the same trust `tryLoad`
    // extends to an exact hit -- so `child`, absent from the forged body and
    // below the forged key, is absent from the view. This is the canary that
    // proves the extension path ran rather than silently re-deriving: a full
    // derive would hold all three check-ins.
    try testing.expect(extended.lookup(child) == null);
    try testing.expect(extended.lookup(root) != null);
    try testing.expect(extended.lookup(fork) != null);
}

test "repository cache extension: a shun-table change refuses the fold and takes the full derive" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    try repo.log.commit();
    Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log);

    // The tail is not just growth: the shun table moved. The cached body was
    // distilled under the old mask, so extension would carry `child` into a
    // view that must now read it as absent. The fingerprint mismatch forces
    // the full derive, which applies the mask.
    var child_digest: [32]u8 = undefined;
    _ = std.fmt.hexToBytes(&child_digest, child) catch unreachable;
    try repo.log.putShun(&child_digest, "test", 0);
    try repo.seal();

    var opened: Repository = undefined;
    try Repository.open(&opened, testing.allocator, io, repo.tmp.dir, .{});
    defer opened.deinit(testing.allocator);
    var cold: Repository = undefined;
    try Repository.open(&cold, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true });
    defer cold.deinit(testing.allocator);
    try expectRepoViewsEqual(&cold, &opened);
    try testing.expect(opened.lookup(child) == null);
}

/// Leaf-by-leaf equality of two opened repositories: rid, hash, open-ness and
/// branch. The oracle the warm-cache test already uses, shared.
fn expectRepoViewsEqual(a: *Repository, b: *Repository) !void {
    var la = a.state().leaves();
    var lb = b.state().leaves();
    while (la.next()) |ra| {
        const rb = lb.next() orelse return error.LeafCountMismatch;
        try testing.expectEqual(ra, rb);
        try testing.expectEqualStrings(a.hashOf(ra), b.hashOf(rb));
        try testing.expectEqual(a.state().isOpenLeaf(ra), b.state().isOpenLeaf(rb));
        try testing.expectEqualStrings(a.branchOf(ra), b.branchOf(rb));
    }
    try testing.expect(lb.next() == null);
}

test "repository manifest/blob: lazy content read returns the uncompressed blob whose hash matches (artifact §7.2)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A plain file blob and a check-in manifest, each content-addressed by its
    // sha3-256 (artifact §7.2 hash-over-uncompressed).
    const blob_bytes = "hello, repository\n";
    const blob_name = try repo.putRaw(blob_bytes);
    defer testing.allocator.free(blob_name);
    const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    // blob: the lazily-opened log returns the exact uncompressed bytes, and the
    // facade verified they hash to the requested name (§7.2).
    const got = (try facade.blob(testing.allocator, blob_name)) orelse return error.BlobMissing;
    defer testing.allocator.free(got);
    try testing.expectEqualStrings(blob_bytes, got);
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(got, &digest, .{});
    const rehash = try hexName(&digest);
    defer testing.allocator.free(rehash);
    try testing.expectEqualStrings(blob_name, rehash); // §7.2: name == hash of uncompressed

    // An unknown name is a clean "not present", not an error.
    try testing.expect((try facade.blob(testing.allocator, "a" ** 64)) == null);

    // manifest: resolve the root, read its manifest lazily, and confirm the
    // round-trip through parse recovered the D-card timestamp we stored.
    const rid = facade.lookup(root).?;
    var om = (try facade.manifest(testing.allocator, rid)) orelse return error.ManifestMissing;
    defer om.deinit(testing.allocator);
    try testing.expectEqualStrings("2020-01-01T00:00:00", om.manifest.d.?.timestamp);
}

test "repository manifest: a fossil-legacy check-in with a dangling F-card separator is read, not dropped (§4.2 read interop)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A root to parent the legacy check-in, so it derives as a real check-in.
    const root = try putCheckin(&repo, .{ .ts = "2008-10-24T00:00:00" });
    defer testing.allocator.free(root);

    // Build a well-formed file-bearing child manifest, then mutate its single F
    // card into fossil's own 2008 cvs2fossil form: a dangling trailing SP where
    // a perm token would go (`F <name> <hash> `). §4.2's `token = 1*tchar` has
    // no production for that empty perm, so strict `parse` rejects it — the very
    // shape that made `timeline` drop check-ins on the full fossil history.
    const child_hash = "b" ** 64;
    var parent_buf = [_]artifact.PCard{.{ .parents = root, .count = 1 }};
    const files = [_]artifact.FCard{fcard("a", child_hash)};
    var m = baseManifest(.manifest, "2008-10-24T12:00:00", parent_buf[0..1], &.{});
    m.f = &files;
    defer m.deinit();
    const good = try artifact.create(&m, testing.allocator);
    defer testing.allocator.free(good);

    // Append one SP just before the F line's LF, then re-seal the §6.2 Z
    // self-checksum over the lengthened content so the artifact stays framed.
    const f_start = std.mem.indexOf(u8, good, "\nF ").? + 1;
    const f_lf = std.mem.indexOfScalarPos(u8, good, f_start, '\n').?;
    const legacy = try testing.allocator.alloc(u8, good.len + 1);
    defer testing.allocator.free(legacy);
    @memcpy(legacy[0..f_lf], good[0..f_lf]);
    legacy[f_lf] = ' ';
    @memcpy(legacy[f_lf + 1 ..], good[f_lf..]);
    const zn = legacy.len;
    var zbuf: [32]u8 = undefined;
    const zsum = artifact.selfChecksum(legacy[0 .. zn - 35], &zbuf);
    @memcpy(legacy[zn - 33 .. zn - 1], zsum);

    // Fixture sanity: this really is the legacy form — strict parse rejects it,
    // the read-path tolerance accepts it. Guards the regression below against a
    // vacuous pass if the crafted bytes ever stop being the rejecting form.
    try testing.expectError(error.SyntaxError, artifact.parse(testing.allocator, legacy));
    var im = try artifact.parseForImport(testing.allocator, legacy);
    im.deinit(testing.allocator);

    // Store the legacy manifest verbatim (its sha3-256 names it) and open.
    const legacy_name = try repo.putRaw(legacy);
    defer testing.allocator.free(legacy_name);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    // The regression: reading this check-in's manifest must SUCCEED (pre-fix it
    // is rejected as error.Corrupt, which `timeline` surfaces as a bogus
    // "input/output error" while silently dropping the check-in and every older
    // one). The recovered comment/timestamp prove the tolerant read round-trips.
    const rid = facade.lookup(legacy_name).?;
    var om = (try facade.manifest(testing.allocator, rid)) orelse return error.ManifestMissing;
    defer om.deinit(testing.allocator);
    try testing.expectEqualStrings("2008-10-24T12:00:00", om.manifest.d.?.timestamp);
    try testing.expectEqualStrings("c", om.manifest.c.?.comment);
}

// F-card blob ids used by the file-set tests. Any valid 64-hex token works —
// `files` walks manifests, never reads file blobs — so these need not name
// stored content; they only have to round-trip through create/parse.
const ha = "a" ** 64;
const hb = "b" ** 64;
const hc = "c" ** 64;
const hd = "d" ** 64;

test "repository files §8.2: a baseline manifest yields its F cards in canonical order" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // §4.5.3: F cards are sorted by decoded filename; §4.7.6: a baseline's cards
    // every carry a hash. The effective set is exactly the authored set, in order.
    const files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb), fcard("c", hc) };
    const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &files });
    defer testing.allocator.free(root);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var list = (try facade.files(testing.allocator, facade.lookup(root).?)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);

    try testing.expectEqual(@as(usize, 3), got.items.len);
    try testing.expectEqualStrings("a", got.items[0].filename);
    try testing.expectEqualStrings("b", got.items[1].filename);
    try testing.expectEqualStrings("c", got.items[2].filename);
    try testing.expectEqualStrings(ha, got.items[0].hash.?);
    try testing.expectEqualStrings(hb, got.items[1].hash.?);
    try testing.expectEqualStrings(hc, got.items[2].hash.?);
}

test "repository files §8.2: delta manifest merges baseline+delta; a replaced file takes the delta hash (step 1)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Baseline {a:ha, b:hb}; delta replaces b (→hc, §8.2 step 1) and adds c (hd).
    // Effective set (§8.2 filename-ordered merge): a:ha, b:hc, c:hd.
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);

    try testing.expectEqual(@as(usize, 3), got.items.len);
    try testing.expectEqualStrings("a", got.items[0].filename);
    try testing.expectEqualStrings(ha, got.items[0].hash.?); // unchanged baseline entry
    try testing.expectEqualStrings("b", got.items[1].filename);
    try testing.expectEqualStrings(hc, got.items[1].hash.?); // step 1: delta hash wins
    try testing.expectEqualStrings("c", got.items[2].filename);
    try testing.expectEqualStrings(hd, got.items[2].hash.?); // step 3: delta-only add
}

test "repository files §8.3: a delta over a fossil-legacy baseline (dangling F-card separator) merges, not BaselineUnresolvable (§4.2 read interop)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A derived root, so the delta below is a real, look-up-able check-in.
    const root = try putCheckin(&repo, .{ .ts = "2008-10-24T00:00:00" });
    defer testing.allocator.free(root);

    // Build a well-formed baseline manifest carrying one F card, then mutate that
    // card into fossil's own 2008 cvs2fossil form: a dangling trailing SP where a
    // perm token would go (`F <name> <hash> `). §4.2's `token = 1*tchar` has no
    // production for that empty perm, so strict `parse` rejects — the shape the
    // delta baseline path (`files`/merge) fed straight into the strict parser.
    var m = baseManifest(.manifest, "2008-10-24T06:00:00", &.{}, &.{});
    const base_files = [_]artifact.FCard{fcard("a", ha)};
    m.f = &base_files;
    defer m.deinit();
    const good = try artifact.create(&m, testing.allocator);
    defer testing.allocator.free(good);

    // Append one SP just before the F line's LF, then re-seal the §6.2 Z
    // self-checksum over the lengthened content so the artifact stays framed.
    const f_start = std.mem.indexOf(u8, good, "\nF ").? + 1;
    const f_lf = std.mem.indexOfScalarPos(u8, good, f_start, '\n').?;
    const legacy = try testing.allocator.alloc(u8, good.len + 1);
    defer testing.allocator.free(legacy);
    @memcpy(legacy[0..f_lf], good[0..f_lf]);
    legacy[f_lf] = ' ';
    @memcpy(legacy[f_lf + 1 ..], good[f_lf..]);
    const zn = legacy.len;
    var zbuf: [32]u8 = undefined;
    const zsum = artifact.selfChecksum(legacy[0 .. zn - 35], &zbuf);
    @memcpy(legacy[zn - 33 .. zn - 1], zsum);

    // Vacuity guard: this really is the rejecting legacy form — strict `parse`
    // refuses it, the read-path tolerance accepts it. If the crafted bytes ever
    // stop being the rejecting shape, the regression below would pass vacuously.
    try testing.expectError(error.SyntaxError, artifact.parse(testing.allocator, legacy));
    var im = try artifact.parseForImport(testing.allocator, legacy);
    im.deinit(testing.allocator);

    // Store the legacy baseline verbatim (its sha3-256 names it); a delta over it
    // adds file "b" and names it as its B-card baseline.
    const base_name = try repo.putRaw(legacy);
    defer testing.allocator.free(base_name);
    const delta_files = [_]artifact.FCard{fcard("b", hc)};
    const delta = try putFiles(&repo, .{ .ts = "2008-10-24T12:00:00", .parent = root, .baseline = base_name, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    // The regression: `files` must MERGE the legacy baseline with the delta.
    // Pre-fix `parseBaseline` runs the strict parser, rejects the legacy baseline
    // as NotManifest, and `enumerate` raises BaselineUnresolvable (§8.3) — the
    // baseline's file "a" vanishes. Post-fix the tolerant read keeps it: effective
    // set is a:ha (from the legacy baseline) and b:hc (the delta's add).
    var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);

    try testing.expectEqual(@as(usize, 2), got.items.len);
    try testing.expectEqualStrings("a", got.items[0].filename);
    try testing.expectEqualStrings(ha, got.items[0].hash.?); // read from the legacy baseline
    try testing.expectEqualStrings("b", got.items[1].filename);
    try testing.expectEqualStrings(hc, got.items[1].hash.?); // the delta's own add
}

test "repository files §8.2: a delta delete-marker suppresses the baseline file (step 2, §8.2-r2)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Baseline {a:ha, b:hb}; delta carries a delete marker for b (null hash).
    // §8.2 step 2: matched delete emits nothing — effective set is {a:ha}.
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{fcard("b", null)};
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);

    try testing.expectEqual(@as(usize, 1), got.items.len);
    try testing.expectEqualStrings("a", got.items[0].filename);
    try testing.expectEqualStrings(ha, got.items[0].hash.?);
}

test "repository files §8.2: unmatched/tail delete markers never appear in the effective set (§4.7.6 hash-less card)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Baseline {b:hb}; delta carries two delete markers with no baseline match:
    // "a" (sorts before b — §8.2 step-3 unmatched) and "c" (delta tail, §8.2-m1).
    // Both are emitted by the raw walk but carry no hash, so the effective set is
    // just {b:hb} (§4.7.6: consumers filter hash-less cards).
    const base_files = [_]artifact.FCard{fcard("b", hb)};
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{ fcard("a", null), fcard("c", null) };
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);

    try testing.expectEqual(@as(usize, 1), got.items.len);
    try testing.expectEqualStrings("b", got.items[0].filename);
    try testing.expectEqualStrings(hb, got.items[0].hash.?);
}

test "repository files §8.3: an unresolvable baseline is BaselineUnresolvable (§8.3-m1)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A delta whose B card names a baseline that was never stored. §8.3 hard
    // failure: file-set enumeration MUST treat it as fatal, not degrade to
    // delta-only.
    const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const delta_files = [_]artifact.FCard{fcard("a", ha)};
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .baseline = hb, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    try testing.expectError(error.BaselineUnresolvable, facade.files(testing.allocator, facade.lookup(delta).?));
}

test "repository files §4.7.3: a baseline that is itself a delta is BaselineIsDelta" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A: a genuine baseline. B: a delta over A. C: a delta whose baseline is B —
    // a delta-of-delta, rejected at resolution time (§4.7.3).
    const base_files = [_]artifact.FCard{fcard("a", ha)};
    const a_ci = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(a_ci);
    const b_files = [_]artifact.FCard{fcard("a", hb)};
    const b_ci = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = a_ci, .baseline = a_ci, .files = &b_files });
    defer testing.allocator.free(b_ci);
    const c_files = [_]artifact.FCard{fcard("a", hc)};
    const c_ci = try putFiles(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = b_ci, .baseline = b_ci, .files = &c_files });
    defer testing.allocator.free(c_ci);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    try testing.expectError(error.BaselineIsDelta, facade.files(testing.allocator, facade.lookup(c_ci).?));
}

test "repository files: a non-manifest (control) artifact is NotManifest" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A control artifact is interned (its hash gains a rid) but is not a check-in
    // (§5.1); `files` on it is NotManifest, never a file listing.
    const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const tags = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
    const ctl = try putControl(&repo, "2020-01-02T00:00:00", &tags);
    defer testing.allocator.free(ctl);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    try testing.expectError(error.NotManifest, facade.files(testing.allocator, facade.lookup(ctl).?));
}

test "repository files: missing target content returns null" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A child whose parent hash was never stored: the parent is interned as a
    // phantom (a rid with no content). `files` on it is a clean null, not a fault.
    const child = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = hd });
    defer testing.allocator.free(child);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    const phantom = facade.lookup(hd).?; // interned as the unstored parent
    try testing.expect((try facade.files(testing.allocator, phantom)) == null);
}

test "repository files: no leak under injected OOM at every allocation" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A delta over a baseline exercises the deepest allocation path: read+parse
    // the target, eagerly read+parse the baseline, then enumerate.
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    // A manual `FailingAllocator` sweep rather than `checkAllAllocationFailures`:
    // `open` runs a best-effort `.zit-cache` write that SWALLOWS OOM (the cache is
    // advisory), which the "never swallow" checker cannot model — the same reason
    // the `open` OOM test sweeps manually. Each round opens a fresh repository and
    // runs `files` under one failing allocator, proving both return ok or
    // OutOfMemory, never crash, and never leak (the wrapped allocator asserts).
    var i: usize = 0;
    while (true) : (i += 1) {
        var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = i });
        const a = fa.allocator();
        var facade: Repository = undefined;
        if (Repository.open(&facade, a, io, repo.tmp.dir, .{ .bypass_cache = true })) |_| {
            const rid = facade.lookup(delta).?;
            if (facade.files(a, rid)) |maybe| {
                if (maybe) |ml| {
                    var list = ml;
                    list.deinit(a);
                }
                facade.deinit(a);
                // The injected fail point lay at or past the last allocation, so
                // no failure was forced — every allocation site is covered.
                if (!fa.has_induced_failure) break;
            } else |err| {
                facade.deinit(a);
                try testing.expectEqual(error.OutOfMemory, err);
            }
        } else |err| {
            try testing.expectEqual(error.OutOfMemory, err);
        }
    }
}

test "repository §8.4: fileAt on a baseline manifest returns the named file's F-card hash; an absent name is null" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A baseline {a:ha, b:hb, c:hc}. §8.4 exact-name lookup returns the F card
    // whose filename equals the query; a name no card carries is null.
    const files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb), fcard("c", hc) };
    const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &files });
    defer testing.allocator.free(root);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);
    const rid = facade.lookup(root).?;

    const found = (try facade.fileAt(testing.allocator, rid, "b")) orelse return error.NotFound;
    defer Repository.freeFileCard(testing.allocator, found);
    try testing.expectEqualStrings("b", found.filename);
    try testing.expectEqualStrings(hb, found.hash.?);

    // §8.4: a name entirely absent from the (baseline) manifest is not-found.
    try testing.expect((try facade.fileAt(testing.allocator, rid, "zzz")) == null);
}

test "repository §8.4: fileAt searches the delta's own F cards first — a file the delta replaced returns the delta hash, not the baseline hash" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Baseline {a:ha, b:hb}; delta replaces b (→hc) and adds c (hd). §8.4:
    // "b" is found in the delta's own cards first (hc wins over the baseline hb);
    // "a" is untouched by the delta so it comes from the baseline (ha); "c" is a
    // delta-only addition (hd).
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);
    const rid = facade.lookup(delta).?;

    const b = (try facade.fileAt(testing.allocator, rid, "b")) orelse return error.NotFound;
    defer Repository.freeFileCard(testing.allocator, b);
    try testing.expectEqualStrings(hc, b.hash.?); // delta hash, not baseline hb

    const a = (try facade.fileAt(testing.allocator, rid, "a")) orelse return error.NotFound;
    defer Repository.freeFileCard(testing.allocator, a);
    try testing.expectEqualStrings(ha, a.hash.?); // untouched baseline entry

    const c = (try facade.fileAt(testing.allocator, rid, "c")) orelse return error.NotFound;
    defer Repository.freeFileCard(testing.allocator, c);
    try testing.expectEqualStrings(hd, c.hash.?); // delta-only add
}

test "repository §8.4: a delta delete-marker reports not-found without consulting the baseline (§4.7.6 hash-less card)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Baseline {a:ha, b:hb}; delta carries a delete marker (null hash) for b.
    // §8.4: an entry whose hash is absent reports not-found for "b" without
    // consulting the baseline; "a" (unmentioned by the delta) still resolves.
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{fcard("b", null)};
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);
    const rid = facade.lookup(delta).?;

    try testing.expect((try facade.fileAt(testing.allocator, rid, "b")) == null);

    const a = (try facade.fileAt(testing.allocator, rid, "a")) orelse return error.NotFound;
    defer Repository.freeFileCard(testing.allocator, a);
    try testing.expectEqualStrings(ha, a.hash.?);
}

test "repository §8.4: a name entirely absent from the delta resolves against the baseline" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Baseline {a:ha, b:hb}; delta touches only c (hd). §8.4: "a" is entirely
    // absent from the delta, so the baseline is resolved and searched — ha.
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{fcard("c", hd)};
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);
    const rid = facade.lookup(delta).?;

    const a = (try facade.fileAt(testing.allocator, rid, "a")) orelse return error.NotFound;
    defer Repository.freeFileCard(testing.allocator, a);
    try testing.expectEqualStrings(ha, a.hash.?);
}

test "repository §8.4: fileAt forwards the §8.3/§4.7.3 baseline faults (BaselineUnresolvable, BaselineIsDelta) unchanged" {
    const io = testing.io;

    // (a) §8.3: a delta whose B card names a never-stored baseline. fileAt runs
    // the same §8.2 walk as `files`, so the hard failure surfaces unchanged.
    {
        var repo: Repo = undefined;
        try repo.init(io);
        defer repo.cleanup();
        const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00" });
        defer testing.allocator.free(root);
        const delta_files = [_]artifact.FCard{fcard("a", ha)};
        const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .baseline = hb, .files = &delta_files });
        defer testing.allocator.free(delta);
        try repo.seal();

        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        try testing.expectError(error.BaselineUnresolvable, facade.fileAt(testing.allocator, facade.lookup(delta).?, "a"));
    }

    // (b) §4.7.3: a delta whose baseline is itself a delta (delta-of-delta).
    {
        var repo: Repo = undefined;
        try repo.init(io);
        defer repo.cleanup();
        const base_files = [_]artifact.FCard{fcard("a", ha)};
        const a_ci = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
        defer testing.allocator.free(a_ci);
        const b_files = [_]artifact.FCard{fcard("a", hb)};
        const b_ci = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = a_ci, .baseline = a_ci, .files = &b_files });
        defer testing.allocator.free(b_ci);
        const c_files = [_]artifact.FCard{fcard("a", hc)};
        const c_ci = try putFiles(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = b_ci, .baseline = b_ci, .files = &c_files });
        defer testing.allocator.free(c_ci);
        try repo.seal();

        var facade: Repository = undefined;
        try openRepo(&facade, &repo, io);
        defer facade.deinit(testing.allocator);
        try testing.expectError(error.BaselineIsDelta, facade.fileAt(testing.allocator, facade.lookup(c_ci).?, "a"));
    }
}

test "repository §8.4: fileAt leaks nothing under injected OOM at every allocation (checkAllAllocationFailures)" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // A delta over a baseline: fileAt reads+parses the target, eagerly reads+
    // parses the baseline, enumerates, then duplicates the matched card — the
    // deepest allocation path. A manual `FailingAllocator` sweep (not
    // `checkAllAllocationFailures`) because `open` swallows the advisory cache
    // write's OOM, which the never-swallow checker cannot model.
    const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
    const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
    defer testing.allocator.free(base);
    const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
    const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
    defer testing.allocator.free(delta);
    try repo.seal();

    var i: usize = 0;
    while (true) : (i += 1) {
        var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = i });
        const a = fa.allocator();
        var facade: Repository = undefined;
        if (Repository.open(&facade, a, io, repo.tmp.dir, .{ .bypass_cache = true })) |_| {
            const rid = facade.lookup(delta).?;
            if (facade.fileAt(a, rid, "b")) |maybe| {
                if (maybe) |card| Repository.freeFileCard(a, card);
                facade.deinit(a);
                if (!fa.has_induced_failure) break; // fail point past the last alloc: fully covered
            } else |err| {
                facade.deinit(a);
                try testing.expectEqual(error.OutOfMemory, err);
            }
        } else |err| {
            try testing.expectEqual(error.OutOfMemory, err);
        }
    }
}

// ---------------------------------------------------------------------------
// draft-fossil-submanifests-00 §5 — tree check-ins through the facade
// ---------------------------------------------------------------------------
//
// The Appendix A.2/A.3 vectors, byte-for-byte, stored as real artifacts so the
// facade's own store-backed §5.2 resolver does the resolving. §9.3 requires
// every consumer of a check-in's file set to obtain it through one shared
// enumeration interface; these tests pin that `files` IS that interface for a
// tree check-in, with nothing about the tree visible above it.

const sm_a2_1_leaf = @embedFile("rfc_vectors/submanifest_a2_1_leaf.txt");
const sm_a2_2_leaf = @embedFile("rfc_vectors/submanifest_a2_2_leaf.txt");
const sm_a3_manifest = @embedFile("rfc_vectors/submanifest_a3_tree_manifest.txt");

/// The Appendix A.1 file set: name and content, in canonical filename order.
const sm_a1_files = [_]struct { name: []const u8, content: []const u8 }{
    .{ .name = "README.md", .content = "# Example\n" },
    .{ .name = "src/main.c", .content = "int main(void){return 0;}\n" },
    .{ .name = "src/node11.c", .content = "/* node11 */\n" },
    .{ .name = "src/util.c", .content = "/* util */\n" },
};

/// Store the four Appendix A.1 file blobs, so a walked enumeration's F-card
/// hashes name real content and the §5.5 R aggregate can be recomputed.
fn smPutContents(repo: *Repo) !void {
    for (sm_a1_files) |f| testing.allocator.free(try repo.putRaw(f.content));
}

/// Assemble a hand-written artifact: `body` plus the §6.2 Z self-checksum.
/// The writer emits no S cards until a later slice, so a tree manifest that is
/// not one of the draft's own vectors is spelled out byte by byte here.
fn smSeal(buf: []u8, body: []const u8) []const u8 {
    @memcpy(buf[0..body.len], body);
    var digest_buf: [32]u8 = undefined;
    const digest = artifact.selfChecksum(body, &digest_buf);
    buf[body.len] = 'Z';
    buf[body.len + 1] = ' ';
    @memcpy(buf[body.len + 2 ..][0..32], digest);
    buf[body.len + 34] = '\n';
    return buf[0 .. body.len + 35];
}

test "repository submanifests §7.4: a second check-in reuses the sub-manifests the first parsed" {
    // §7.4: "unchanged subtrees are shared by hash across check-ins, so a
    // memoizing rebuild parses each distinct sub-manifest once ... A naive
    // implementation obtains the byte reduction of this format but not the
    // parse-count reduction that motivated it."
    //
    // Two DISTINCT tree check-ins over the same two leaves — the shared-subtree
    // case the section is about. `Repository` is the enumeration facade §9.3
    // routes every file-set consumer through, so this is the production path:
    // the merge in `cmd/checkout` enumerates three check-ins from one open
    // repository exactly like this.
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    try smPutContents(&repo);
    testing.allocator.free(try repo.putRaw(sm_a2_1_leaf));
    testing.allocator.free(try repo.putRaw(sm_a2_2_leaf));
    const tree_a = try repo.putRaw(sm_a3_manifest);
    defer testing.allocator.free(tree_a);

    // A second tree manifest naming the SAME two sub-manifests: identical S run,
    // different C card, so it is a different artifact over an unchanged tree.
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(testing.allocator);
    var it = std.mem.splitScalar(u8, sm_a3_manifest, '\n');
    while (it.next()) |line| {
        if (line.len == 0) continue;
        if (line[0] == 'Z') continue; // resealed below
        if (line[0] == 'C') {
            try body.appendSlice(testing.allocator, "C second\n");
            continue;
        }
        try body.appendSlice(testing.allocator, line);
        try body.append(testing.allocator, '\n');
    }
    var seal_buf: [4096]u8 = undefined;
    const tree_b_bytes = smSeal(&seal_buf, body.items);
    const tree_b = try repo.putRaw(tree_b_bytes);
    defer testing.allocator.free(tree_b);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var first = (try facade.files(testing.allocator, facade.lookup(tree_a).?)) orelse return error.NoFiles;
    defer first.deinit(testing.allocator);
    const memo = &facade.sub_memo.?;
    const distinct = memo.misses;
    try testing.expect(distinct >= 2); // both leaves were parsed for the first tree
    try testing.expectEqual(@as(usize, 0), memo.hits);

    var second = (try facade.files(testing.allocator, facade.lookup(tree_b).?)) orelse return error.NoFiles;
    defer second.deinit(testing.allocator);

    // The second enumeration parsed NO new sub-manifest: every one it needed was
    // already resolved, which is precisely the parse-count reduction §7.4 asks
    // for and a naive implementation does not get.
    try testing.expectEqual(distinct, memo.misses);
    try testing.expect(memo.hits >= distinct);
    try testing.expect(!memo.degraded);

    // …and the answer is identical, so the memo is transparent (the DST asserts
    // this per seed; here it is asserted on the real façade).
    var a_files: std.ArrayList(artifact.FCard) = .empty;
    defer a_files.deinit(testing.allocator);
    var b_files: std.ArrayList(artifact.FCard) = .empty;
    defer b_files.deinit(testing.allocator);
    try collectFiles(&first, &a_files);
    try collectFiles(&second, &b_files);
    try testing.expectEqual(a_files.items.len, b_files.items.len);
    for (a_files.items, b_files.items) |x, y| try testing.expectEqualStrings(x.filename, y.filename);
}

test "repository submanifests §5.1/§9.3: a tree check-in enumerates its complete file set" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    try smPutContents(&repo);
    testing.allocator.free(try repo.putRaw(sm_a2_1_leaf));
    testing.allocator.free(try repo.putRaw(sm_a2_2_leaf));
    const tree = try repo.putRaw(sm_a3_manifest);
    defer testing.allocator.free(tree);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    var list = (try facade.files(testing.allocator, facade.lookup(tree).?)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);

    // §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c,
    // src/node11.c, src/util.c: the same list a flat manifest would carry."
    try testing.expectEqual(sm_a1_files.len, got.items.len);
    for (sm_a1_files, got.items) |want, f| {
        try testing.expectEqualStrings(want.name, f.filename);
        try testing.expect(f.hash != null); // §4.4: every reachable F card carries a hash
    }
}

test "repository submanifests §§5.1–5.2: LogStore and Repository flatten the same tree" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    try smPutContents(&repo);
    testing.allocator.free(try repo.putRaw(sm_a2_1_leaf));
    testing.allocator.free(try repo.putRaw(sm_a2_2_leaf));
    const tree_name = try repo.putRaw(sm_a3_manifest);
    defer testing.allocator.free(tree_name);

    var direct = try artifact.parse(testing.allocator, sm_a3_manifest);
    defer direct.deinit();
    try Repository.flattenTreeFromLog(testing.allocator, &repo.log, &direct);
    try testing.expectEqual(sm_a1_files.len, direct.flattened.?.len);

    try repo.seal();
    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);
    var listed = (try facade.files(testing.allocator, facade.lookup(tree_name).?)) orelse
        return error.NoFiles;
    defer listed.deinit(testing.allocator);
    var flattened: std.ArrayList(artifact.FCard) = .empty;
    defer flattened.deinit(testing.allocator);
    try collectFiles(&listed, &flattened);
    try testing.expectEqual(direct.flattened.?.len, flattened.items.len);
    for (direct.flattened.?, flattened.items) |left, right| {
        try testing.expectEqualStrings(left.filename, right.filename);
        try testing.expectEqualStrings(left.hash.?, right.hash.?);
    }
}

test "repository submanifests §5.2: missing LogStore target remains a tree fault" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();
    defer {
        repo.log.deinit(testing.allocator);
        repo.file.close(io);
    }

    try smPutContents(&repo);
    testing.allocator.free(try repo.putRaw(sm_a2_1_leaf));
    var tree = try artifact.parse(testing.allocator, sm_a3_manifest);
    defer tree.deinit();
    try testing.expectError(
        error.SubUnresolvable,
        Repository.flattenTreeFromLog(testing.allocator, &repo.log, &tree),
    );
    try testing.expect(tree.flattened == null);
}

test "repository submanifests §9.2/§7.3: a persisted SUBMANIFEST is excluded from the crosslink sweep" {
    // §7.3: "A SUBMANIFEST artifact has no direct crosslink effect: it carries
    // no state-bearing card and contributes to repository state only through the
    // manifests whose trees contain it." §9.2 turns that into a requirement on
    // the sweep itself — exclude SUBMANIFEST artifacts, "otherwise it re-selects
    // and re-parses them on every pass, forever."
    //
    // zit's sweep (`repoview.linkOne`) visits every persisted frame, so the
    // exclusion is the ONLY thing standing between a repository holding a leaf
    // sub-manifest and a derive that treats a D-less artifact as a check-in.
    // Remove the `sweepDisposition` guard and this open returns `Corrupt`: a
    // SUBMANIFEST has no D card, and a check-in without a parseable D-card
    // timestamp is structurally corrupt. The whole repository stops opening.
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    try smPutContents(&repo);
    const leaf_1 = try repo.putRaw(sm_a2_1_leaf);
    defer testing.allocator.free(leaf_1);
    const leaf_2 = try repo.putRaw(sm_a2_2_leaf);
    defer testing.allocator.free(leaf_2);
    const tree = try repo.putRaw(sm_a3_manifest);
    defer testing.allocator.free(tree);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io); // Corrupt without the exclusion
    defer facade.deinit(testing.allocator);

    // The tree manifest DID crosslink: it carries a D card and is a check-in,
    // so the exclusion is proven discriminating, not a blanket skip.
    const tree_rid = facade.lookup(tree) orelse return error.TreeNotCrosslinked;
    try testing.expect(facade.view.state.mtimeOf(tree_rid) != null);

    // Neither sub-manifest produced a derived row of its own. `lookup` answers
    // over the derived view's identity space, so a null IS "no row" — the
    // negative space a §9.2-conforming sweep leaves behind, and the reason a
    // negative-space sweep would re-select them forever.
    try testing.expect(facade.lookup(leaf_1) == null);
    try testing.expect(facade.lookup(leaf_2) == null);

    // …while the tree they belong to still enumerates through them (§7.3's
    // "contributes to repository state only through the manifests whose trees
    // contain it"), so the exclusion cost the file set nothing.
    var list = (try facade.files(testing.allocator, tree_rid)) orelse return error.NoFiles;
    defer list.deinit(testing.allocator);
    var got: std.ArrayList(artifact.FCard) = .empty;
    defer got.deinit(testing.allocator);
    try collectFiles(&list, &got);
    try testing.expectEqual(sm_a1_files.len, got.items.len);
}

test "repository submanifests §5.5: a tree check-in's R card verifies over the walked enumeration" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    try smPutContents(&repo);
    testing.allocator.free(try repo.putRaw(sm_a2_1_leaf));
    testing.allocator.free(try repo.putRaw(sm_a2_2_leaf));
    const tree = try repo.putRaw(sm_a3_manifest);
    defer testing.allocator.free(tree);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    // §A.3 states the value: "MD5 of these 117 bytes =
    // 152bc688378069a3c22a1a4c55bc8b1a, which matches the R card."
    try testing.expectEqual(
        @as(?bool, true),
        try facade.verifyChecksum(testing.allocator, facade.lookup(tree).?),
    );
}

test "repository submanifests §5.2: a tree check-in with an absent sub-manifest fails hard" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // Only the first leaf is stored. §5.2: "An implementation MUST NOT present
    // a partially resolved tree as a complete file set" — so this is a fault,
    // not a two-file enumeration. (The deferred-record retry path of §5.2 and
    // §9.4 is post-receive processing, a later slice.)
    try smPutContents(&repo);
    testing.allocator.free(try repo.putRaw(sm_a2_1_leaf));
    const tree = try repo.putRaw(sm_a3_manifest);
    defer testing.allocator.free(tree);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    try testing.expectError(
        error.BaselineUnresolvable,
        facade.files(testing.allocator, facade.lookup(tree).?),
    );
}

test "repository submanifests §3.6: an S card naming a non-SUBMANIFEST fails the walk" {
    const io = testing.io;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();

    // §3.6: "The referenced artifact MUST be of type SUBMANIFEST; a reference
    // to any other type MUST cause the tree walk to fail." The S card here
    // names a plain file blob, which is stored but is not an artifact at all.
    const blob = try repo.putRaw("# Example\n");
    defer testing.allocator.free(blob);

    var body_buf: [256]u8 = undefined;
    var art_buf: [256]u8 = undefined;
    const body = try std.fmt.bufPrint(
        &body_buf,
        "C tree\nD 2020-01-01T00:00:00\nR d41d8cd98f00b204e9800998ecf8427e\nS README.md {s}\nU u\n",
        .{blob},
    );
    const tree = try repo.putRaw(smSeal(&art_buf, body));
    defer testing.allocator.free(tree);
    try repo.seal();

    var facade: Repository = undefined;
    try openRepo(&facade, &repo, io);
    defer facade.deinit(testing.allocator);

    try testing.expectError(
        error.BaselineUnresolvable,
        facade.files(testing.allocator, facade.lookup(tree).?),
    );
}

fn costShapeEntries(arena: std.mem.Allocator, n: usize, hash_digit: u8) ![]artifact.FCard {
    std.debug.assert(hash_digit >= '0');
    std.debug.assert(hash_digit <= '9');
    var entries: std.ArrayList(artifact.FCard) = .empty;
    for (0..n) |i| {
        const filename = try std.fmt.allocPrint(arena, "src/file{d:0>5}.zig", .{i});
        const hash = try arena.alloc(u8, 64);
        @memset(hash, hash_digit);
        try entries.append(arena, .{
            .filename = filename,
            .hash = hash,
            .perm = null,
            .old_name = null,
            .mode = .regular,
        });
    }
    return entries.items;
}

test "repository submanifests §6.3: cost-shape digest ignores same-width content hashes" {
    // draft-fossil-submanifests-00 §6.3 defines C(k) solely from serialized
    // leaf and leaf-naming S-card bytes. Equal-width content hashes therefore
    // leave every candidate cost unchanged; the digest must do the same.
    const gpa = testing.allocator;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const before = try costShapeEntries(arena, 400, '1');
    const after = try costShapeEntries(arena, 400, '2');

    var before_digest: submanifest.CostShapeDigest = undefined;
    var after_digest: submanifest.CostShapeDigest = undefined;
    try testing.expectEqualSlices(
        u8,
        submanifest.costShapeDigest(before, &before_digest),
        submanifest.costShapeDigest(after, &after_digest),
    );
    try testing.expectEqual(@as(usize, 32), before_digest.len);
    try testing.expectEqual(@as(usize, 32), after_digest.len);

    var k: u4 = artifact.min_k;
    while (true) : (k += 1) {
        const before_cost = try submanifest.cost(gpa, before, k, .sha3_256);
        const after_cost = try submanifest.cost(gpa, after, k, .sha3_256);
        try testing.expectEqualDeep(before_cost, after_cost);
        if (k == artifact.max_k) {
            break;
        }
    }
}

test "repository submanifests §6.3: cost-shape digest frames every cost input" {
    // draft-fossil-submanifests-00 §6.3 candidate construction depends on
    // filename partitioning and serialized F-card widths. This local cache key
    // must invalidate whenever an input changes; framing prevents aliases.
    const hash_64 = "0" ** 64;
    const hash_40 = "0" ** 40;
    const base = [_]artifact.FCard{.{
        .filename = "src/a.zig",
        .hash = hash_64,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    var base_digest: submanifest.CostShapeDigest = undefined;
    const base_bytes = submanifest.costShapeDigest(&base, &base_digest);

    const cases = [_]artifact.FCard{
        .{
            .filename = "src/b.zig",
            .hash = hash_64,
            .perm = null,
            .old_name = null,
            .mode = .regular,
        },
        .{
            .filename = "src/a.zig",
            .hash = hash_64,
            .perm = "x",
            .old_name = null,
            .mode = .regular,
        },
        .{
            .filename = "src/a.zig",
            .hash = hash_64,
            .perm = null,
            .old_name = "src/old.zig",
            .mode = .regular,
        },
        .{
            .filename = "src/a.zig",
            .hash = hash_40,
            .perm = null,
            .old_name = null,
            .mode = .regular,
        },
    };
    for (cases) |entry| {
        var digest: submanifest.CostShapeDigest = undefined;
        const candidate = submanifest.costShapeDigest(&.{entry}, &digest);
        try testing.expect(!std.mem.eql(u8, base_bytes, candidate));
        try testing.expectEqual(@as(usize, 32), digest.len);
    }
}

test "repository content channel: the scan index is written on miss, adopted on the next open, and never consulted under bypass" {
    const io = testing.io;
    const gpa = testing.allocator;
    var repo: Repo = undefined;
    try repo.init(io);
    defer repo.cleanup();
    try repo.config("trunk");
    const blob_hash = try repo.putRaw("file body\n");
    defer testing.allocator.free(blob_hash);
    const tip = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &.{fcard("a.txt", blob_hash)} });
    defer testing.allocator.free(tip);
    try repo.seal();

    const index_exists = struct {
        fn f(aio: std.Io, dir: std.Io.Dir) bool {
            var fl = dir.openFile(aio, ".zit-cache/index", .{}) catch return false;
            fl.close(aio);
            return true;
        }
    }.f;

    // First content read pays the scan and persists its product.
    var r1: Repository = undefined;
    try openRepo(&r1, &repo, io);
    try testing.expect(!index_exists(io, repo.tmp.dir));
    const b1 = (try r1.blob(gpa, blob_hash)).?;
    defer gpa.free(b1);
    try testing.expect(index_exists(io, repo.tmp.dir));
    r1.deinit(gpa);

    // The next open adopts the index; every answer is identical.
    var r2: Repository = undefined;
    try openRepo(&r2, &repo, io);
    const b2 = (try r2.blob(gpa, blob_hash)).?;
    defer gpa.free(b2);
    try testing.expectEqualSlices(u8, b1, b2);
    r2.deinit(gpa);

    // A corrupted index is a silent miss: the answer is still right, and the
    // rewrite-on-miss replaces it with a valid one.
    {
        var f = try repo.tmp.dir.createFile(io, ".zit-cache/index", .{ .truncate = true });
        try f.writePositionalAll(io, "garbage over the index", 0);
        f.close(io);
    }
    var r3: Repository = undefined;
    try openRepo(&r3, &repo, io);
    const b3 = (try r3.blob(gpa, blob_hash)).?;
    defer gpa.free(b3);
    try testing.expectEqualSlices(u8, b1, b3);
    r3.deinit(gpa);

    // --no-cache: same answer, and the sidecar is neither read nor rewritten
    // (delete it and confirm bypass leaves it absent).
    repo.tmp.dir.deleteFile(io, ".zit-cache/index") catch {};
    var r4: Repository = undefined;
    try Repository.open(&r4, gpa, io, repo.tmp.dir, .{ .bypass_cache = true });
    const b4 = (try r4.blob(gpa, blob_hash)).?;
    defer gpa.free(b4);
    try testing.expectEqualSlices(u8, b1, b4);
    r4.deinit(gpa);
    try testing.expect(!index_exists(io, repo.tmp.dir));
}