Skip to content
zit Artifact [63bd0409f4]
Login

Artifact [63bd0409f4]

Artifact 63bd0409f4f26d70f224fa91434815509944b0c9b58cfe4766320b2fb87fbc7d:


const std = @import("std");

/// Config threaded into each per-artifact builder: target/optimize choice plus
/// the single `zit` library module every root imports. storage, logstore,
/// checkout, and update are folded into that one module (re-exported from
/// root.zig), so there are no separate store peers to wire.
const Env = struct {
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    optimize: std.builtin.OptimizeMode,
    mod: *std.Build.Module,
};

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    // `--release` means ReleaseSafe here, and that is a correctness decision
    // rather than a preference. src/ carries over a thousand `std.debug.assert`
    // calls, which are the mechanism by which a detected inconsistency becomes a
    // crash instead of a durable write: TigerStyle's rule is that assertions
    // "downgrade catastrophic correctness bugs into liveness bugs", and that
    // trade only exists while the assertion does. ReleaseFast and ReleaseSmall
    // compile every one of them to nothing, so a binary built that way silently
    // converts each guarded corruption into an unguarded one — in an append-only
    // store, where a bad frame is permanent and a crash is not.
    //
    // Two consequences, both measured rather than assumed:
    //
    // Stating a preference REPLACES `-Doptimize=<mode>` with `-Drelease=<bool>`
    // in the option surface. The explicit escape hatch is the top-level
    // `--release=fast`, not `-Doptimize=ReleaseFast`, which no longer parses.
    //
    // The cost is NOT uniform, and "a few percent" would be wrong. Read-side
    // verbs are indistinguishable between the two modes (status/log/stat all
    // inside noise on a 5 MB store). `zit pack` is not: 599 ms fast against
    // 15.2 s safe on that same store, ~25x, because the delta encoder's
    // best-of-N search is a tight per-byte loop where a bounds check is a large
    // fraction of the work. That is an argument for `@setRuntimeSafety(false)`
    // on the measured inner loop — a local, reviewable opt-out over code the
    // fuzz and DST lanes already cover — and NOT an argument for stripping the
    // assertions from every other path in the binary.
    //
    // `zig build bench` builds its executables in this same ReleaseSafe. A
    // benchmark must measure optimized code, but it must measure the code that
    // SHIPS. The assertions above are part of the binary users run, and the
    // cost is not uniform — `zit pack` alone differs ~25x between the modes —
    // so a ReleaseFast number describes a binary nobody executes.
    const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe });

    // The zit library: a Zig implementation of the Fossil formats and protocol
    // as specified by the four RFC drafts in docs/rfcs/. root.zig re-exports the
    // store layers (storage/logstore/checkout/update) and the Repository facade;
    // one module is the whole embeddable surface.
    const mod = b.addModule("zit", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
    });
    const env = Env{
        .b = b,
        .target = target,
        .optimize = optimize,
        .mod = mod,
    };

    // `zig build test` — library + CLI unit tests, and nothing else. The store
    // layers and their sibling tests aggregate into `mod_tests` via root.zig's
    // test block; build_cli wires the CLI's inline tests in below. Every other
    // lane hangs off `gate`, not here.
    const test_step = b.step("test", "Run unit tests");
    const mod_tests = b.addTest(.{ .root_module = build_unit_test_module(b, target, optimize) });
    test_step.dependOn(&b.addRunArtifact(mod_tests).step);
    const git_import_tests = b.addTest(.{ .root_module = b.createModule(.{
        .root_source_file = b.path("src/git/git_import_test.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{.{ .name = "zit", .module = mod }},
    }) });
    test_step.dependOn(&b.addRunArtifact(git_import_tests).step);

    // Task 9 refs/provenance coverage intentionally remains opt-in until the
    // finalization API exists, so translation tests still run independently.
    const git_import_refs_tests = b.addTest(.{ .root_module = b.createModule(.{
        .root_source_file = b.path("src/git/git_import_refs_test.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{.{ .name = "zit", .module = mod }},
    }) });
    const git_import_refs_run = b.addRunArtifact(git_import_refs_tests);
    const git_import_refs_step = b.step("git-import-refs", "Run git import refs tests");
    git_import_refs_step.dependOn(&git_import_refs_run.step);
    test_step.dependOn(&git_import_refs_run.step);

    const git_import_pipe_helper = b.addExecutable(.{
        .name = "git-import-cli-pipe-helper",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/git/git_import_cli_pipe_helper.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const git_import_cli_options = b.addOptions();
    git_import_cli_options.addOptionPath("git_import_pipe_helper", git_import_pipe_helper.getEmittedBin());

    // Task 10 transaction coverage guards the executable adapter as part of
    // the ordinary unit-test gate, and remains separately runnable for focus.
    const git_import_cli_tests = b.addTest(.{ .root_module = b.createModule(.{
        .root_source_file = b.path("src/git/git_import_cli_test.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "zit", .module = mod },
            .{ .name = "git_import_cli_options", .module = git_import_cli_options.createModule() },
        },
    }) });
    const git_export_cli_tests = b.addTest(.{ .root_module = b.createModule(.{
        .root_source_file = b.path("src/git/git_export_cli_test.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{.{ .name = "zit", .module = mod }},
    }) });
    test_step.dependOn(&b.addRunArtifact(git_export_cli_tests).step);

    const git_import_cli_run = b.addRunArtifact(git_import_cli_tests);
    git_import_cli_run.step.dependOn(&git_import_pipe_helper.step);
    const git_import_cli_step = b.step("git-import-cli", "Run git import CLI transaction tests");
    git_import_cli_step.dependOn(&git_import_cli_run.step);
    test_step.dependOn(&git_import_cli_run.step);

    // `zig build gate` — everything `test` runs, plus the compile-only proofs
    // that the e2e and smoke harnesses still build against the current library.
    //
    // Those two compiles used to hang off `test`, and they are not cheap: both
    // rebuild the whole `zit` module in the pinned ReleaseSafe. Measured at the
    // time of the split, `zig build test` was 9m13s wall while the tests it
    // actually RAN accounted for well under a minute of it -- the rest was
    // compilation, a large share of it these two artifacts that `test` builds
    // and then never executes. A suite that slow between edits stops being run
    // between edits, which is the opposite of what a unit lane is for.
    //
    // What did NOT move: the dst, bench and smoke *unit* tests. Those are unit
    // tests despite their siblings' names, and `zig build dst`/`smoke` run the
    // simulation and the interop check rather than them -- so moving them here
    // would have dropped them from CI entirely, since CI invokes the lanes
    // individually and never invokes `gate`. Cheap to run, and the only thing
    // covering that code.
    const gate_step = b.step("gate", "Full pre-merge gate: unit tests plus the e2e and smoke compile proofs");
    gate_step.dependOn(test_step);

    const cli = build_cli(env, test_step);
    build_e2e(env, cli.exe, gate_step);
    build_git_import_e2e(env, cli.exe, gate_step);
    build_git_export_e2e(env, cli.exe, gate_step);
    build_fuzz(env);
    const dst_tests = build_dst(env, test_step);
    build_bench(env, cli.exe, test_step);
    build_git_import_bench(env, test_step);
    build_smoke(env, cli.exe, test_step, gate_step);
    build_cov(env, &.{ mod_tests, cli.tests, dst_tests });
}

/// The unit-test compile of root.zig. A *separate* module from the exported
/// `zit` one, purely so the repository files that src/packaging_test.zig pins
/// (LICENSE, the package manifest, the versioned ignore-glob) can be reached
/// as anonymous imports without attaching them to the public module.
///
/// Attaching them to `zit` itself made every consumer's build depend on files
/// outside `build.zig.zon`'s `.paths` — a fetched package then failed with
/// `failed to check cache: '.fossil-settings/ignore-glob' file_hash
/// FileNotFound` before compiling a line. The need is test-only, so the scope
/// is test-only.
fn build_unit_test_module(
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    optimize: std.builtin.OptimizeMode,
) *std.Build.Module {
    const test_mod = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });
    test_mod.addAnonymousImport("license_text", .{ .root_source_file = b.path("LICENSE") });
    test_mod.addAnonymousImport("build_manifest", .{ .root_source_file = b.path("build.zig.zon") });
    test_mod.addAnonymousImport("ignore_glob", .{ .root_source_file = b.path(".fossil-settings/ignore-glob") });
    test_mod.addAnonymousImport("build_source", .{ .root_source_file = b.path("build.zig") });
    test_mod.addAnonymousImport("ci_workflow", .{ .root_source_file = b.path(".github/workflows/ci.yml") });

    // The repository root, handed to the tests as a constant. src/packaging_test.zig
    // asks fossil which files are version-controlled, and a test binary's working
    // directory is not the checkout; without this the query has nowhere to run.
    // Embedding a *file* cannot answer the question — whether a path is tracked is
    // a property of the repository, not of the working tree.
    //
    // A null `path` gets a panic rather than a "." fallback. "." is the test
    // binary's working directory, not the checkout, so the fallback would not
    // degrade the pin to a wrong answer — it would degrade it to a *silent
    // skip*, since `fossil ls` outside a checkout fails and the pin treats that
    // as "no fossil here". A configure-time panic naming the cause is the
    // legible failure; a pin that quietly stops checking is not.
    const repo = b.addOptions();
    const repo_root = b.build_root.path orelse
        @panic("build.zig: build root has no path; src/packaging_test.zig's version-control " ++
            "pin needs the checkout directory and would otherwise skip itself into silence");
    repo.addOption([]const u8, "root", repo_root);
    test_mod.addOptions("repo_options", repo);

    return test_mod;
}

/// build_cli's two artifacts: the installed `zit` exe (the e2e harness spawns
/// it) and its unit-test compile (the out-of-band cov step traces it).
const Cli = struct {
    exe: *std.Build.Step.Compile,
    tests: *std.Build.Step.Compile,
};

/// The `zit` command-line tool: `zig build` installs it, `zig build run --
/// <args>` runs it, and its own unit tests join `test_step`.
fn build_cli(env: Env, test_step: *std.Build.Step) Cli {
    const exe = env.b.addExecutable(.{
        .name = "zit",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/main.zig"),
            .target = env.target,
            .optimize = env.optimize,
            .imports = &.{
                .{ .name = "zit", .module = env.mod },
            },
        }),
    });
    env.b.installArtifact(exe);

    // `zig build run -- <args>`
    const run_step = env.b.step("run", "Run the zit CLI");
    const run_cmd = env.b.addRunArtifact(exe);
    run_step.dependOn(&run_cmd.step);
    run_cmd.step.dependOn(env.b.getInstallStep());
    if (env.b.args) |args| run_cmd.addArgs(args);

    const exe_tests = env.b.addTest(.{ .root_module = exe.root_module });
    test_step.dependOn(&env.b.addRunArtifact(exe_tests).step);

    return .{ .exe = exe, .tests = exe_tests };
}

