Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-coverage/src/lib.rs

//! # nornir-coverage
//!
//! Source-based **line/region coverage**, maven-report style (feature N).
//!
//! Where `nornir::warehouse::surface_coverage` (AUT5) answers *"is every
//! testable surface reached by a test?"*, THIS module answers the orthogonal
//! question maven's JaCoCo report answers: *"what fraction of the **source
//! lines / regions** did the test run actually execute?"* — measured by
//! `cargo-llvm-cov` (LLVM source-based instrumentation).
//!
//! The flow mirrors the bencher:
//!
//! ```text
//!   cargo llvm-cov --json   →  parse  →  CoverageReport  →  warehouse rows
//!        (runner.rs)          (this)      (per crate/file/fn)   (coverage, coverage_fn)
//!                                              │
//!                                              ├─► docs `nornir:gen:coverage` table → book/PDF
//!                                              └─► viz 🧪 Test pane coverage panel + boundary view
//! ```
//!
//! ## The model (PURE — std + serde only, fully unit-testable)
//!
//! [`CoverageReport`] is the parsed, summarised report: an overall
//! line/region %, one [`CrateCoverage`] per crate, and one [`FileCoverage`] per
//! source file (each carrying its [`FnCoverage`] functions). It is built from
//! the llvm-cov JSON export by [`parse_llvm_cov_json`], so the whole pipeline is
//! testable by feeding a known JSON blob and asserting the percentages.
//!
//! ## Boundary coverage (the user's explicit ask — task #4)
//!
//! Each [`FnCoverage`] is tagged with a [`Boundary`] by **name/path** (the
//! layer-boundary-discoverability LAW): `ui`/`state_json`/`draw` fns under
//! `src/viz/` (UI), the named gRPC service handlers in the proto server (gRPC),
//! and the `emit*`/trace/`state_json` emitter fns (Emitter). [`boundary_fns`]
//! filters the report to just those, so the viz "boundary coverage" view can
//! prove the hard-to-cover ui/grpc/emitter surfaces are actually exercised.

pub mod diff;
pub mod runner;
pub mod selftest;

use serde::{Deserialize, Serialize};

// ─── the layer boundary a function sits on (task #4) ────────────────────────

/// Which layer boundary a function is, classified by name + file path per the
/// layer-boundary-discoverability LAW. `Core` = an ordinary (non-boundary) fn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Boundary {
    /// An `impl Facet`/viz UI function — `ui`, `draw`, `state_json` under `src/viz/`.
    Ui,
    /// A named gRPC service handler (an `async fn` in the proto server impl).
    Grpc,
    /// An emitter — `emit`/`emit_in`/`emit_out`/`emit_end`/`emit_event`,
    /// `$NORNIR_VIZ_TRACE` trace points, `state_json` (the readable-data emitters).
    Emitter,
    /// Not a layer boundary.
    Core,
}

impl Boundary {
    pub fn as_str(self) -> &'static str {
        match self {
            Boundary::Ui => "ui",
            Boundary::Grpc => "grpc",
            Boundary::Emitter => "emitter",
            Boundary::Core => "core",
        }
    }

    /// Is this a layer boundary the boundary-coverage view surfaces?
    pub fn is_boundary(self) -> bool {
        !matches!(self, Boundary::Core)
    }
}

