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 test modules to the coverage run. /// Each module gets its own kcov invocation, threshold check, and output subdirectory. /// /// 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. Each module gets its own kcov /// invocation and threshold check, all wired into the shared "coverage" step. /// 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 (reads coverage.json after kcov finishes) const json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name }); const check_step = create(b, test_exe, json_path, self.coverage_threshold); check_step.step.dependOn(&run_coverage.step); self.coverage_step.dependOn(&check_step.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 per-module threshold checking. // These are only meaningful on instances created by create(), not by init(). step: Build.Step = undefined, json_path: []const u8 = "", threshold: u7 = 0, test_exe: *Build.Step.Compile = undefined, /// Create a coverage check step that reads the kcov JSON output after /// the coverage run completes and verifies the threshold is met. fn create(owner: *Build, test_exe: *Build.Step.Compile, json_path: []const u8, threshold: u7) *Coverage { const check = owner.allocator.create(Coverage) catch @panic("OOM"); check.* = .{ .b = owner, .coverage_step = undefined, .coverage_dir = "", .coverage_threshold = 0, .kcov_path = "", .run_download = undefined, .step = Build.Step.init(.{ .id = .custom, .name = "check coverage", .owner = owner, .makeFn = make, }), .json_path = json_path, .threshold = threshold, .test_exe = test_exe, }; return check; } // This must be kept in step with kcov coverage.json format const CoverageReport = struct { percent_covered: f64, covered_lines: usize, total_lines: usize, percent_low: u7, percent_high: u7, command: []const u8, date: []const u8, files: []File, }; const File = struct { file: []const u8, percent_covered: f64, covered_lines: usize, total_lines: usize, pub fn coverageLessThanDesc(context: []File, lhs: File, rhs: File) bool { _ = context; return lhs.percent_covered > rhs.percent_covered; } }; /// Build step make function: reads the kcov coverage.json output, /// prints summary (and per-file breakdown if verbose), and fails /// the build if coverage is below the configured 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 = try std.fs.cwd().openFile(check.json_path, .{}); defer file.close(); const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); defer allocator.free(content); const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{}); defer json.deinit(); const coverage = json.value; 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) { const files = coverage.files; std.mem.sort(File, files, files, File.coverageLessThanDesc); for (files) |f| try stdout.print( "{d: >5.1}% {d: >5}/{d: <5}:{s}\n", .{ f.percent_covered, f.covered_lines, f.total_lines, f.file }, ); } try stdout.print( "Total test coverage: {d}% ({d}/{d})\n", .{ coverage.percent_covered, coverage.covered_lines, coverage.total_lines }, ); try stdout.flush(); if (@as(u7, @intFromFloat(@floor(coverage.percent_covered))) < check.threshold) return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage.percent_covered, check.threshold }); }