/// `zig build e2e` — end-to-end tests that spawn the REAL `zit` binary in an
/// isolated, pinned environment and read the resulting repository back through
/// the library. The exe's path is threaded in as a build option (a `LazyPath`,
/// so the binary is always rebuilt before the tests run); the `zit` module is
/// imported so the tests can decode `.zit`/`.zit-checkout` state.
///
/// The subprocess-driven run stays out of `zig build test` (it spawns the real
/// binary ~150 times), but the test binary is *compiled* by `test` so this
/// 4.6k-line file cannot silently rot — a type error here fails `test`, at no
/// execution cost.
fn build_e2e(env: Env, exe: *std.Build.Step.Compile, gate_step: *std.Build.Step) void {
    const opts = env.b.addOptions();
    opts.addOptionPath("zit_exe", exe.getEmittedBin());

    const filter: ?[]const u8 = blk: {
        const args = env.b.args orelse break :blk null;
        if (args.len == 2 and std.mem.eql(u8, args[0], "--test-filter")) break :blk args[1];
        if (args.len == 1 and std.mem.startsWith(u8, args[0], "--test-filter=")) break :blk args[0]["--test-filter=".len..];
        break :blk null;
    };
    const e2e_mod = env.b.createModule(.{
        .root_source_file = env.b.path("src/e2e.zig"),
        .target = env.target,
        .optimize = env.optimize,
        .imports = &.{
            .{ .name = "zit", .module = env.mod },
            .{ .name = "build_options", .module = opts.createModule() },
        },
    });
    const e2e_tests = env.b.addTest(.{ .root_module = e2e_mod, .filters = if (filter) |value| &.{value} else &.{} });

    // Compile-only gate: `test` builds the e2e binary but never runs it.
    gate_step.dependOn(&e2e_tests.step);

    const e2e_run = env.b.addRunArtifact(e2e_tests);
    const e2e_step = env.b.step("e2e", "End-to-end tests: spawn the real zit binary");
    e2e_step.dependOn(&e2e_run.step);
}

