Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

tools/janitor/main.zig

Raw
// Termux Janitor specification tool.
//
// This executable composes the generic specification engine with the product
// registries and projections owned by this repository. It holds no relationship
// mechanics of its own.
const std = @import("std");
const engine = @import("engine");
const model_types = @import("model.zig");
const janitor_codegen = @import("codegen.zig");
const janitor_project = @import("project.zig");
const janitor_query = @import("query.zig");
const janitor_validate = @import("validate.zig");
const janitor_profile = @import("profile");

const memory_size = 16 * 1024 * 1024;
const output_size_max = engine.project.output_size_max;

var memory: [memory_size]u8 = undefined;

comptime {
    std.debug.assert(output_size_max < memory_size);
}

pub fn main(init: std.process.Init.Minimal) !void {
    var fixed = std.heap.FixedBufferAllocator.init(&memory);
    const allocator = fixed.allocator();
    var threaded: std.Io.Threaded = .init(allocator, .{
        .environ = init.environ,
        .argv0 = .init(init.args),
    });
    defer threaded.deinit();
    const io = threaded.io();
    const args = try init.args.toSlice(allocator);

    var stdout_buffer: [4096]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
    const stdout = &stdout_writer.interface;
    var stderr_buffer: [4096]u8 = undefined;
    var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
    const stderr = &stderr_writer.interface;

    run(allocator, io, args[1..], stdout, stderr) catch |err| {
        try stderr.print("spec-tool: {s}\n", .{@errorName(err)});
        if (usageHelps(err)) try renderUsage(stderr);
        try stderr.flush();
        std.process.exit(1);
    };
    try stdout.flush();
    try stderr.flush();
}