/// Classify a function as a UI / gRPC / emitter boundary (or `Core`) from its
/// demangled name + the source file it lives in. PURE + name/path-driven so the
/// classification is discoverable and unit-testable (layer-boundary LAW).
///
/// * **gRPC** — the proto server's named handlers live in `*nornir-server*`
///   (the tonic service impl); a handler is an `async fn` there. We can't see
///   `async` in the name, so we treat any function in the server binary that is
///   NOT an obvious helper as a handler candidate, biased by known verb names.
/// * **UI** — a `ui` / `draw` / `draw_*` / `state_json` fn under `src/viz/`.
/// * **Emitter** — `emit` / `emit_in|out|end|event` / `*::trace::*` / `state_json`
///   anywhere (the readable-data + `$NORNIR_VIZ_TRACE` emitters).
pub fn classify(fn_name: &str, file: &str) -> Boundary {
    let leaf = fn_name.rsplit("::").next().unwrap_or(fn_name);
    let file = file.replace('\\', "/");

    // gRPC service handlers — the proto server impl. The handler set is the
    // named verbs of the tonic service; they live in the server binary.
    if file.contains("nornir-server") {
        // Known handler verbs (the Bench/Test/Viz/etc. RPCs). Anything matching
        // these is a counted gRPC surface even if other helpers share the file.
        const GRPC_VERBS: &[&str] = &[
            "test_results", "test_matrix", "bench_telemetry", "bench_history",
            "architecture", "run_test_matrix", "run_bench", "search", "coverage",
            "viz_state", "telemetry", "submit", "gate_all", "docs", "history",
            "deps_of", "dependents_of", "build_order", "affected", "dep_path",
        ];
        if GRPC_VERBS.iter().any(|v| leaf == *v || leaf.starts_with(v)) {
            return Boundary::Grpc;
        }
    }

    // Emitter functions (the readable-data + trace emitters), anywhere.
    if leaf == "state_json"
        || leaf == "emit"
        || leaf.starts_with("emit_")
        || leaf == "assert_emit"
        || file.contains("/trace.rs")
        || file.contains("/selftest.rs")
    {
        // `state_json` in a viz file is BOTH ui and emitter; we tag it Emitter
        // (it is the canonical readable-data emitter the LAW names) so the
        // boundary view shows the emitter surface explicitly.
        return Boundary::Emitter;
    }

    // UI functions — viz panes.
    if (file.contains("/viz/") || file.contains("/viz.rs"))
        && (leaf == "ui" || leaf == "draw" || leaf.starts_with("draw_") || leaf == "show")
    {
        return Boundary::Ui;
    }

    Boundary::Core
}

// ─── the summarised report (built from llvm-cov JSON) ───────────────────────

/// A single function's coverage, with its boundary tag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FnCoverage {
    /// Demangled function name (`nornir::viz::test_tab::TestTabState::ui`).
    pub name: String,
    /// The source file the function is defined in (repo-relative when possible).
    pub file: String,
    /// Executable lines in the function.
    pub lines: u64,
    /// Lines that were executed at least once.
    pub lines_covered: u64,
    /// Coverage regions in the function (LLVM region == branch-y line segment).
    pub regions: u64,
    /// Regions executed at least once.
    pub regions_covered: u64,
    /// The layer boundary this fn sits on (ui / grpc / emitter / core).
    pub boundary: Boundary,
    /// The first source line of the function's code (its first region's start),
    /// `0` = the export named none. With [`Self::file`] this is the key a reader
    /// joins a measured function to the item the compiler saw at that place —
    /// Mímir's code room keys its functions by `(repo, file, line span)`, and a
    /// demangled llvm name (crate hash, generic arguments) is no join key.
    #[serde(default)]
    pub line: u32,
    /// The last source line of the function's code (its regions' furthest end).
    #[serde(default)]
    pub end_line: u32,
}

impl FnCoverage {
    /// Line coverage %, 0.0..100.0 (100 when the fn has no executable lines).
    pub fn line_pct(&self) -> f64 {
        pct(self.lines_covered, self.lines)
    }
    /// Region coverage %, 0.0..100.0.
    pub fn region_pct(&self) -> f64 {
        pct(self.regions_covered, self.regions)
    }
    /// Was this function exercised at all (any covered region/line)?
    pub fn exercised(&self) -> bool {
        self.regions_covered > 0 || self.lines_covered > 0
    }
}

/// One source file's coverage, with its functions.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FileCoverage {
    pub file: String,
    /// The crate this file belongs to (derived from the workspace layout).
    pub krate: String,
    pub lines: u64,
    pub lines_covered: u64,
    pub regions: u64,
    pub regions_covered: u64,
    pub functions: Vec<FnCoverage>,
}

impl FileCoverage {
    pub fn line_pct(&self) -> f64 {
        pct(self.lines_covered, self.lines)
    }
    pub fn region_pct(&self) -> f64 {
        pct(self.regions_covered, self.regions)
    }
}

/// One crate's rolled-up coverage.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrateCoverage {
    pub krate: String,
    pub lines: u64,
    pub lines_covered: u64,
    pub regions: u64,
    pub regions_covered: u64,
    /// Number of files in the crate.
    pub files: u64,
}

impl CrateCoverage {
    pub fn line_pct(&self) -> f64 {
        pct(self.lines_covered, self.lines)
    }
    pub fn region_pct(&self) -> f64 {
        pct(self.regions_covered, self.regions)
    }
}