/// `zig build git-import-e2e -Dgit-import-e2e-filter=<fixture>` — real-Git
/// differential tests for `import-git`.  Filtering is compiled into the Zig
/// test artifact, rather than forwarded to its runtime argument parser.
/// Like the general e2e binary its compile is proved by `gate` rather than by
/// `test`, while process execution remains opt-in; the emitted CLI path is a
/// LazyPath, so the test cannot accidentally use a stale binary.
fn build_git_export_e2e(env: Env, exe: *std.Build.Step.Compile, gate_step: *std.Build.Step) void {
    const opts = env.b.addOptions();
    opts.addOptionPath("zit_exe", exe.getEmittedBin());
    const test_mod = env.b.createModule(.{
        .root_source_file = env.b.path("src/git/git_export_e2e.zig"),
        .target = env.target,
        .optimize = env.optimize,
        .imports = &.{
            .{ .name = "zit", .module = env.mod },
            .{ .name = "build_options", .module = opts.createModule() },
        },
    });
    const tests = env.b.addTest(.{ .root_module = test_mod });
    gate_step.dependOn(&tests.step);

    const run = env.b.addRunArtifact(tests);
    const step = env.b.step("git-export-e2e", "Run real-Git export differential tests");
    step.dependOn(&run.step);
}

fn build_git_import_e2e(env: Env, exe: *std.Build.Step.Compile, gate_step: *std.Build.Step) void {
    const opts = env.b.addOptions();
    opts.addOptionPath("zit_exe", exe.getEmittedBin());
    const filter = env.b.option([]const u8, "git-import-e2e-filter", "Compile only matching real-Git import E2E tests");
    const test_mod = env.b.createModule(.{
        .root_source_file = env.b.path("src/git/git_import_e2e.zig"),
        .target = env.target,
        .optimize = env.optimize,
        .imports = &.{
            .{ .name = "zit", .module = env.mod },
            .{ .name = "build_options", .module = opts.createModule() },
        },
    });
    const tests = env.b.addTest(.{
        .root_module = test_mod,
        .filters = if (filter) |value| &.{value} else &.{},
    });
    gate_step.dependOn(&tests.step);

    const run = env.b.addRunArtifact(tests);
    const step = env.b.step("git-import-e2e", "Run real-Git import differential tests");
    step.dependOn(&run.step);
}