/// Gates validate before answering. Generators, transformations, and reports do
/// not, because their purpose is to resolve an inconsistent model.
fn run(
    allocator: std.mem.Allocator,
    io: std.Io,
    args: []const []const u8,
    stdout: *std.Io.Writer,
    stderr: *std.Io.Writer,
) !void {
    if (args.len == 0) return error.MissingCommand;
    if (std.mem.eql(u8, args[0], "help") or std.mem.eql(u8, args[0], "--help")) {
        try renderUsage(stdout);
        return;
    }
    const model = try model_types.Model.load(allocator, io);

    if (std.mem.eql(u8, args[0], "check")) {
        if (args.len != 1) return error.UnexpectedArgument;
        try janitor_validate.validateModel(allocator, io, &model, stderr);
        try engine.project.checkDerived(allocator, io, &model.graph, stderr);
        try janitor_project.checkDerived(allocator, io, &model, stderr);
        try reportModel(&model, stdout);
        return;
    }
    if (std.mem.eql(u8, args[0], "generate")) {
        try janitor_validate.validateModel(allocator, io, &model, stderr);
        const target = if (args.len == 2) args[1] else "all";
        const product_target = std.mem.eql(u8, target, "adapters") or
            std.mem.eql(u8, target, "classification") or
            std.mem.eql(u8, target, "thresholds");
        if (!product_target) {
            try engine.project.runGenerate(allocator, io, &model.graph, args[1..], stderr);
        }
        try janitor_project.runGenerate(allocator, io, &model, args[1..]);
        try stdout.writeAll("generated specification projections\n");
        return;
    }
    if (std.mem.eql(u8, args[0], "query")) {
        try janitor_validate.validateModel(allocator, io, &model, stderr);
        try runQuery(&model, args[1..], stdout);
        return;
    }
    if (std.mem.eql(u8, args[0], "emit-zig")) {
        if (args.len != 2) return error.InvalidGenerationArguments;
        try janitor_validate.validateModel(allocator, io, &model, stderr);
        try emitZig(allocator, io, &model, args[1]);
        return;
    }
    if (std.mem.eql(u8, args[0], "emit-tests")) {
        if (args.len < 2 or args.len > 3) return error.InvalidGenerationArguments;
        try janitor_validate.validateModel(allocator, io, &model, stderr);
        const suite: ?[]const u8 = if (args.len == 3) args[2] else null;
        try emitTests(allocator, io, &model.graph, args[1], suite);
        return;
    }
    if (std.mem.eql(u8, args[0], "record-suite")) {
        try runRecordSuite(allocator, io, args[1..]);
        return;
    }
    if (std.mem.eql(u8, args[0], "evidence")) {
        if (args.len != 1) return error.InvalidRecordArguments;
        try janitor_validate.validateModel(allocator, io, &model, stderr);
        try runEvidence(
            allocator,
            io,
            &model.graph,
            janitor_profile.evidence_dir,
            stdout,
        );
        return;
    }
    if (std.mem.eql(u8, args[0], "scaffold-test")) {
        if (args.len != 2) return error.InvalidScaffoldArguments;
        try engine.scaffold.scaffoldTest(&model.graph, args[1], stdout);
        return;
    }
    if (std.mem.eql(u8, args[0], "new-test")) {
        if (args.len < 4) return error.InvalidNewTestArguments;
        var options: engine.scaffold.NewTestOptions = .{};
        for (args[4..]) |argument| {
            if (std.mem.eql(u8, argument, "--write")) {
                options.write_fixture = true;
            } else if (std.mem.startsWith(u8, argument, "--path=")) {
                options.fixture_path = argument["--path=".len..];
            } else return error.InvalidNewTestArguments;
        }
        try engine.scaffold.newTest(
            &model.graph,
            args[1],
            args[2],
            args[3],
            options,
            io,
            stdout,
        );
        return;
    }
    if (std.mem.eql(u8, args[0], "new-source")) {
        if (args.len < 3) return error.InvalidNewSourceArguments;
        try engine.sync.newSource(allocator, io, &model.graph, args[1], args[2..], stdout);
        return;
    }
    if (std.mem.eql(u8, args[0], "sync-fingerprints")) {
        _ = try engine.sync.fingerprints(allocator, io, &model.graph, args[1..], stdout);
        return;
    }
    if (std.mem.eql(u8, args[0], "status")) {
        const json = args.len > 1 and std.mem.eql(u8, args[args.len - 1], "--json");
        const positional = args[1 .. args.len - @intFromBool(json)];
        if (positional.len > 1) return error.InvalidStatusArguments;
        const filter: ?[]const u8 = if (positional.len == 1) positional[0] else null;
        try engine.status.report(&model.graph, filter, json, stdout);
        return;
    }
    if (std.mem.eql(u8, args[0], "fingerprint")) {
        if (args.len != 2) return error.InvalidFingerprintArguments;
        try engine.query.runFingerprint(allocator, io, args[1], stdout, stderr);
        return;
    }
    if (std.mem.eql(u8, args[0], "impact")) {
        if (args.len < 2 or args.len > 3) return error.InvalidImpactArguments;
        const json = args.len == 3 and std.mem.eql(u8, args[2], "--json");
        if (args.len == 3 and !json) return error.InvalidImpactArguments;
        try engine.query.runImpact(&model.graph, args[1], json, stdout);
        return;
    }
    return error.UnknownCommand;
}

const Command = struct {
    usage: []const u8,
    summary: []const u8,
};