/// The whole parsed + summarised coverage report — the maven-report shape.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CoverageReport {
    /// The repo / workspace the report covers.
    pub repo: String,
    pub lines: u64,
    pub lines_covered: u64,
    pub regions: u64,
    pub regions_covered: u64,
    pub crates: Vec<CrateCoverage>,
    pub files: Vec<FileCoverage>,
}

impl CoverageReport {
    /// Overall line coverage %, 0.0..100.0.
    pub fn line_pct(&self) -> f64 {
        pct(self.lines_covered, self.lines)
    }
    /// Overall region coverage %, 0.0..100.0.
    pub fn region_pct(&self) -> f64 {
        pct(self.regions_covered, self.regions)
    }

    /// The N worst-covered files by line %, ascending (worst first). Files with
    /// zero executable lines are skipped (nothing to cover).
    pub fn worst_files(&self, n: usize) -> Vec<&FileCoverage> {
        let mut v: Vec<&FileCoverage> = self.files.iter().filter(|f| f.lines > 0).collect();
        v.sort_by(|a, b| {
            a.line_pct()
                .total_cmp(&b.line_pct())
                .then(b.lines.cmp(&a.lines))
        });
        v.truncate(n);
        v
    }

    /// Every boundary (ui/grpc/emitter) function across the report, sorted by
    /// boundary then name. This is what the viz "boundary coverage" view + the
    /// `boundary_fns` warehouse rows surface (task #4).
    pub fn boundary_fns(&self) -> Vec<&FnCoverage> {
        let mut v: Vec<&FnCoverage> = self
            .files
            .iter()
            .flat_map(|f| f.functions.iter())
            .filter(|f| f.boundary.is_boundary())
            .collect();
        v.sort_by(|a, b| a.boundary.cmp(&b.boundary).then(a.name.cmp(&b.name)));
        v
    }

    /// Roll the boundary functions up into a `(boundary → (total, exercised))`
    /// tally — the green-matrix proof the ui/grpc/emitter surfaces are tested.
    pub fn boundary_tally(&self) -> Vec<(Boundary, u64, u64)> {
        let mut out = Vec::new();
        for b in [Boundary::Ui, Boundary::Grpc, Boundary::Emitter] {
            let fns: Vec<&FnCoverage> =
                self.boundary_fns().into_iter().filter(|f| f.boundary == b).collect();
            let total = fns.len() as u64;
            let exercised = fns.iter().filter(|f| f.exercised()).count() as u64;
            out.push((b, total, exercised));
        }
        out
    }
}

/// Integer-ratio percentage helper, 0.0..100.0; 100 when `total == 0` (nothing
/// to cover is fully covered, matching llvm-cov's own convention).
fn pct(covered: u64, total: u64) -> f64 {
    if total == 0 {
        100.0
    } else {
        (covered as f64 / total as f64) * 100.0
    }
}

/// `i64`-typed percentage for the warehouse row layer (counts are stored as
/// `i64` there). Same convention as [`pct`]. Negative/garbage clamps to 0.
pub fn pct_i64(covered: i64, total: i64) -> f64 {
    if total <= 0 {
        100.0
    } else {
        (covered.max(0) as f64 / total as f64) * 100.0
    }
}

// ─── the persisted `coverage` row shape (plain serde POD) ───────────────────

/// One `coverage` row: an overall / per-crate / per-file rollup — the shape the
/// warehouse persists and the docs `nornir:gen:coverage` table renders.
///
/// Moved here from nornir's `warehouse::coverage` (which re-exports it
/// unchanged): the struct is a plain serde POD whose only logic is [`pct_i64`],
/// which already lives in this crate. The Iceberg/arrow I/O that reads and
/// writes these rows stays behind in nornir — this is just the shape, so the
/// warehouse-free doc assembly (`nornir-docgen`'s coverage section) can accept
/// pre-fetched rows without linking the warehouse.
///
/// `serde` derives let the server ship the exact rows to a thin viz client over
/// `Viz.Coverage` and the client decode them into the identical struct.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CoverageRow {
    pub run_id: String,
    pub ts_micros: i64,
    pub repo: String,
    /// `overall` | `crate:<name>` | `file:<path>`.
    pub scope: String,
    pub krate: String,
    pub file: String,
    pub lines: i64,
    pub lines_covered: i64,
    pub regions: i64,
    pub regions_covered: i64,
}

impl CoverageRow {
    pub fn line_pct(&self) -> f64 {
        pct_i64(self.lines_covered, self.lines)
    }
    pub fn region_pct(&self) -> f64 {
        pct_i64(self.regions_covered, self.regions)
    }
}

