const builtin = @import("builtin"); const std = @import("std"); const Build = std.Build; const Coverage = @This(); /// Whether the host platform supports kcov-based coverage. /// Only x86_64 and aarch64 Linux are supported (kcov binary availability). /// On unsupported platforms, the coverage step will fail at runtime with /// a clear error from the kcov download or execution step. // pub const supported = builtin.os.tag == .linux and // (builtin.cpu.arch == .x86_64 or builtin.cpu.arch == .aarch64); /// Initialize coverage infrastructure. Creates the "coverage" build step, /// registers build options (-Dcoverage-threshold, -Dcoverage-dir), /// and sets up the kcov download step. The kcov binary is downloaded into the /// zig cache on first use and reused thereafter. /// /// Use `zig build coverage --verbose` to see per-file coverage breakdown. /// /// Call `addModule()` on the returned value to add the test module to the /// coverage run. /// /// Because addModule creates a new test executable from the root module provided, /// if there are any linking steps being done to your test executable, those /// must also be done to the test_exe returned by addModule. pub fn init(b: *Build) Coverage { // Add options const coverage_threshold = b.option(u7, "coverage-threshold", "Minimum coverage percentage required") orelse 0; const coverage_dir = b.option([]const u8, "coverage-dir", "Coverage output directory") orelse b.pathJoin(&.{ b.build_root.path orelse ".", "coverage" }); const coverage_step = b.step("coverage", "Generate test coverage report"); // Set up kcov download. // We can't download directly because we are sandboxed during build, but // we can create a helper program and run it. First we need the destination // directory, keyed by architecture. const arch_name = switch (builtin.cpu.arch) { .x86_64 => "x86_64", .aarch64 => "aarch64", else => @tagName(builtin.cpu.arch), }; const Algo = std.crypto.hash.sha2.Sha256; var hasher = Algo.init(.{}); hasher.update("kcov-"); hasher.update(arch_name); var cache_hash: [Algo.digest_length]u8 = undefined; hasher.final(&cache_hash); const cache_dir = b.pathJoin(&.{ b.cache_root.path.?, "o", b.fmt("{s}", .{std.fmt.bytesToHex(cache_hash, .lower)}), }); const kcov_path = b.pathJoin(&.{ cache_dir, b.fmt("kcov-{s}", .{arch_name}) }); // Create the download helper executable const download_exe = b.addExecutable(.{ .name = "download-kcov", .root_module = b.createModule(.{ .root_source_file = b.path("build/download_kcov.zig"), .target = b.resolveTargetQuery(.{}), }), }); const run_download = b.addRunArtifact(download_exe); run_download.addArg(kcov_path); run_download.addArg(arch_name); return .{ .b = b, .coverage_step = coverage_step, .coverage_dir = coverage_dir, .coverage_threshold = coverage_threshold, .kcov_path = kcov_path, .run_download = run_download, }; } /// Add a test module to the coverage run. Runs kcov on the test binary, /// then reads the coverage JSON and prints a summary (with per-file /// breakdown if --verbose). Fails if below -Dcoverage-threshold. /// /// Returns the test executable so the caller can add any extra linking steps. pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile { const b = self.b; // Set up kcov run: filter to src/ only, use custom CSS for HTML report const run_coverage = b.addSystemCommand(&.{self.kcov_path}); const include_path = b.pathJoin(&.{ b.build_root.path.?, "src" }); run_coverage.addArgs(&.{ "--include-path", include_path }); const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" }); run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file})); run_coverage.addArg(self.coverage_dir); // Create a test executable for this module. // We need to set use_llvm because the self-hosted backend // does not emit the DWARF data that kcov needs. const test_exe = b.addTest(.{ .name = name, .root_module = root_module, .use_llvm = true, }); run_coverage.addArtifactArg(test_exe); run_coverage.step.dependOn(&test_exe.step); run_coverage.step.dependOn(&self.run_download.step); // Wire up the threshold check step after kcov completes const check = b.allocator.create(Coverage) catch @panic("OOM"); check.* = .{ .b = b, .coverage_step = undefined, .coverage_dir = undefined, .coverage_threshold = undefined, .kcov_path = undefined, .run_download = undefined, .step = Build.Step.init(.{ .id = .custom, .name = "check coverage", .owner = b, .makeFn = make, }), .json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name }), .threshold = self.coverage_threshold, }; check.step.dependOn(&run_coverage.step); self.coverage_step.dependOn(&check.step); return test_exe; } // ── Coverage struct fields ────────────────────────────────── // Fields used by init() to configure the shared coverage infrastructure b: *Build, coverage_step: *Build.Step, coverage_dir: []const u8, coverage_threshold: u7, kcov_path: []const u8, run_download: *Build.Step.Run, // Fields used by make() for the threshold check (set by addModule) step: Build.Step = undefined, json_path: []const u8 = "", threshold: u7 = 0, // This must be kept in step with kcov per-binary coverage.json format const CoverageReport = struct { files: []const CoverageFile, }; const CoverageFile = struct { file: []const u8, covered_lines: usize, total_lines: usize, }; const File = struct { file: []const u8, percent_covered: f64, covered_lines: usize, total_lines: usize, pub fn coverageLessThanDesc(_: void, lhs: File, rhs: File) bool { return lhs.percent_covered > rhs.percent_covered; } }; /// Build step make function: reads kcov JSON output, prints a summary /// (with per-file breakdown if verbose), and fails if below threshold. fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { _ = options; const check: *Coverage = @fieldParentPtr("step", step); const allocator = step.owner.allocator; const file = std.fs.cwd().openFile(check.json_path, .{}) catch |err| { return step.fail("Failed to open coverage report {s}: {}", .{ check.json_path, err }); }; defer file.close(); const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); defer allocator.free(content); const json = std.json.parseFromSlice(CoverageReport, allocator, content, .{ .ignore_unknown_fields = true, }) catch |err| { return step.fail("Failed to parse coverage JSON: {}", .{err}); }; defer json.deinit(); var total_covered: usize = 0; var total_lines: usize = 0; var file_list = std.ArrayList(File).empty; defer file_list.deinit(allocator); for (json.value.files) |f| { const pct: f64 = if (f.total_lines > 0) @as(f64, @floatFromInt(f.covered_lines)) / @as(f64, @floatFromInt(f.total_lines)) * 100.0 else 0; try file_list.append(allocator, .{ .file = f.file, .covered_lines = f.covered_lines, .total_lines = f.total_lines, .percent_covered = pct, }); total_covered += f.covered_lines; total_lines += f.total_lines; } std.mem.sort(File, file_list.items, {}, File.coverageLessThanDesc); var stdout_buffer: [1024]u8 = undefined; var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); const stdout = &stdout_writer.interface; if (step.owner.verbose) { for (file_list.items) |f| { try stdout.print( "{d: >5.1}% {d: >5}/{d: <5}:{s}\n", .{ f.percent_covered, f.covered_lines, f.total_lines, f.file }, ); } } const total_pct: f64 = if (total_lines > 0) @as(f64, @floatFromInt(total_covered)) / @as(f64, @floatFromInt(total_lines)) * 100.0 else 0; try stdout.print( "Total test coverage: {d:.2}% ({d}/{d})\n", .{ total_pct, total_covered, total_lines }, ); try stdout.flush(); if (@as(u7, @intFromFloat(@floor(total_pct))) < check.threshold) return step.fail("Coverage {d:.2}% is below threshold {d}%", .{ total_pct, check.threshold }); }