Skip to content
zit Artifact [6f8f789de8]
Login

Artifact [6f8f789de8]

Artifact 6f8f789de8d37c24a6b97dfe426badbd7f22784345d45f94efff30f41734874e:


//! 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 storage = @import("storage.zig");
const checkout = @import("checkout.zig");
const Repository = @import("repository.zig").Repository;
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);
    }
};

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);
}

/// 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 }` 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.
    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 == 2); // exactly { NotFound, Ambiguous }
    }
}

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" });
    {
        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" });
    {
        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 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);
}