// ─── llvm-cov JSON parsing (PURE) ───────────────────────────────────────────
//
// `cargo llvm-cov --json` emits LLVM's `export` schema (version 2.x):
//   { "data": [ { "files": [ { "filename", "summary": { "lines": {count,covered},
//                                                        "regions": {...} } } ],
//                 "functions": [ { "name", "filenames": [..],
//                                  "regions": [[...],...],
//                                  "count": <execution count> } ] } ] }
// We read per-file summaries directly, and per-function region coverage from the
// `regions` array (each region row's element index 7 ≈ execution_count in the
// LLVM v2 schema; we conservatively treat count>0 as covered) plus the function
// `count`. Line counts per-function aren't in the export, so we attribute the
// file's lines proportionally is avoided — instead we report regions per fn
// (the maven-style "region %") and use the function `count` for exercised-ness.

#[derive(Deserialize)]
struct LlvmExport {
    data: Vec<LlvmData>,
}

#[derive(Deserialize)]
struct LlvmData {
    #[serde(default)]
    files: Vec<LlvmFile>,
    #[serde(default)]
    functions: Vec<LlvmFunction>,
}

#[derive(Deserialize)]
struct LlvmFile {
    filename: String,
    summary: LlvmSummary,
}

#[derive(Deserialize)]
struct LlvmSummary {
    lines: LlvmCounts,
    regions: LlvmCounts,
}

#[derive(Deserialize, Clone, Copy)]
struct LlvmCounts {
    count: u64,
    covered: u64,
}

#[derive(Deserialize)]
struct LlvmFunction {
    name: String,
    #[serde(default)]
    filenames: Vec<String>,
    /// Each region is an array; LLVM v2 puts the execution count at index 4.
    #[serde(default)]
    regions: Vec<Vec<i64>>,
    #[serde(default)]
    count: u64,
}

/// Parse the `cargo llvm-cov --json` export into a summarised [`CoverageReport`].
///
/// `repo` labels the report; `crate_of` maps a source file path to its crate
/// name (e.g. via the workspace layout) — pass [`crate_from_path`] for the
/// default heuristic. Files NOT under the repo (deps, std, the registry) are
/// dropped so the report only covers first-party code.
pub fn parse_llvm_cov_json(
    json: &str,
    repo: &str,
    repo_root: &str,
    crate_of: impl Fn(&str) -> String,
) -> anyhow::Result<CoverageReport> {
    let export: LlvmExport = serde_json::from_str(json)?;
    let root = repo_root.replace('\\', "/");

    // file path → its functions (built from the functions array first).
    use std::collections::BTreeMap;
    let mut fn_by_file: BTreeMap<String, Vec<FnCoverage>> = BTreeMap::new();

    for d in &export.data {
        for f in &d.functions {
            let file = f.filenames.first().cloned().unwrap_or_default().replace('\\', "/");
            if !is_first_party(&file, &root) {
                continue;
            }
            let regions = f.regions.len() as u64;
            // A region is covered if its execution count (index 4 in the LLVM v2
            // region tuple: [lineStart,colStart,lineEnd,colEnd,execCount,...]) > 0.
            let regions_covered = f
                .regions
                .iter()
                .filter(|r| r.get(4).copied().unwrap_or(0) > 0)
                .count() as u64;
            // cargo-llvm-cov's JSON export emits MANGLED symbols (`_RNvNt…` v0 /
            // `_ZN…` legacy); demangle so the boundary classifier + the table see
            // readable `crate::module::Type::fn` paths.
            let name = demangle(&f.name);
            let boundary = classify(&name, &file);
            // Where the function sits: regions of THIS file (index 5 = file id;
            // 0 = the function's own file, others are macro expansions).
            let own = |r: &&Vec<i64>| r.get(5).copied().unwrap_or(0) == 0;
            let line = f.regions.iter().filter(own).filter_map(|r| r.first().copied()).min().unwrap_or(0).max(0) as u32;
            let end_line = f.regions.iter().filter(own).filter_map(|r| r.get(2).copied()).max().unwrap_or(0).max(0) as u32;
            // Per-fn line counts aren't in the export; approximate "lines" by the
            // region span and treat exercised-ness via the function `count`.
            let lines = regions;
            let lines_covered = if f.count > 0 { regions_covered.max(1).min(regions) } else { regions_covered };
            fn_by_file.entry(file.clone()).or_default().push(FnCoverage {
                name,
                // repo-relative, like every `FileCoverage::file` (and the code
                // room's spans): the absolute path of the box that measured is no key
                file: rel(&file, &root),
                lines,
                lines_covered,
                regions,
                regions_covered,
                boundary,
                line,
                end_line,
            });
        }
    }

    // Per-file summaries (authoritative line/region totals).
    let mut files: Vec<FileCoverage> = Vec::new();
    for d in &export.data {
        for file in &d.files {
            let path = file.filename.replace('\\', "/");
            if !is_first_party(&path, &root) {
                continue;
            }
            let krate = crate_of(&path);
            let functions = fn_by_file.remove(&path).unwrap_or_default();
            files.push(FileCoverage {
                file: rel(&path, &root),
                krate,
                lines: file.summary.lines.count,
                lines_covered: file.summary.lines.covered,
                regions: file.summary.regions.count,
                regions_covered: file.summary.regions.covered,
                functions,
            });
        }
    }
    files.sort_by(|a, b| a.file.cmp(&b.file));

    summarise(repo, files)
}