/// `zig build fuzz -- <target> [--seed N] [--rounds N]` — seeded deterministic
/// fuzz targets, driven in a loop by an out-of-band runner; not part of `test`.
fn build_fuzz(env: Env) void {
    const fuzz_exe = env.b.addExecutable(.{
        .name = "fuzz",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/fuzz_tests.zig"),
            .target = env.target,
            .optimize = env.optimize,
            .imports = &.{
                .{ .name = "zit", .module = env.mod },
            },
        }),
    });
    const fuzz_step = env.b.step("fuzz", "Run a seeded fuzz target (zig build fuzz -- <target> --seed N)");
    const fuzz_run = env.b.addRunArtifact(fuzz_exe);
    if (env.b.args) |args| fuzz_run.addArgs(args);
    fuzz_step.dependOn(&fuzz_run.step);
}

/// `zig build dst [-- --seed N | --seeds K] [--steps M]` — deterministic
/// simulation of the repostate derivation. No args runs the fixed smoke
/// sweep (seeds 1..25 x 10_000 steps); deep sweeps pass `--seeds`.
///
/// dst.zig's module reaches sim/*.zig (kept out of the public `zit` module);
/// running it as a test binary is how those sibling harness files' inline unit
/// tests join `zig build test`.
fn build_dst(env: Env, test_step: *std.Build.Step) *std.Build.Step.Compile {
    const dst_exe = env.b.addExecutable(.{
        .name = "dst",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/dst.zig"),
            .target = env.target,
            .optimize = env.optimize,
            .imports = &.{
                .{ .name = "zit", .module = env.mod },
            },
        }),
    });
    const dst_step = env.b.step("dst", "Deterministic simulation (default smoke sweep; zig build dst -- --seed N)");
    const dst_run = env.b.addRunArtifact(dst_exe);
    if (env.b.args) |args| dst_run.addArgs(args);
    dst_step.dependOn(&dst_run.step);

    const dst_tests = env.b.addTest(.{ .root_module = dst_exe.root_module });
    test_step.dependOn(&env.b.addRunArtifact(dst_tests).step);

    return dst_tests;
}

