diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg new file mode 100644 index 00000000..94b9f1d1 --- /dev/null +++ b/.github/badges/coverage.svg @@ -0,0 +1 @@ +coverage: 82.12%coverage82.12% \ No newline at end of file diff --git a/.gitignore b/.gitignore index 913b8390..38b38263 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .zig-cache/ zig-out/ +coverage/ # Override global gitignore — vendored SQLite is intentional !vendor/ diff --git a/README.md b/README.md index 2875f2f8..a4edb886 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ![macOS only](https://img.shields.io/badge/platform-macOS-blue) ![Zig 0.15.x](https://img.shields.io/badge/zig-0.15.x-orange) ![License](https://img.shields.io/badge/license-MIT-green) +![Coverage](.github/badges/coverage.svg) [![Built with Devbox](https://www.jetify.com/img/devbox/shield_galaxy.svg)](https://www.jetify.com/devbox/docs/contributor-quickstart/) malt is a macOS-only package manager written in Zig that consumes Homebrew's existing formula, bottle, cask, and tap ecosystem. It ships as a single binary (`malt`, ~3 MB) with sub-millisecond cold start. malt downloads pre-built bottles from the Homebrew infrastructure — it is a fast client for Homebrew's package registry, not a fork. Requires macOS 11 (Big Sur) or later on Apple Silicon or Intel. diff --git a/build.zig b/build.zig index ecdf6f1d..dd6becab 100644 --- a/build.zig +++ b/build.zig @@ -67,6 +67,7 @@ pub fn build(b: *std.Build) void { }; const test_step = b.step("test", "Run all unit tests"); + const test_bin_step = b.step("test-bin", "Install test binaries for coverage (kcov)"); // Shared library module — single root that re-exports all source modules. // This avoids "file exists in multiple modules" errors from Zig's module system. @@ -89,7 +90,12 @@ pub fn build(b: *std.Build) void { malt_lib.addOptions("version_string", version_options); inline for (test_modules) |test_file| { + // e.g. "tests/formula_test.zig" → "formula_test" (so each test binary + // has a unique install name, which `test-bin` / kcov need). + const test_name = comptime std.fs.path.stem(std.fs.path.basename(test_file)); + const t = b.addTest(.{ + .name = test_name, .root_module = b.createModule(.{ .root_source_file = b.path(test_file), .target = target, @@ -105,6 +111,13 @@ pub fn build(b: *std.Build) void { const run_t = b.addRunArtifact(t); test_step.dependOn(&run_t.step); + + // Only installed when the user asks for `zig build test-bin` + // (used by the coverage recipe and the coverage CI job). + const install_t = b.addInstallArtifact(t, .{ + .dest_dir = .{ .override = .{ .custom = "test-bin" } }, + }); + test_bin_step.dependOn(&install_t.step); } // --- Universal binary step (macOS only) --- diff --git a/justfile b/justfile index bbf47d82..8a678895 100644 --- a/justfile +++ b/justfile @@ -22,6 +22,74 @@ install: test: zig build test +# Run tests under kcov, print line-coverage percentage, and refresh +# the README badge SVG at .github/badges/coverage.svg. +# HTML report lands at coverage/merged/kcov-merged/index.html. +# Requires kcov (brew install kcov). Internet is needed for the badge fetch. +coverage: + #!/usr/bin/env bash + set -euo pipefail + if ! command -v kcov >/dev/null 2>&1; then + echo "error: kcov not found. Install with: brew install kcov" >&2 + exit 1 + fi + rm -rf coverage + mkdir -p coverage + zig build test-bin + # Only report coverage for files under the project's src/ directory. + # --include-path takes an absolute path and is more reliable than --include-pattern. + src_dir="$(pwd)/src" + # Run kcov once per test binary into a shared outdir + for bin in zig-out/test-bin/*; do + # Skip .dSYM debug bundles and any non-regular files + [ -f "$bin" ] || continue + [ -x "$bin" ] || continue + echo "→ kcov: $(basename "$bin")" + kcov --include-path="$src_dir" coverage "$bin" >/dev/null + done + # kcov 43 on macOS doesn't reliably auto-merge, so do it explicitly. + # The per-binary reports are in hash-suffixed dirs (e.g. cellar_test.a934ecd0). + shopt -s nullglob + per_bin_dirs=(coverage/*_test.*) + shopt -u nullglob + if [ ${#per_bin_dirs[@]} -eq 0 ]; then + echo "error: kcov produced no per-binary reports" >&2 + exit 1 + fi + kcov --merge coverage/merged "${per_bin_dirs[@]}" >/dev/null + report="coverage/merged/kcov-merged/coverage.json" + if [ ! -f "$report" ]; then + echo "error: merged report not found at $report" >&2 + exit 1 + fi + if command -v jq >/dev/null 2>&1; then + percent=$(jq -r '.percent_covered' "$report") + else + percent=$(grep -oE '"percent_covered"[^,}]*' "$report" | grep -oE '[0-9]+(\.[0-9]+)?' | head -1) + fi + # Pick a shields.io color based on the integer part of the percentage. + pct_int=${percent%.*} + if [ "$pct_int" -ge 90 ]; then color="brightgreen" + elif [ "$pct_int" -ge 80 ]; then color="green" + elif [ "$pct_int" -ge 70 ]; then color="yellowgreen" + elif [ "$pct_int" -ge 60 ]; then color="yellow" + elif [ "$pct_int" -ge 50 ]; then color="orange" + else color="red" + fi + # Fetch the static badge SVG from shields.io and commit it under .github/badges/. + # This keeps the README badge in-repo so it updates with normal commits — no CI needed. + mkdir -p .github/badges + badge_url="https://img.shields.io/badge/coverage-${percent}%25-${color}" + if curl -sSLf "$badge_url" -o .github/badges/coverage.svg; then + badge_msg=".github/badges/coverage.svg (refreshed — remember to commit it)" + else + badge_msg="warning: could not fetch badge from shields.io (offline?) — badge not refreshed" + fi + echo "" + echo "Coverage: ${percent}%" + echo "Report: coverage/merged/kcov-merged/index.html" + echo "Badge: ${badge_msg}" + # Check formatting fmt-check: zig fmt --check src/ tests/ @@ -39,6 +107,7 @@ lint: # Pre-commit hook: auto-format staged .zig files in place and re-stage them # so the formatted version is what actually lands in the commit. # Note: if you `git add -p` partial hunks of a file, this will pick up the + # unstaged hunks too — a known limitation of format-on-commit hooks. pre-commit: #!/usr/bin/env bash