/// Roll a flat file list up into the full report (per-crate + overall totals).
/// Split out so tests can build `FileCoverage` directly and assert the rollup.
pub fn summarise(repo: &str, files: Vec<FileCoverage>) -> anyhow::Result<CoverageReport> {
    use std::collections::BTreeMap;
    let mut crates: BTreeMap<String, CrateCoverage> = BTreeMap::new();
    let (mut lines, mut lines_cov, mut regions, mut regions_cov) = (0u64, 0u64, 0u64, 0u64);

    for f in &files {
        lines += f.lines;
        lines_cov += f.lines_covered;
        regions += f.regions;
        regions_cov += f.regions_covered;
        let c = crates.entry(f.krate.clone()).or_insert_with(|| CrateCoverage {
            krate: f.krate.clone(),
            lines: 0,
            lines_covered: 0,
            regions: 0,
            regions_covered: 0,
            files: 0,
        });
        c.lines += f.lines;
        c.lines_covered += f.lines_covered;
        c.regions += f.regions;
        c.regions_covered += f.regions_covered;
        c.files += 1;
    }

    let mut crates: Vec<CrateCoverage> = crates.into_values().collect();
    crates.sort_by(|a, b| a.krate.cmp(&b.krate));

    Ok(CoverageReport {
        repo: repo.to_string(),
        lines,
        lines_covered: lines_cov,
        regions,
        regions_covered: regions_cov,
        crates,
        files,
    })
}

/// Demangle a Rust symbol (v0 `_R…` or legacy `_ZN…`) to its readable path,
/// stripping the trailing `::h<hash>` disambiguator the legacy mangling adds.
/// A name that isn't mangled passes through unchanged.
fn demangle(sym: &str) -> String {
    let d = format!("{:#}", rustc_demangle::demangle(sym));
    // `{:#}` already omits the legacy hash; guard against a stray one anyway.
    match d.rsplit_once("::") {
        Some((head, tail)) if tail.starts_with('h') && tail.len() == 17 && tail[1..].chars().all(|c| c.is_ascii_hexdigit()) => {
            head.to_string()
        }
        _ => d,
    }
}

/// Is `file` first-party source under `root` (not a dep / std / build artifact)?
fn is_first_party(file: &str, root: &str) -> bool {
    let f = file;
    if !root.is_empty() && !f.starts_with(root) {
        return false;
    }
    // Drop registry / toolchain / generated paths even when under root.
    !(f.contains("/.cargo/")
        || f.contains("/registry/")
        || f.contains("/rustc/")
        || f.contains("/target/")
        || f.contains("/.rustup/"))
}

/// Path relative to `root` (or the original when not under it).
fn rel(file: &str, root: &str) -> String {
    if !root.is_empty() {
        if let Some(r) = file.strip_prefix(root) {
            return r.trim_start_matches('/').to_string();
        }
    }
    file.to_string()
}

