From dee910c33a59245f01150bddc5a0a8c08494e154 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 27 Feb 2026 10:41:22 -0800 Subject: [PATCH] make sure cli/tui are handled by zig build test and augment coverage step for multiple modules --- build.zig | 48 ++++++++--- build/Coverage.zig | 210 ++++++++++++++++++++++++++------------------- src/tui/main.zig | 6 ++ 3 files changed, 165 insertions(+), 99 deletions(-) diff --git a/build.zig b/build.zig index 13f6275..566e368 100644 --- a/build.zig +++ b/build.zig @@ -32,7 +32,7 @@ pub fn build(b: *std.Build) void { }, }); - // Shared TUI imports (used by the TUI module and its tests) + // Shared imports for TUI and CLI modules const tui_imports: []const std.Build.Module.Import = &.{ .{ .name = "zfin", .module = mod }, .{ .name = "srf", .module = srf_mod }, @@ -40,13 +40,18 @@ pub fn build(b: *std.Build) void { .{ .name = "z2d", .module = z2d_dep.module("z2d") }, }; - // TUI module (imported by the unified binary) const tui_mod = b.addModule("tui", .{ .root_source_file = b.path("src/tui/main.zig"), .target = target, .imports = tui_imports, }); + const cli_imports: []const std.Build.Module.Import = &.{ + .{ .name = "zfin", .module = mod }, + .{ .name = "srf", .module = srf_mod }, + .{ .name = "tui", .module = tui_mod }, + }; + // Unified executable (CLI + TUI in one binary) const exe = b.addExecutable(.{ .name = "zfin", @@ -54,11 +59,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/cli/main.zig"), .target = target, .optimize = optimize, - .imports = &.{ - .{ .name = "zfin", .module = mod }, - .{ .name = "srf", .module = srf_mod }, - .{ .name = "tui", .module = tui_mod }, - }, + .imports = cli_imports, }), }); b.installArtifact(exe); @@ -72,15 +73,13 @@ pub fn build(b: *std.Build) void { run_cmd.addArgs(args); } - // Tests + // Tests: Zig test discovery doesn't cross module boundaries, so each + // module (lib, TUI, CLI) needs its own test target. const test_step = b.step("test", "Run all tests"); const mod_tests = b.addTest(.{ .root_module = mod }); test_step.dependOn(&b.addRunArtifact(mod_tests).step); - const exe_tests = b.addTest(.{ .root_module = exe.root_module }); - test_step.dependOn(&b.addRunArtifact(exe_tests).step); - const tui_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/tui/main.zig"), .target = target, @@ -89,6 +88,14 @@ pub fn build(b: *std.Build) void { }) }); test_step.dependOn(&b.addRunArtifact(tui_tests).step); + const cli_tests = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/cli/main.zig"), + .target = target, + .optimize = optimize, + .imports = cli_imports, + }) }); + test_step.dependOn(&b.addRunArtifact(cli_tests).step); + // Docs const lib = b.addLibrary(.{ .name = "zfin", @@ -108,6 +115,21 @@ pub fn build(b: *std.Build) void { .install_subdir = "docs", }).step); - // Coverage: `zig build coverage` (Linux only, uses kcov) - _ = Coverage.addCoverageStep(b, mod, "zfin"); + // Coverage: `zig build coverage` (uses kcov, Linux x86_64/aarch64 only) + { + const cov = Coverage.init(b); + _ = cov.addModule(mod, "zfin-lib"); + _ = cov.addModule(b.createModule(.{ + .root_source_file = b.path("src/tui/main.zig"), + .target = target, + .optimize = optimize, + .imports = tui_imports, + }), "zfin-tui"); + _ = cov.addModule(b.createModule(.{ + .root_source_file = b.path("src/cli/main.zig"), + .target = target, + .optimize = optimize, + .imports = cli_imports, + }), "zfin-cli"); + } } diff --git a/build/Coverage.zig b/build/Coverage.zig index a0c97e9..b40c2a9 100644 --- a/build/Coverage.zig +++ b/build/Coverage.zig @@ -4,118 +4,153 @@ const Build = std.Build; const Coverage = @This(); -/// Adds test coverage. This will create a new test coverage executable to the -/// build graph, generated only if coverage is a target. It will create an -/// option -Dcoverage-threshold that will fail the build if the threshold is -/// not met. It will also add a step that downloads a zig fork of the kcov -/// executable into zig cache if it doesn't already exist +/// 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. /// -/// Because it is creating a new test executable from the root module provided, +/// 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 this function -pub fn addCoverageStep(b: *Build, root_module: *Build.Module, coverage_name: []const u8) *Coverage { - //verify host requirements - { - const supported = builtin.os.tag == .linux and - (builtin.cpu.arch == .x86_64 or builtin.cpu.arch == .aarch64); - - if (!supported) - @panic("Coverage only supported on x86_64-linux or aarch64-linux"); - } - +/// 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 do it directly because we are sandboxed during build, but - // we can create a program and run that program. First we need the destination - // directory - const kcov = blk: { - const arch_name = switch (builtin.cpu.arch) { - .x86_64 => "x86_64", - .aarch64 => "aarch64", - else => unreachable, - }; - - 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_name = b.fmt("kcov-{s}", .{arch_name}); - break :blk .{ .path = b.pathJoin(&.{ cache_dir, kcov_name }), .arch = arch_name }; + // 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), }; - // Create download and coverage build steps - return blk: { - 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 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 run_download = b.addRunArtifact(download_exe); - run_download.addArg(kcov.path); - run_download.addArg(kcov.arch); + const cache_dir = b.pathJoin(&.{ + b.cache_root.path.?, + "o", + b.fmt("{s}", .{std.fmt.bytesToHex(cache_hash, .lower)}), + }); - const run_coverage = b.addSystemCommand(&.{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(coverage_dir); - const test_exe = b.addTest(.{ - .name = coverage_name, - .root_module = root_module, - // we need to set the test exe to use llvm as the self hosted backend - // does not support the data kcov needs - .use_llvm = true, - }); - run_coverage.addArtifactArg(test_exe); - run_coverage.step.dependOn(&test_exe.step); - run_coverage.step.dependOn(&run_download.step); + const kcov_path = b.pathJoin(&.{ cache_dir, b.fmt("kcov-{s}", .{arch_name}) }); - const json_path = b.fmt("{s}/{s}/coverage.json", .{ coverage_dir, coverage_name }); - const verbose = b.option(bool, "coverage-verbose", "Show test coverage for each file") orelse false; - const check_step = create(b, test_exe, json_path, coverage_threshold, verbose); - check_step.step.dependOn(&run_coverage.step); - coverage_step.dependOn(&check_step.step); - break :blk check_step; + // 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, }; } -step: Build.Step, -json_path: []const u8, -threshold: u7, -test_exe: *std.Build.Step.Compile, -verbose: bool, +/// 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; -pub fn create(owner: *Build, test_exe: *std.Build.Step.Compile, xml_path: []const u8, threshold: u7, verbose: bool) *Coverage { + // 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 = xml_path, + .json_path = json_path, .threshold = threshold, .test_exe = test_exe, - .verbose = verbose, }; return check; } @@ -144,6 +179,9 @@ const File = struct { } }; +/// 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); @@ -162,7 +200,7 @@ fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { 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 or check.verbose) { + if (step.owner.verbose) { const files = coverage.files; std.mem.sort(File, files, files, File.coverageLessThanDesc); for (files) |f| diff --git a/src/tui/main.zig b/src/tui/main.zig index 7c756a0..446cbec 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -3866,6 +3866,12 @@ fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { } } +// Force test discovery for imported TUI sub-modules +comptime { + _ = keybinds; + _ = theme_mod; +} + /// Entry point for the interactive TUI. pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void { var portfolio_path: ?[]const u8 = null;