const commands = [_]Command{
    .{ .usage = "check", .summary = "Validate every specification link and generated projection." },
    .{ .usage = "generate [TARGET]", .summary = "Rewrite generated documents: agents, " ++
        "requirements, glossary, adapters, classification, thresholds, or all." },
    .{ .usage = "query KIND ID [--json]", .summary = "Inspect one fact. KIND is requirement, " ++
        "concept, document, owner," },
    .{ .usage = "", .summary = "limit, cli-option, android-package, classification-rule, threshold, test-spec, obligation, or implementation." },
    .{ .usage = "status [REQUIREMENT] [--json]", .summary = "Report what is verified, merely " ++
        "specified, or unverified." },
    .{ .usage = "impact ID-OR-PATH [--json]", .summary = "List every fact linked to a " ++
        "requirement, obligation, fixture, test, or source." },
    .{ .usage = "fingerprint PATH", .summary = "Print the reviewed SHA-256 fingerprint of a " ++
        "tracked file." },
    .{ .usage = "sync-fingerprints [PATH...]", .summary = "Record reviewed fingerprints after " ++
        "classifying a change." },
    .{ .usage = "new-test NAME REQUIREMENT SUITE", .summary = "Print every registry delta " ++
        "required to add one verified scenario." },
    .{ .usage = "  [--path=FILE] [--write]", .summary = "Optional explicit fixture path; " ++
        "--write creates the fixture file." },
    .{ .usage = "new-source PATH OBLIGATION...", .summary = "Print the implementation-unit " ++
        "stanza registering a new source." },
    .{ .usage = "scaffold-test TEST-ID", .summary = "Print a disposable fixture implementation " ++
        "that cannot pass unfinished." },
    .{ .usage = "emit-zig PATH", .summary = "Write the generated Zig projection. Used by the " ++
        "build graph." },
    .{ .usage = "emit-tests PATH [SUITE]", .summary = "Write generated fixture tests. Used " ++
        "by the build graph." },
    .{ .usage = "record-suite --suite NAME --artifact PATH --zig V --target T", .summary = "Write one transient suite-evidence record after a fresh suite run." },
    .{ .usage = "  --optimize M [--suite-filter F] [--records-dir DIR]", .summary = "Each " ++
        "--spec ID@REV needs one following --fixture ID@REV:SHA and --binding SRC:SHA." },
    .{ .usage = "evidence", .summary = "Join stored suite records to the current graph. " ++
        "Execution evidence only, never acceptance." },
};

fn usageHelps(err: anyerror) bool {
    return switch (err) {
        error.MissingCommand,
        error.UnknownCommand,
        error.UnknownQuery,
        error.InvalidQuery,
        error.UnexpectedArgument,
        error.InvalidGenerationArguments,
        error.InvalidScaffoldArguments,
        error.InvalidNewTestArguments,
        error.InvalidNewSourceArguments,
        error.InvalidStatusArguments,
        error.InvalidFingerprintArguments,
        error.InvalidImpactArguments,
        error.InvalidRecordArguments,
        => true,
        else => false,
    };
}

fn renderUsage(writer: *std.Io.Writer) !void {
    try writer.writeAll("\nusage: zig build spec-tool -- COMMAND [ARGUMENTS]\n\n");
    for (commands) |command| {
        if (command.usage.len == 0) {
            try writer.print("  {s:<31} {s}\n", .{ "", command.summary });
        } else {
            try writer.print("  {s:<31} {s}\n", .{ command.usage, command.summary });
        }
    }
    try writer.writeAll(
        "\nEvery gate runs from the normal build: " ++
            "zig build, zig build test, zig build check.\n" ++
            "Failures name their owner, inspection commands, and resolution.\n",
    );
}

fn reportModel(model: *const model_types.Model, stdout: *std.Io.Writer) !void {
    const graph = &model.graph;
    try stdout.print(
        "specification valid: {d} requirements, {d} concepts, {d} limits, " ++
            "{d} CLI options, {d} test specifications, ",
        .{
            graph.requirementCount(),
            graph.concepts.len,
            model.limits.len,
            model.cli.options.len,
            graph.test_specs.len,
        },
    );
    try stdout.print("{d} {s}, {d} obligations, {d} {s}, {d} documents\n", .{
        graph.test_bindings.len,
        if (graph.test_bindings.len == 1) "binding" else "bindings",
        graph.verification.len,
        graph.implementations.len,
        if (graph.implementations.len == 1) "implementation unit" else "implementation units",
        graph.documents.len,
    });
}

fn runQuery(
    model: *const model_types.Model,
    args: []const []const u8,
    writer: *std.Io.Writer,
) !void {
    if (args.len < 2 or args.len > 3) return error.InvalidQuery;
    const json = args.len == 3 and std.mem.eql(u8, args[2], "--json");
    if (args.len == 3 and !json) return error.InvalidQuery;
    if (std.mem.eql(u8, args[0], "limit")) {
        return janitor_query.queryLimit(model, args[1], json, writer);
    }
    if (std.mem.eql(u8, args[0], "cli-option")) {
        return janitor_query.queryCliOption(model, args[1], json, writer);
    }
    if (std.mem.eql(u8, args[0], "android-package")) {
        return janitor_query.queryAndroidPackage(model, args[1], json, writer);
    }
    if (std.mem.eql(u8, args[0], "classification-rule")) {
        return janitor_query.queryClassification(model, args[1], json, writer);
    }
    if (std.mem.eql(u8, args[0], "threshold")) {
        return janitor_query.queryThreshold(model, args[1], json, writer);
    }
    return engine.query.runQuery(&model.graph, args, writer);
}