/// Default `crate_of`: derive a crate name from a repo-relative path. Files
/// under `crates/<name>/…` belong to `<name>`; everything else belongs to the
/// repo's own root crate. A pragmatic heuristic good enough for the maven-style
/// per-crate rollup (nornir's own crate + its `crates/*` members).
pub fn crate_from_path(repo: &str) -> impl Fn(&str) -> String + '_ {
    move |path: &str| {
        let p = path.replace('\\', "/");
        if let Some(idx) = p.find("/crates/") {
            let rest = &p[idx + "/crates/".len()..];
            if let Some(name) = rest.split('/').next() {
                return name.to_string();
            }
        }
        // workspace-member crate dir e.g. `<root>/foo/src/...` → first segment
        // after root that isn't `src`. Fall back to the repo name.
        repo.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_finds_ui_grpc_emitter_boundaries() {
        // UI: a `ui`/`draw` fn under src/viz.
        assert_eq!(
            classify("nornir::viz::test_tab::TestTabState::draw", "/r/src/viz/test_tab.rs"),
            Boundary::Ui
        );
        assert_eq!(
            classify("nornir::viz::bench_live::draw_live_row", "/r/src/viz/bench_live.rs"),
            Boundary::Ui
        );
        // state_json is the canonical emitter (readable-data LAW) — tagged Emitter
        // even in a viz file.
        assert_eq!(
            classify("nornir::viz::test_tab::TestTabState::state_json", "/r/src/viz/test_tab.rs"),
            Boundary::Emitter
        );
        // gRPC: a named handler in the server binary.
        assert_eq!(
            classify("<S as Viz>::bench_telemetry", "/r/src/bin/nornir-server.rs"),
            Boundary::Grpc
        );
        assert_eq!(
            classify("nornir_server::run_test_matrix", "/r/src/bin/nornir-server.rs"),
            Boundary::Grpc
        );
        // Emitter: emit* + trace points.
        assert_eq!(classify("nornir::viz::trace::emit_out", "/r/src/viz/trace.rs"), Boundary::Emitter);
        assert_eq!(classify("nornir::selftest::emit", "/r/src/selftest.rs"), Boundary::Emitter);
        // Core: an ordinary fn.
        assert_eq!(classify("nornir::deps::topo_sort", "/r/src/deps.rs"), Boundary::Core);
    }

    /// INJECT a known llvm-cov JSON export → ASSERT the parsed percentages,
    /// per-crate rollup, worst files, and boundary functions (LAW 1).
    #[test]
    fn parse_real_llvm_cov_export_shape() {
        // Two files: a viz pane (50% lines) and a core file (100% lines), each
        // with one function. The viz file's `ui` fn is a UI boundary; the core
        // file's `topo_sort` is core.
        let json = r#"{
          "data": [{
            "files": [
              { "filename": "/repo/src/viz/test_tab.rs",
                "summary": { "lines": {"count": 10, "covered": 5},
                             "regions": {"count": 8, "covered": 4} } },
              { "filename": "/repo/src/deps.rs",
                "summary": { "lines": {"count": 4, "covered": 4},
                             "regions": {"count": 4, "covered": 4} } },
              { "filename": "/home/u/.cargo/registry/src/dep.rs",
                "summary": { "lines": {"count": 99, "covered": 0},
                             "regions": {"count": 99, "covered": 0} } }
            ],
            "functions": [
              { "name": "nornir::viz::test_tab::TestTabState::ui",
                "filenames": ["/repo/src/viz/test_tab.rs"],
                "regions": [[1,1,1,1,3],[2,1,2,1,0]], "count": 3 },
              { "name": "nornir::deps::topo_sort",
                "filenames": ["/repo/src/deps.rs"],
                "regions": [[1,1,1,1,7]], "count": 7 }
            ]
          }]
        }"#;
        let report =
            parse_llvm_cov_json(json, "nornir", "/repo", crate_from_path("nornir")).unwrap();

        // The dep file under .cargo/registry is dropped (first-party only).
        assert_eq!(report.files.len(), 2, "only first-party files: {:?}", report.files);

        // Overall: lines 14 total / 9 covered = 64.28..%; regions 12/8 = 66.66%.
        assert_eq!(report.lines, 14);
        assert_eq!(report.lines_covered, 9);
        assert!((report.line_pct() - 9.0 / 14.0 * 100.0).abs() < 1e-9);
        assert!((report.region_pct() - 8.0 / 12.0 * 100.0).abs() < 1e-9);

        // Per-crate rollup: everything maps to the `nornir` crate here.
        assert_eq!(report.crates.len(), 1);
        assert_eq!(report.crates[0].krate, "nornir");
        assert_eq!(report.crates[0].files, 2);

        // worst_files: the viz file (50%) is worse than deps.rs (100%).
        let worst = report.worst_files(1);
        assert_eq!(worst.len(), 1);
        assert_eq!(worst[0].file, "src/viz/test_tab.rs");
        assert!((worst[0].line_pct() - 50.0).abs() < 1e-9);

        // boundary_fns: the `ui` fn is a UI boundary and was exercised (count>0).
        let bf = report.boundary_fns();
        assert_eq!(bf.len(), 1, "one boundary fn (the ui); topo_sort is core");
        assert_eq!(bf[0].name, "nornir::viz::test_tab::TestTabState::ui");
        assert_eq!(bf[0].boundary, Boundary::Ui);
        assert!(bf[0].exercised(), "ui fn count>0 → exercised");
        assert_eq!(bf[0].regions, 2);
        assert_eq!(bf[0].regions_covered, 1, "one region had count>0");
        // where it sits: repo-relative file, first region's start .. furthest end
        assert_eq!((bf[0].file.as_str(), bf[0].line, bf[0].end_line), ("src/viz/test_tab.rs", 1, 2));

        // boundary_tally: 1 ui (1 exercised), 0 grpc, 0 emitter.
        let tally = report.boundary_tally();
        assert_eq!(tally[0], (Boundary::Ui, 1, 1));
        assert_eq!(tally[1], (Boundary::Grpc, 0, 0));
        assert_eq!(tally[2], (Boundary::Emitter, 0, 0));
    }

    #[test]
    fn demangle_unwraps_v0_and_passes_plain_through() {
        // A real v0-mangled symbol from cargo-llvm-cov's export demangles to a
        // readable path so the classifier sees `crate::module::…`.
        let m = "_RNvNtNtCsX6HlMSjEvs_17nornir_testmatrix4sink5tests4rows";
        let d = demangle(m);
        assert!(d.contains("nornir_testmatrix::sink::tests::rows"), "demangled: {d}");
        // Already-readable names pass through unchanged.
        assert_eq!(demangle("nornir::viz::test_tab::TestTabState::ui"), "nornir::viz::test_tab::TestTabState::ui");
    }

    #[test]
    fn pct_full_when_no_lines() {
        assert_eq!(pct(0, 0), 100.0);
        assert_eq!(pct(0, 10), 0.0);
        assert_eq!(pct(5, 10), 50.0);
    }

    #[test]
    fn pct_i64_clamps_garbage_and_handles_empty() {
        // Same 100%-when-nothing-to-cover convention as `pct`.
        assert_eq!(pct_i64(0, 0), 100.0, "no regions → fully covered");
        assert_eq!(pct_i64(5, 0), 100.0);
        assert_eq!(pct_i64(-3, 0), 100.0, "non-positive total → 100, never a divide");
        assert_eq!(pct_i64(-1, 10), 0.0, "negative covered clamps to 0, not a negative %");
        assert_eq!(pct_i64(5, 10), 50.0);
        assert_eq!(pct_i64(10, 10), 100.0);
    }

    #[test]
    fn region_pct_wrappers_agree_across_fn_file_crate_report() {
        // Build one function at 1/4 regions covered, roll it into a file, crate,
        // and report, and assert every layer's region_pct() reports the same 25%.
        let f = FnCoverage {
            name: "k::f".into(),
            file: "src/f.rs".into(),
            lines: 4,
            lines_covered: 1,
            regions: 4,
            regions_covered: 1,
            boundary: Boundary::Core,
            line: 1,
            end_line: 4,
        };
        assert_eq!(f.region_pct(), 25.0, "fn region %");

        let file = FileCoverage {
            file: "src/f.rs".into(),
            krate: "k".into(),
            lines: 4,
            lines_covered: 1,
            regions: 4,
            regions_covered: 1,
            functions: vec![f],
        };
        assert_eq!(file.region_pct(), 25.0, "file region %");

        let report = summarise("repo", vec![file]).unwrap();
        assert_eq!(report.region_pct(), 25.0, "report region % rolls up");
        assert_eq!(report.crates.len(), 1);
        assert_eq!(report.crates[0].region_pct(), 25.0, "crate region % rolls up");
        // An empty region set is 100% at every layer (nothing to cover).
        let empty = CrateCoverage {
            krate: "e".into(),
            lines: 0,
            lines_covered: 0,
            regions: 0,
            regions_covered: 0,
            files: 0,
        };
        assert_eq!(empty.region_pct(), 100.0);
    }
}