/// `zig build bench -- <put|read|open|sync|submanifest> [flags]` — LogStore
/// throughput, sync timing, and reproducible clone-shape measurement. Pinned to
/// ReleaseSafe: the mode that ships, so a measurement describes the real binary
/// rather than one with every assertion compiled out.
/// The executable reaches the store layers through the `zit` module; its pure
/// corpus tests join `zig build test` in Debug (no timing loop ever runs under
/// `test`).
///
/// The `serve` subcommand spawns the REAL `zit` binary (the #13 listen-before-
/// prime probe), so the exe's path is threaded in as a build option — a
/// `LazyPath`, so the binary is always rebuilt before the bench runs — exactly
/// as e2e/smoke do. The option module is shared by the bench exe and its test
/// compile so `serve_benchmark.zig` resolves `build_options.zit_exe` in both.
fn build_bench(env: Env, _: *std.Build.Step.Compile, test_step: *std.Build.Step) void {
    const bench_zit = env.b.addExecutable(.{
        .name = "zit-bench",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/main.zig"),
            .target = env.target,
            .optimize = .ReleaseSafe,
            .imports = &.{.{ .name = "zit", .module = env.mod }},
        }),
    });
    const opts = env.b.addOptions();
    opts.addOptionPath("zit_exe", bench_zit.getEmittedBin());
    const bench_imports = [_]std.Build.Module.Import{
        .{ .name = "zit", .module = env.mod },
        .{ .name = "build_options", .module = opts.createModule() },
    };
    const bench_exe = env.b.addExecutable(.{
        .name = "bench",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/logstore_benchmark.zig"),
            .target = env.target,
            .optimize = .ReleaseSafe,
            .imports = &bench_imports,
        }),
    });
    const bench_step = env.b.step("bench", "LogStore + clone benchmarks (zig build bench -- <put|read|open|sync|submanifest> [flags])");
    const bench_run = env.b.addRunArtifact(bench_exe);
    if (env.b.args) |args| bench_run.addArgs(args);
    bench_step.dependOn(&bench_run.step);

    const bench_tests = env.b.addTest(.{ .root_module = env.b.createModule(.{
        .root_source_file = env.b.path("src/logstore_benchmark.zig"),
        .target = env.target,
        .optimize = env.optimize,
        .imports = &bench_imports,
    }) });
    test_step.dependOn(&env.b.addRunArtifact(bench_tests).step);
}

/// `zig build import-git-bench -- [flags]` — bounded native fast-export
/// translation controls. Deliberately opt-in: its full-corpus mode is a
/// qualification workload, not an ordinary unit-test dependency.
fn build_git_import_bench(env: Env, test_step: *std.Build.Step) void {
    const opts = env.b.addOptions();
    opts.addOption([]const u8, "revision", env.b.option([]const u8, "revision", "Source revision recorded by import-git-bench") orelse "unknown");
    const exe = env.b.addExecutable(.{
        .name = "import-git-bench",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/git/git_import_benchmark.zig"),
            .target = env.target,
            .optimize = .ReleaseSafe,
            .imports = &.{
                .{ .name = "build_options", .module = opts.createModule() },
                .{ .name = "zit", .module = env.mod },
            },
        }),
    });
    const step = env.b.step("import-git-bench", "Bounded native Git import scaling benchmark");
    const run = env.b.addRunArtifact(exe);
    if (env.b.args) |args| run.addArgs(args);
    step.dependOn(&run.step);

    const tests = env.b.addTest(.{ .root_module = env.b.createModule(.{
        .root_source_file = env.b.path("src/git/git_import_benchmark.zig"),
        .target = env.target,
        .optimize = env.optimize,
        .imports = &.{.{ .name = "zit", .module = env.mod }},
    }) });
    test_step.dependOn(&env.b.addRunArtifact(tests).step);
}