fn emitZig(
    allocator: std.mem.Allocator,
    io: std.Io,
    model: *const model_types.Model,
    path: []const u8,
) !void {
    var output = std.Io.Writer.Allocating.init(allocator);
    defer output.deinit();
    try janitor_codegen.render(model, &output.writer);
    if (output.written().len > output_size_max) return error.OutputTooLarge;
    try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = output.written() });
}

fn emitTests(
    allocator: std.mem.Allocator,
    io: std.Io,
    graph: *const engine.model.Graph,
    path: []const u8,
    suite: ?[]const u8,
) !void {
    var output = std.Io.Writer.Allocating.init(allocator);
    defer output.deinit();
    try engine.fixture.render(allocator, io, graph, suite, &output.writer);
    if (output.written().len > output_size_max) return error.OutputTooLarge;
    try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = output.written() });
}

const RecordOptions = struct {
    suite: []const u8,
    artifact_path: []const u8,
    zig: []const u8,
    target: []const u8,
    optimize: []const u8,
    suite_filter: []const u8 = "",
    records_dir: []const u8 = janitor_profile.evidence_dir,
    identities: std.ArrayListUnmanaged(engine.evidence.SpecIdentity) = .empty,

    fn identityBuffer(self: *RecordOptions, allocator: std.mem.Allocator) !*engine.evidence.SpecIdentity {
        try self.identities.ensureUnusedCapacity(allocator, 1);
        return self.identities.addOneAssumeCapacity();
    }
};

fn recordFlagName(argument: []const u8) ?[]const u8 {
    const prefix = "--";
    if (!std.mem.startsWith(u8, argument, prefix)) return null;
    return argument[prefix.len..];
}

/// Write one bounded transient suite-evidence record. The caller is the build
/// graph, immediately after a fresh successful run of one generated suite
/// binary. Nothing here reads a registry: every identity arrives frozen on the
/// command line, so a changed manifest can never be attributed to this run.
fn runRecordSuite(
    allocator: std.mem.Allocator,
    io: std.Io,
    args: []const []const u8,
) !void {
    var options: RecordOptions = .{
        .suite = "",
        .artifact_path = "",
        .zig = "",
        .target = "",
        .optimize = "",
    };
    defer options.identities.deinit(allocator);
    var index: usize = 0;
    while (index < args.len) : (index += 1) {
        const name = recordFlagName(args[index]) orelse return error.InvalidRecordArguments;
        const rest = args[index + 1 ..];
        if (std.mem.eql(u8, name, "suite")) {
            options.suite = takeValue(rest, &index) orelse return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "artifact")) {
            options.artifact_path = takeValue(rest, &index) orelse
                return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "zig")) {
            options.zig = takeValue(rest, &index) orelse return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "target")) {
            options.target = takeValue(rest, &index) orelse
                return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "optimize")) {
            options.optimize = takeValue(rest, &index) orelse
                return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "suite-filter")) {
            options.suite_filter = takeValue(rest, &index) orelse
                return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "records-dir")) {
            options.records_dir = takeValue(rest, &index) orelse
                return error.InvalidRecordArguments;
        } else if (std.mem.eql(u8, name, "spec")) {
            const value = takeValue(rest, &index) orelse return error.InvalidRecordArguments;
            if (rest.len < 5) return error.InvalidRecordArguments;
            if (!std.mem.eql(u8, rest[1], "--fixture") or
                !std.mem.eql(u8, rest[3], "--binding"))
            {
                return error.InvalidRecordArguments;
            }
            const identity = try options.identityBuffer(allocator);
            identity.* = .{
                .id = parseSpecReference(value) orelse return error.InvalidRecordArguments,
                .revision = parseRevision(value) orelse return error.InvalidRecordArguments,
                .fixture_id = parseFixtureId(rest[2]) orelse
                    return error.InvalidRecordArguments,
                .fixture_revision = parseFixtureRevision(rest[2]) orelse
                    return error.InvalidRecordArguments,
                .fixture_sha256 = parseFixtureSha(rest[2]) orelse
                    return error.InvalidRecordArguments,
                .binding_source = parseBindingSource(rest[4]) orelse
                    return error.InvalidRecordArguments,
                .binding_sha256 = parseBindingSha(rest[4]) orelse
                    return error.InvalidRecordArguments,
            };
            index += 4;
        } else return error.InvalidRecordArguments;
    }
    if (options.suite.len == 0 or options.artifact_path.len == 0 or
        options.zig.len == 0 or options.target.len == 0 or options.optimize.len == 0 or
        options.identities.items.len == 0)
    {
        return error.InvalidRecordArguments;
    }
    std.mem.sort(
        engine.evidence.SpecIdentity,
        options.identities.items,
        {},
        identityOrder,
    );
    const artifact_sha256 = try hashFile(io, options.artifact_path);
    var record = engine.evidence.SuiteRecord{
        .schema_version = engine.evidence.schema_version,
        .suite = options.suite,
        .outcome = engine.evidence.outcome_passed,
        .zig = options.zig,
        .target = options.target,
        .optimize = options.optimize,
        .suite_filter = options.suite_filter,
        .artifact_sha256 = &artifact_sha256,
        .test_specs = options.identities.items,
    };
    var encoded = std.Io.Writer.Allocating.init(allocator);
    defer encoded.deinit();
    try engine.evidence.renderRecord(&record, &encoded.writer);
    if (encoded.written().len > engine.evidence.record_size_max) {
        return error.OutputTooLarge;
    }
    try writeRecordAtomically(io, options.records_dir, options.suite, encoded.written());
}

fn identityOrder(
    _: void,
    a: engine.evidence.SpecIdentity,
    b: engine.evidence.SpecIdentity,
) bool {
    return std.mem.order(u8, a.id, b.id) == .lt;
}

fn takeValue(rest: []const []const u8, index: *usize) ?[]const u8 {
    if (rest.len == 0) return null;
    index.* += 1;
    return rest[0];
}

fn parseSpecReference(value: []const u8) ?[]const u8 {
    const at = std.mem.indexOfScalar(u8, value, '@') orelse return null;
    if (at == 0) return null;
    return value[0..at];
}

fn parseRevision(value: []const u8) ?u32 {
    const at = std.mem.lastIndexOfScalar(u8, value, '@') orelse return null;
    return std.fmt.parseInt(u32, value[at + 1 ..], 10) catch null;
}

fn parseFixtureRevision(value: []const u8) ?u32 {
    const at = std.mem.indexOfScalar(u8, value, '@') orelse return null;
    const colon = std.mem.indexOfScalarPos(u8, value, at, ':') orelse return null;
    return std.fmt.parseInt(u32, value[at + 1 .. colon], 10) catch null;
}

fn parseFixtureId(value: []const u8) ?[]const u8 {
    const at = std.mem.indexOfScalar(u8, value, '@') orelse return null;
    if (at == 0) return null;
    return value[0..at];
}

fn parseFixtureSha(value: []const u8) ?[]const u8 {
    const colon = std.mem.indexOfScalar(u8, value, ':') orelse return null;
    if (colon + 1 + 64 != value.len) return null;
    return value[colon + 1 ..];
}

fn parseBindingSource(value: []const u8) ?[]const u8 {
    const colon = std.mem.indexOfScalar(u8, value, ':') orelse return null;
    if (colon < 2) return null;
    return value[0..colon];
}

fn parseBindingSha(value: []const u8) ?[]const u8 {
    const colon = std.mem.indexOfScalar(u8, value, ':') orelse return null;
    if (colon + 1 + 64 != value.len) return null;
    return value[colon + 1 ..];
}

/// Stream one file through SHA-256 without loading it into memory.
fn hashFile(io: std.Io, path: []const u8) ![64]u8 {
    var file = try std.Io.Dir.cwd().openFile(io, path, .{});
    defer file.close(io);
    var hasher = std.crypto.hash.sha2.Sha256.init(.{});
    var file_buffer: [4096]u8 = undefined;
    var file_reader = file.reader(io, &file_buffer);
    var chunk: [4096]u8 = undefined;
    while (true) {
        const read = try file_reader.interface.readSliceShort(&chunk);
        if (read == 0) break;
        hasher.update(chunk[0..read]);
    }
    var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
    hasher.final(&digest);
    var hex: [64]u8 = undefined;
    _ = std.fmt.bufPrint(&hex, "{x}", .{&digest}) catch unreachable;
    return hex;
}