/// `zig build smoke` — the one sanctioned use of the real `fossil` binary:
/// confirms zit-produced artifacts/deltas against fossil's own
/// parser/hasher/store. Confined to this step; never part of `test` or
/// pre-commit. `zig build test` still compiles smoke.zig (catching type
/// errors) without running its fossil-driving scenarios.
fn build_smoke(env: Env, exe: *std.Build.Step.Compile, test_step: *std.Build.Step, gate_step: *std.Build.Step) void {
    // Thread the real `zit` binary's path in as a build option (a `LazyPath`, so
    // it is always rebuilt first), same as e2e: S5 spawns `zit serve`/`pack` as
    // subprocesses and clones from them with the real `fossil` client.
    const opts = env.b.addOptions();
    opts.addOptionPath("zit_exe", exe.getEmittedBin());

    const smoke_exe = env.b.addExecutable(.{
        .name = "smoke",
        .root_module = env.b.createModule(.{
            .root_source_file = env.b.path("src/smoke.zig"),
            .target = env.target,
            .optimize = env.optimize,
            .imports = &.{
                .{ .name = "zit", .module = env.mod },
                .{ .name = "build_options", .module = opts.createModule() },
            },
        }),
    });
    gate_step.dependOn(&smoke_exe.step);

    // The scenarios need a real `fossil` and stay out of `test`, but smoke.zig's
    // pure REPORT ORACLES (the helpers that decide pass/fail from fossil's stdout)
    // are ordinary string code and are unit-tested here: an oracle that cannot
    // fail is worse than no oracle, and only a test says which it is. Runs no
    // subprocess — only the `test` blocks in this file execute.
    const smoke_tests = env.b.addTest(.{ .root_module = env.b.createModule(.{
        .root_source_file = env.b.path("src/smoke.zig"),
        .target = env.target,
        .optimize = env.optimize,
        .imports = &.{
            .{ .name = "zit", .module = env.mod },
            .{ .name = "build_options", .module = opts.createModule() },
        },
    }) });
    test_step.dependOn(&env.b.addRunArtifact(smoke_tests).step);

    const smoke_step = env.b.step("smoke", "Fossil interop smoke check (milestone-manual)");
    const smoke_run = env.b.addRunArtifact(smoke_exe);
    if (env.b.args) |args| smoke_run.addArgs(args);
    smoke_step.dependOn(&smoke_run.step);
}

/// Reads kcov's merged `coverage.json` and prints a `src/`-only per-file line
/// summary, ascending by coverage so the least-tested regions surface first.
const cov_summary_py =
    \\import json,sys
    \\d=json.load(open(sys.argv[1]))
    \\for f in sorted(d["files"],key=lambda f:float(f["percent_covered"])):
    \\    print(f"{float(f['percent_covered']):6.2f}%  {f['covered_lines']:>5}/{f['total_lines']:<5}  {f['file'].split('/zit/')[-1]}")
    \\print(f"total {d['percent_covered']}%   html: zig-out/cov/kcov-merged/index.html")
;

/// `zig build cov` — merged line coverage over every unit-test binary via
/// kcov (`brew install kcov`). Out-of-band: never part of `test`/pre-commit,
/// so untested regions surface mechanically without gating a commit.
fn build_cov(env: Env, test_bins: []const *std.Build.Step.Compile) void {
    const b = env.b;
    const merge = b.addSystemCommand(&.{ "kcov", "--merge" });
    const merged = merge.addOutputDirectoryArg("cov");
    for (test_bins) |t| {
        const run = b.addSystemCommand(&.{"kcov"});
        run.addArg(b.fmt("--include-pattern={s}/", .{b.pathFromRoot("src")}));
        merge.addDirectoryArg(run.addOutputDirectoryArg("kcov"));
        run.addArtifactArg(t);
    }
    const install = b.addInstallDirectory(.{
        .source_dir = merged,
        .install_dir = .prefix,
        .install_subdir = "cov",
    });
    const summary = b.addSystemCommand(&.{ "python3", "-c", cov_summary_py });
    summary.addFileArg(merged.path(b, "kcov-merged/coverage.json"));
    summary.step.dependOn(&install.step);
    b.step("cov", "Merged kcov line coverage (out-of-band)").dependOn(&summary.step);
}