/// Replace the suite record in one atomic step. A crash mid-write leaves the
/// previous record and an ignored temporary file, never a partial record.
fn writeRecordAtomically(
    io: std.Io,
    records_dir: []const u8,
    suite: []const u8,
    encoded: []const u8,
) !void {
    try std.Io.Dir.cwd().createDirPath(io, records_dir);
    var name_buffer: [128]u8 = undefined;
    const final = try std.fmt.bufPrint(&name_buffer, "{s}/suite-{s}.json", .{ records_dir, suite });
    var temp_buffer: [160]u8 = undefined;
    const temporary = try std.fmt.bufPrint(
        &temp_buffer,
        "{s}/suite-{s}.json.spec-tool.tmp",
        .{ records_dir, suite },
    );
    try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = temporary, .data = encoded });
    std.Io.Dir.cwd().rename(temporary, std.Io.Dir.cwd(), final, io) catch |err| {
        std.Io.Dir.cwd().deleteFile(io, temporary) catch {};
        return err;
    };
}

/// Join stored suite records to the current graph. The projection reports
/// suite execution evidence only; it never equates suite success with
/// requirement acceptance, and a missing record is never a failure verdict.
fn runEvidence(
    allocator: std.mem.Allocator,
    io: std.Io,
    graph: *const engine.model.Graph,
    records_dir: []const u8,
    writer: *std.Io.Writer,
) !void {
    var parsed_records: std.ArrayListUnmanaged(engine.evidence.ParsedRecord) = .empty;
    defer parsed_records.deinit(allocator);
    var records: std.ArrayListUnmanaged(engine.evidence.NamedRecord) = .empty;
    defer records.deinit(allocator);
    var raw: std.ArrayListUnmanaged([]u8) = .empty;
    defer {
        for (raw.items) |item| allocator.free(item);
        raw.deinit(allocator);
    }
    for (graph.test_bindings) |*binding| {
        const test_spec = engine.model.findTestSpec(graph.test_specs, binding.test_spec.id).?;
        const suite = @tagName(test_spec.suite);
        var duplicate = false;
        for (records.items) |*named| {
            if (std.mem.eql(u8, named.suite, suite)) duplicate = true;
        }
        if (duplicate) continue;
        var path_buffer: [128]u8 = undefined;
        const path = try std.fmt.bufPrint(
            &path_buffer,
            "{s}/suite-{s}.json",
            .{ records_dir, suite },
        );
        const bytes = std.Io.Dir.cwd().readFileAlloc(
            io,
            path,
            allocator,
            .limited(engine.evidence.record_size_max + 1),
        ) catch |err| switch (err) {
            error.FileNotFound => continue,
            else => return err,
        };
        try raw.append(allocator, bytes);
        try parsed_records.append(
            allocator,
            try engine.evidence.parseRecord(allocator, bytes),
        );
        const parsed = &parsed_records.items[parsed_records.items.len - 1];
        try records.append(allocator, .{ .suite = suite, .record = parsed.record });
    }

    var expected: std.ArrayListUnmanaged(engine.evidence.ExpectedIdentity) = .empty;
    defer expected.deinit(allocator);
    for (graph.test_bindings) |*binding| {
        const test_spec = engine.model.findTestSpec(graph.test_specs, binding.test_spec.id).?;
        try expected.append(allocator, .{
            .suite = @tagName(test_spec.suite),
            .identity = .{
                .id = binding.test_spec.id,
                .revision = binding.test_spec.revision,
                .fixture_id = test_spec.fixture.id,
                .fixture_revision = test_spec.fixture.revision,
                .fixture_sha256 = test_spec.fixture.sha256,
                .binding_source = binding.source,
                .binding_sha256 = binding.source_sha256,
            },
        });
    }
    const rows = try engine.evidence.join(expected.items, records.items, allocator);
    defer allocator.free(rows);

    try writer.writeAll("{\"schema_version\":1,\"records_dir\":");
    try std.json.Stringify.encodeJsonString(records_dir, .{}, writer);
    try writer.writeAll(",\"rows\":[");
    for (rows, 0..) |row, index| {
        if (index > 0) try writer.writeByte(',');
        try writer.writeAll("{\"test_spec\":");
        try std.json.Stringify.encodeJsonString(row.identity.id, .{}, writer);
        try writer.print(",\"revision\":{d},\"suite\":", .{row.identity.revision});
        try std.json.Stringify.encodeJsonString(row.suite, .{}, writer);
        try writer.writeAll(",\"state\":\"");
        try writer.writeAll(@tagName(row.state));
        try writer.writeAll("\"}");
    }
    try writer.writeAll("]}\n");
}

test {
    _ = model_types;
    _ = janitor_codegen;
    _ = janitor_project;
    _ = janitor_query;
    _ = janitor_validate;
    _ = @import("profile");
}

test "record suite writes a joinable transient record" {
    const allocator = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "artifact.bin", .data = "suite-artifact-bytes" });
    const artifact_path = try std.fmt.allocPrint(
        allocator,
        ".zig-cache/tmp/{s}/artifact.bin",
        .{tmp.sub_path},
    );
    defer allocator.free(artifact_path);

    const args = [_][]const u8{
        "--suite",       "u",
        "--artifact",    artifact_path,
        "--zig",         "0.16.0",
        "--target",      "aarch64-linux-android",
        "--optimize",    "Debug",
        "--records-dir", ".zig-cache/tmp/records-test",
        "--spec",        "test-b@2",
        "--fixture",     "fixture-b@2:" ++ ("ab" ** 32),
        "--binding",     "tests/fixtures/b.zig:" ++ ("cd" ** 32),
        "--spec",        "test-a@1",
        "--fixture",     "fixture-a@1:" ++ ("ab" ** 32),
        "--binding",     "tests/fixtures/a.zig:" ++ ("cd" ** 32),
    };
    try runRecordSuite(allocator, io, &args);

    var path_buffer: [128]u8 = undefined;
    const record_path = try std.fmt.bufPrint(
        &path_buffer,
        ".zig-cache/tmp/records-test/suite-u.json",
        .{},
    );
    const bytes = try std.Io.Dir.cwd().readFileAlloc(
        io,
        record_path,
        allocator,
        .limited(engine.evidence.record_size_max),
    );
    defer allocator.free(bytes);
    var parsed = try engine.evidence.parseRecord(allocator, bytes);
    defer parsed.deinit();
    try std.testing.expectEqualStrings("u", parsed.record.suite);
    try std.testing.expectEqual(@as(usize, 2), parsed.record.test_specs.len);
    // Identities are sorted by identifier regardless of argument order.
    try std.testing.expectEqualStrings("test-a", parsed.record.test_specs[0].id);
    try std.testing.expectEqualStrings("test-b", parsed.record.test_specs[1].id);

    const expected = [_]engine.evidence.ExpectedIdentity{
        .{
            .suite = "u",
            .identity = .{
                .id = "test-a",
                .revision = 1,
                .fixture_id = "fixture-a",
                .fixture_revision = 1,
                .fixture_sha256 = "ab" ** 32,
                .binding_source = "tests/fixtures/a.zig",
                .binding_sha256 = "cd" ** 32,
            },
        },
        .{
            .suite = "u",
            .identity = .{
                .id = "test-b",
                .revision = 9,
                .fixture_id = "fixture-b",
                .fixture_revision = 2,
                .fixture_sha256 = "ab" ** 32,
                .binding_source = "tests/fixtures/b.zig",
                .binding_sha256 = "cd" ** 32,
            },
        },
    };
    const rows = try engine.evidence.join(&expected, &.{.{
        .suite = "u",
        .record = parsed.record,
    }}, allocator);
    defer allocator.free(rows);
    try std.testing.expectEqual(engine.evidence.SpecState.passed, rows[0].state);
    try std.testing.expectEqual(engine.evidence.SpecState.superseded, rows[1].state);
}