Skip to main content

testing_conventions/
coverage.rs

1//! Coverage rule.
2//!
3//! Enforces the README's Coverage rule: a library's unit suite must meet the
4//! configured floor, with test files excluded from the denominator. This module
5//! is the deterministic core — given a parsed coverage report and the thresholds
6//! from config, an `evaluate` function decides pass/fail. Producing the report
7//! (shelling out to the language's coverage tool) is a thin layer on top, kept
8//! separate so the guarantee is testable without that toolchain installed.
9//!
10//! Python uses coverage.py: a single total, branch coverage on. Given a
11//! [`CoverageReport`] and [`Thresholds`], [`evaluate`] decides pass/fail, and
12//! [`measure`] shells out to `coverage`. TypeScript is the twin: vitest
13//! reports four independent metrics (lines / branches / functions / statements),
14//! so it carries its own [`TypeScriptThresholds`], [`VitestReport`], and
15//! [`evaluate_typescript`] / [`measure_typescript`] pair — sharing only the
16//! [`Outcome`] type. Its subprocess layer shells out to `vitest`. Rust is
17//! the third twin: `cargo llvm-cov` reports regions/lines (branch coverage is
18//! experimental), so it carries [`RustThresholds`], [`LlvmCovReport`], and
19//! [`evaluate_rust`] / [`measure_rust`]; its subprocess layer shells out to
20//! `cargo llvm-cov`.
21//!
22//! Files exempted from coverage in config are omitted from the
23//! denominator alongside the test files; the caller resolves them
24//! ([`crate::config::resolve_exempt`]) and passes their paths to [`measure`] /
25//! [`measure_typescript`] / [`measure_rust`].
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29use std::process::Command;
30use std::sync::atomic::{AtomicU64, Ordering};
31
32use anyhow::{bail, Context, Result};
33use serde::Deserialize;
34
35/// Always omitted from the coverage denominator: colocated unit tests are the
36/// suite, never a subject of it.
37const TEST_OMIT: &str = "*_test.py";
38
39/// Also always omitted: `conftest.py` holds pytest fixtures (test support), never
40/// a coverage subject. `*conftest.py` matches it at any depth, mirroring the
41/// `*_test.py` glob.
42const SUPPORT_OMIT: &str = "*conftest.py";
43
44/// The coverage floor to enforce, from a `[<language>].coverage` table.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Thresholds {
47    /// Minimum total coverage percent the unit suite must meet.
48    pub fail_under: u8,
49    /// Whether branch coverage must be measured (and folded into the total).
50    pub branch: bool,
51}
52
53/// A coverage.py JSON report (`coverage json`), pared to what the checks need:
54/// the `totals` (the floor) and the per-file `files` block (patch
55/// coverage). Unmodeled fields (metadata, per-function/class data) are
56/// ignored.
57#[derive(Debug, Clone, Deserialize)]
58pub struct CoverageReport {
59    pub totals: Totals,
60    /// Per-file line/branch detail, keyed by the path coverage.py reports
61    /// (relative to the measured root). Additive: `#[serde(default)]`, so a report
62    /// parsed for the floor alone (the inline tests) needs no `files`.
63    #[serde(default)]
64    pub files: BTreeMap<String, FileCoverage>,
65}
66
67/// Per-file coverage detail from a coverage.py report (one `files` entry) — what
68/// patch coverage reads to decide whether a changed line is covered.
69/// Unmodeled fields (the summary, per-function/class data) are ignored.
70#[derive(Debug, Clone, Default, Deserialize)]
71pub struct FileCoverage {
72    /// Executable lines the suite ran.
73    #[serde(default)]
74    pub executed_lines: Vec<u64>,
75    /// Executable lines the suite never ran — an uncovered changed line is one of
76    /// these.
77    #[serde(default)]
78    pub missing_lines: Vec<u64>,
79    /// Lines excluded from coverage (e.g. `# pragma: no cover`); never a miss.
80    #[serde(default)]
81    pub excluded_lines: Vec<u64>,
82    /// `[source_line, dest_line]` pairs for branches the suite never took; `dest`
83    /// may be negative (a function / loop exit). Only the source line matters to
84    /// patch coverage. Empty when branch coverage was off.
85    #[serde(default)]
86    pub missing_branches: Vec<Vec<i64>>,
87    /// `[source_line, dest_line]` pairs for branches the suite DID take (coverage.py
88    /// emits these alongside `missing_branches` under `--branch`). The diff-scoped
89    /// floor counts an arc toward changed-line branch coverage when its source
90    /// line is in the diff; with `missing_branches` it gives branch coverage over the
91    /// changed lines. Empty when branch coverage was off.
92    #[serde(default)]
93    pub executed_branches: Vec<Vec<i64>>,
94}
95
96/// The `totals` block of a coverage.py report.
97#[derive(Debug, Clone, Deserialize)]
98pub struct Totals {
99    /// Total covered percent — line coverage, plus branch when measured.
100    pub percent_covered: f64,
101    /// Branches measured; `0` when branch coverage was not enabled.
102    #[serde(default)]
103    pub num_branches: u64,
104}
105
106/// The result of checking a report against the thresholds.
107#[derive(Debug, Clone, PartialEq)]
108pub enum Outcome {
109    /// The floor is met.
110    Pass,
111    /// The floor is not met; the message explains why (actual vs. required).
112    Fail(String),
113}
114
115/// Parse a coverage.py JSON report (the output of `coverage json`).
116pub fn parse_report(json: &str) -> Result<CoverageReport> {
117    serde_json::from_str(json).context("parsing coverage.py JSON report")
118}
119
120/// Decide whether `report` meets `thresholds`.
121///
122/// Fails when total coverage is below `fail_under`, or when branch coverage was
123/// required but the report measured no branches (a misconfigured run).
124pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
125    if thresholds.branch && report.totals.num_branches == 0 {
126        return Outcome::Fail(
127            "branch coverage is required but the report measured no branches".to_string(),
128        );
129    }
130    let actual = report.totals.percent_covered;
131    let required = f64::from(thresholds.fail_under);
132    // A hair of tolerance so a report that rounds to the floor (e.g. 99.999…%
133    // for a 100% target) isn't failed by float noise.
134    if actual + 1e-9 >= required {
135        Outcome::Pass
136    } else {
137        Outcome::Fail(format!(
138            "coverage {actual:.2}% is below the required {}%",
139            thresholds.fail_under
140        ))
141    }
142}
143
144/// Run the unit suite under coverage.py in `root` and check it against
145/// `thresholds`.
146///
147/// Shells out to `coverage run --branch` (omitting `*_test.py` and every path in
148/// `omit` from the denominator) then `coverage json`, and evaluates the report.
149/// `omit` holds the `coverage`-rule exemptions resolved from config, as
150/// `root`-relative paths. The `coverage` CLI — with `pytest` importable — must be
151/// on `PATH`.
152pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
153    let report = run_coverage(root, omit, false)?;
154    Ok(evaluate(&report, thresholds))
155}
156
157/// Run the Python unit suite under coverage.py in `root` with **every** source
158/// under `root` measured (`coverage run --source=.`) and return the parsed report
159/// — so an untested source shows in the `files` block as wholly uncovered rather
160/// than vanishing. The per-file detail is what patch coverage reads; `omit`
161/// is as in [`measure`] (an exempt file stays out of the run, so its changed
162/// lines are lifted).
163pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
164    run_coverage(root, omit, true)
165}
166
167/// Run the Python unit suite under coverage.py in `root` and return the parsed report
168/// with its per-file `files` detail — measuring only the files the suite imports (no
169/// `--source=.`), exactly as the whole-tree floor [`measure`] does. The line-scoped
170/// exemption path reads this: it recomputes the floor over the measured lines
171/// minus the exempt ones, so it must see the same file set [`measure`] does (an
172/// untested-but-unimported file is out of scope for both), not the wider `--source=.`
173/// set [`measure_patch_report`] uses for the diff. `omit` is as in [`measure`].
174pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
175    run_coverage(root, omit, false)
176}
177
178/// A coverage.py data file under the temp dir — unique per call (so checks
179/// running in parallel don't collide) and removed on drop (so nothing leaks
180/// into the scanned tree).
181struct DataFile(PathBuf);
182
183impl DataFile {
184    fn new() -> Self {
185        static COUNTER: AtomicU64 = AtomicU64::new(0);
186        let name = format!(
187            "testing-conventions-{}-{}.coverage",
188            std::process::id(),
189            COUNTER.fetch_add(1, Ordering::Relaxed),
190        );
191        DataFile(std::env::temp_dir().join(name))
192    }
193}
194
195impl Drop for DataFile {
196    fn drop(&mut self) {
197        let _ = std::fs::remove_file(&self.0);
198    }
199}
200
201/// Run coverage.py over the unit suite in `root` and return the parsed report.
202///
203/// `include_all_sources` adds `--source=.` so coverage measures every source
204/// under `root` — even one no test imports, which then appears in the `files`
205/// block as wholly uncovered. The floor passes `false` (measuring only imported
206/// files, so its total is unchanged); patch coverage passes `true`.
207fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
208    let data = DataFile::new();
209    let omit = build_omit(omit);
210
211    // Branch coverage on; measure the sources in `root` with the test files —
212    // and any `coverage`-waived files — omitted from the denominator. Byte-code
213    // and the pytest cache are suppressed so the scanned tree stays pristine.
214    let mut command = Command::new("coverage");
215    command
216        .current_dir(root)
217        .args(["run", "--branch"])
218        .arg(format!("--omit={omit}"));
219    if include_all_sources {
220        command.arg("--source=.");
221    }
222    let run = command
223        .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
224        .env("COVERAGE_FILE", &data.0)
225        .env("PYTHONDONTWRITEBYTECODE", "1")
226        .output()
227        .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
228    if !run.status.success() {
229        bail!(
230            "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
231            root.display(),
232            String::from_utf8_lossy(&run.stdout),
233            String::from_utf8_lossy(&run.stderr),
234        );
235    }
236
237    let json = Command::new("coverage")
238        .current_dir(root)
239        .args(["json", "-o", "-"])
240        .env("COVERAGE_FILE", &data.0)
241        .output()
242        .context("running `coverage json`")?;
243    if !json.status.success() {
244        bail!(
245            "`coverage json` failed:\n{}",
246            String::from_utf8_lossy(&json.stderr),
247        );
248    }
249
250    parse_report(&String::from_utf8_lossy(&json.stdout))
251}
252
253/// The single comma-joined `--omit` value for the coverage run: always the test
254/// glob `*_test.py` and the support glob `*conftest.py`, plus every
255/// `coverage`-exempt path from config. (coverage.py takes one `--omit` — repeated
256/// flags don't accumulate, so the patterns must be joined.) An exempt file leaves
257/// the denominator with its reason recorded in config — an auditable omission, not
258/// a silent ignore-glob.
259fn build_omit(omit: &[String]) -> String {
260    [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
261        .into_iter()
262        .chain(omit.iter().cloned())
263        .collect::<Vec<_>>()
264        .join(",")
265}
266
267// TypeScript (vitest).
268//
269// The TypeScript twin of the Python rule above. vitest reports four independent
270// metrics rather than Python's single total-plus-branch, so it carries its own
271// thresholds, report shape, and evaluate/measure pair; only `Outcome` is shared.
272// The split is the same: a pure `evaluate_typescript` over a parsed json-summary
273// report, and a thin `measure_typescript` that shells out to vitest to produce
274// one — so the enforcement core is testable without a Node toolchain.
275
276/// What vitest measures: every TypeScript source under the scanned root. The
277/// braces are a vitest (picomatch) glob, expanded by vitest, not the shell.
278const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
279
280/// The project's own installed vitest's default coverage excludes (test files,
281/// declaration files, build-tool config files, `dist/`, `node_modules/`, …),
282/// resolved live via Node rather than hand-maintained here.
283///
284/// Passing *any* `--coverage.exclude` value to vitest replaces its built-in
285/// default list rather than extending it — so a rule-owned exclude flag (the
286/// colocated test glob; a config-driven `coverage` exemption) would otherwise
287/// silently un-exclude every default the provider ships, including its own
288/// `**/{vite,vitest,eslint,...}.config.*` pattern. That default list is
289/// exactly the ecosystem knowledge this tool has no business re-enumerating —
290/// it's resolved from whatever vitest version `root` actually has installed,
291/// so it can never go stale relative to it.
292fn vitest_default_excludes(root: &Path) -> Result<Vec<String>> {
293    let run = Command::new("node")
294        .current_dir(root)
295        .args([
296            "-e",
297            "process.stdout.write(JSON.stringify(require('vitest/config').coverageConfigDefaults.exclude))",
298        ])
299        .output()
300        .context("resolving vitest's default coverage excludes via node")?;
301    if !run.status.success() {
302        bail!(
303            "could not resolve vitest's default coverage excludes in `{}`. The rule runs the \
304             project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
305             must be installed in the project. node output:\n{}{}",
306            root.display(),
307            String::from_utf8_lossy(&run.stdout),
308            String::from_utf8_lossy(&run.stderr),
309        );
310    }
311    let excludes: Vec<String> = serde_json::from_slice(&run.stdout).with_context(|| {
312        format!(
313            "vitest's default coverage excludes were not a JSON string array — got: {}",
314            String::from_utf8_lossy(&run.stdout)
315        )
316    })?;
317    // A couple of vitest's own default patterns embed a literal NUL byte (its
318    // virtual-module boundary markers, e.g. `**/\0*`) — meaningless as a glob
319    // against real files, and a NUL byte can't be passed as a process argument
320    // at all, so those entries are dropped rather than sent to `Command::arg`.
321    Ok(excludes.into_iter().filter(|p| !p.contains('\0')).collect())
322}
323
324/// The four vitest coverage floors, from a `[typescript].coverage` table. Each
325/// is an independent percent the unit suite must meet — vitest measures all four.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub struct TypeScriptThresholds {
328    pub lines: u8,
329    pub branches: u8,
330    pub functions: u8,
331    pub statements: u8,
332}
333
334/// A vitest `coverage-summary.json` report, pared to the `total` block the check
335/// needs. Per-file entries and unmodeled fields are ignored.
336#[derive(Debug, Clone, Copy, Deserialize)]
337pub struct VitestReport {
338    pub total: VitestTotals,
339}
340
341/// The `total` block of a vitest json-summary report — the four metrics this
342/// rule enforces. vitest also emits `branchesTrue`, which the check ignores.
343#[derive(Debug, Clone, Copy, Deserialize)]
344pub struct VitestTotals {
345    pub lines: VitestMetric,
346    pub branches: VitestMetric,
347    pub functions: VitestMetric,
348    pub statements: VitestMetric,
349}
350
351/// One metric's totals from a vitest json-summary block, pared to what the check
352/// needs: the covered percent and the denominator size.
353#[derive(Debug, Clone, Copy, Deserialize)]
354pub struct VitestMetric {
355    /// Percent covered — `None` when nothing was measured, which vitest writes as
356    /// the string `"Unknown"` (and `total` is then `0`).
357    #[serde(deserialize_with = "deserialize_pct")]
358    pub pct: Option<f64>,
359    /// Size of the denominator (statements/branches/functions/lines counted).
360    pub total: u64,
361}
362
363/// Deserialize a json-summary `pct`: a number for a measured metric (vitest
364/// emits whole percents as JSON integers and fractional ones as floats), or the
365/// string `"Unknown"` (→ `None`) when the denominator is empty.
366fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
367where
368    D: serde::Deserializer<'de>,
369{
370    struct PctVisitor;
371    impl serde::de::Visitor<'_> for PctVisitor {
372        type Value = Option<f64>;
373
374        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
375            f.write_str("a coverage percent number or the string \"Unknown\"")
376        }
377
378        fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
379            Ok(Some(value))
380        }
381
382        // serde_json hands a whole-number percent (e.g. `100`) to `visit_u64`;
383        // percents are never negative, so `visit_i64` is not needed.
384        fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
385            Ok(Some(value as f64))
386        }
387
388        // Any non-numeric percent (vitest writes the literal "Unknown") means the
389        // metric had nothing to measure.
390        fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
391            Ok(None)
392        }
393    }
394    deserializer.deserialize_any(PctVisitor)
395}
396
397/// Parse a vitest json-summary report (`coverage-summary.json`).
398pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
399    serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
400}
401
402/// Decide whether `report` meets every threshold in `thresholds`.
403///
404/// Fails when the run measured no code at all (an empty line denominator — a
405/// wrong path, or a suite that touched nothing — is never a silent pass),
406/// otherwise checks each of the four metrics and fails listing every one below
407/// its floor. A metric whose denominator is empty *amid* a non-empty run (e.g.
408/// branch-free code measured alongside real code) has nothing to miss and is
409/// vacuously satisfied.
410pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
411    let total = &report.total;
412    // Vacuous-run guard: every source file has lines, so a zero line-denominator
413    // means nothing was measured — a misconfigured run (wrong path, or every file
414    // excluded), failed rather than passed on an empty measurement.
415    if total.lines.total == 0 {
416        return Outcome::Fail(
417            "the unit suite measured no code — check the path and that the suite runs".to_string(),
418        );
419    }
420    let checks = [
421        ("lines", total.lines, thresholds.lines),
422        ("branches", total.branches, thresholds.branches),
423        ("functions", total.functions, thresholds.functions),
424        ("statements", total.statements, thresholds.statements),
425    ];
426    let mut shortfalls = Vec::new();
427    for (name, metric, required) in checks {
428        // A metric with an empty denominator (e.g. branch-free code) has nothing
429        // to cover and is vacuously full; a measured one compares its percent.
430        let actual = metric.pct.unwrap_or(100.0);
431        // A hair of tolerance so a percent that rounds to the floor isn't failed
432        // by float noise (matches the Python path).
433        if actual + 1e-9 < f64::from(required) {
434            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
435        }
436    }
437    if shortfalls.is_empty() {
438        Outcome::Pass
439    } else {
440        Outcome::Fail(format!(
441            "coverage below thresholds: {}",
442            shortfalls.join(", ")
443        ))
444    }
445}
446
447/// Run the unit suite under vitest coverage in `root` and check it against
448/// `thresholds`.
449///
450/// Shells out to `npx vitest run` with v8 coverage and the json-summary reporter,
451/// excluding `*.test.*`, declaration files, and every path in `exclude` from the
452/// denominator, then evaluates the report. `exclude` holds the `coverage`-rule
453/// exemptions resolved from config, as `root`-relative paths. `npx` resolves the
454/// project-local `vitest`, so it and `@vitest/coverage-v8` must be installed
455/// under `root`.
456pub fn measure_typescript(
457    root: &Path,
458    thresholds: TypeScriptThresholds,
459    exclude: &[String],
460) -> Result<Outcome> {
461    let report = run_vitest(root, exclude)?;
462    Ok(evaluate_typescript(&report, thresholds))
463}
464
465/// A vitest reports directory under the temp dir — unique per call (so checks
466/// running in parallel don't collide) and removed on drop (so the report never
467/// leaks into the scanned tree). vitest writes `coverage-summary.json` here.
468struct ReportDir(PathBuf);
469
470impl ReportDir {
471    fn new() -> Self {
472        static COUNTER: AtomicU64 = AtomicU64::new(0);
473        let name = format!(
474            "testing-conventions-vitest-{}-{}",
475            std::process::id(),
476            COUNTER.fetch_add(1, Ordering::Relaxed),
477        );
478        ReportDir(std::env::temp_dir().join(name))
479    }
480}
481
482impl Drop for ReportDir {
483    fn drop(&mut self) {
484        let _ = std::fs::remove_dir_all(&self.0);
485    }
486}
487
488/// Run vitest over the unit suite in `root` and return the parsed floor report.
489fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
490    let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
491    parse_vitest_report(&json)
492}
493
494/// Run vitest coverage over the unit suite in `root` and return the raw contents
495/// of the `report_file` the `reporter` wrote. Shared by the floor (the
496/// `json-summary` → `coverage-summary.json` pair) and patch coverage (the
497/// detailed `json` → `coverage-final.json` Istanbul pair) — the two differ only in
498/// the reporter and how they parse it.
499///
500/// v8 coverage is written to an out-of-tree temp dir so the scanned tree stays
501/// pristine. `include` scopes measurement to the sources under `root`; vitest's
502/// own default excludes (test files, declaration files, build-tool config
503/// files, …, resolved live — see [`vitest_default_excludes`]) and the config
504/// `exclude` paths are excluded from the denominator. `all=true` counts source
505/// files the suite never imported, so an untested file is measured (lowering
506/// the floor / showing as uncovered) rather than vanishing. `--no-cache` keeps
507/// vitest from writing a cache into the tree.
508fn run_vitest_coverage(
509    root: &Path,
510    exclude: &[String],
511    reporter: &str,
512    report_file: &str,
513) -> Result<String> {
514    let reports = ReportDir::new();
515
516    let mut command = Command::new("npx");
517    command
518        .current_dir(root)
519        // `--no-install`, never `--yes`: run the project's own vitest (resolved via
520        // Node's parent-dir lookup) and refuse to download anything. With `--yes` a
521        // missing vitest would be silently fetched; the TS arm must fail clean like the
522        // coverage.py / cargo-llvm-cov arms, which invoke their binary directly.
523        .args(["--no-install", "vitest", "run", "--no-cache"])
524        .args(["--coverage.enabled", "--coverage.provider=v8"])
525        .arg(format!("--coverage.reporter={reporter}"))
526        .arg("--coverage.all=true")
527        .arg(format!(
528            "--coverage.reportsDirectory={}",
529            reports.0.display()
530        ))
531        .arg(format!("--coverage.include={TS_INCLUDE}"));
532    // Passing any `--coverage.exclude` replaces vitest's own default exclude
533    // list rather than extending it, so its defaults are resolved and passed
534    // back explicitly, alongside the config-driven exemption paths.
535    for path in vitest_default_excludes(root)?.iter().chain(exclude) {
536        command.arg(format!("--coverage.exclude={path}"));
537    }
538    // CI=1 keeps vitest non-interactive (no watch prompt, plain output).
539    let run = command
540        .env("CI", "1")
541        .output()
542        .context("running `npx --no-install vitest run --coverage`")?;
543    if !run.status.success() {
544        bail!(
545            "the unit suite did not run cleanly under vitest in `{}`. The rule runs the \
546             project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
547             and `@vitest/coverage-v8` must be installed in the project. vitest output:\n{}{}",
548            root.display(),
549            String::from_utf8_lossy(&run.stdout),
550            String::from_utf8_lossy(&run.stderr),
551        );
552    }
553
554    let path = reports.0.join(report_file);
555    std::fs::read_to_string(&path).with_context(|| {
556        format!(
557            "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
558            path.display()
559        )
560    })
561}
562
563// TypeScript diff-scoped coverage detail.
564//
565// What the diff-scoped floor (`crate::patch_coverage::measure_typescript`) reads:
566// per-file coverage detail for the four vitest metrics. vitest's `json-summary`
567// gives only per-file totals, so this measures with the detailed `json` (Istanbul
568// `coverage-final.json`) reporter and reduces each file to the per-statement /
569// per-branch-arm / per-function `(line, covered)` counts the floor's ratio needs.
570
571/// One file's entry in a vitest v8 `coverage-final.json` (Istanbul) report, pared
572/// to what patch coverage reads: the statement / branch / function maps and their
573/// hit counts. Unmodeled fields (`path`, per-node metadata) are ignored.
574#[derive(Debug, Clone, Deserialize)]
575struct IstanbulFile {
576    /// Statement id → source span. A statement whose hit count in `s` is `0` was
577    /// never executed, so its lines are uncovered.
578    #[serde(rename = "statementMap", default)]
579    statement_map: BTreeMap<String, IstanbulSpan>,
580    /// Statement id → execution count.
581    #[serde(default)]
582    s: BTreeMap<String, u64>,
583    /// Branch id → branch location. A branch with a `0` among its `b` counts had a
584    /// path the suite never took, so its source line is uncovered.
585    #[serde(rename = "branchMap", default)]
586    branch_map: BTreeMap<String, IstanbulBranch>,
587    /// Branch id → per-arm execution counts (one count per branch arm).
588    #[serde(default)]
589    b: BTreeMap<String, Vec<u64>>,
590    /// Function id → declaration location. A function whose hit count in `f` is `0`
591    /// was never called. The diff-scoped floor reads this via
592    /// [`istanbul_patch_detail`].
593    #[serde(rename = "fnMap", default)]
594    fn_map: BTreeMap<String, IstanbulFn>,
595    /// Function id → execution count.
596    #[serde(default)]
597    f: BTreeMap<String, u64>,
598}
599
600/// A source span — only the 1-based line numbers matter to patch coverage.
601#[derive(Debug, Clone, Deserialize)]
602struct IstanbulSpan {
603    start: IstanbulPos,
604    end: IstanbulPos,
605}
606
607/// A position in a source span; the `column` is ignored.
608#[derive(Debug, Clone, Deserialize)]
609struct IstanbulPos {
610    line: u64,
611}
612
613/// A branch entry — only its location (whose start line is the branch's source
614/// line) matters; the `type` and per-path `locations` are ignored.
615#[derive(Debug, Clone, Deserialize)]
616struct IstanbulBranch {
617    loc: IstanbulSpan,
618}
619
620/// A function entry — only its declaration's start line (the function's source
621/// line) matters; the `name`, `loc`, and top-level `line` are ignored. vitest's
622/// v8 export shapes this as `{"name":.., "decl":{"start":{"line":N,..},..}, ..}`.
623#[derive(Debug, Clone, Deserialize)]
624struct IstanbulFn {
625    decl: IstanbulSpan,
626}
627
628/// Per-file coverage detail from a vitest v8 `coverage-final.json` (Istanbul)
629/// report — the counts the diff-scoped floor needs. Each entry carries the
630/// Istanbul maps reduced to `(line, …, covered)` tuples, so the pure
631/// [`crate::patch_coverage::evaluate_patch_typescript`] can restrict each of the
632/// four metrics to the changed lines.
633#[derive(Debug, Clone, Default)]
634pub struct TsPatchCoverage {
635    /// One per `statementMap` entry: `(start_line, end_line, covered)` — `covered`
636    /// is `s[id] > 0`. A statement counts toward the diff when any line it spans is
637    /// a changed line.
638    pub statements: Vec<(u64, u64, bool)>,
639    /// One per branch **arm**: `(source_line, covered)` — `source_line` is the
640    /// branch's `loc.start.line` (shared by every arm) and `covered` is that arm's
641    /// count `> 0`. An arm counts toward the diff when its source line is changed.
642    pub branch_arms: Vec<(u64, bool)>,
643    /// One per `fnMap` entry: `(decl_line, covered)` — `decl_line` is `decl.start.line`
644    /// and `covered` is `f[id] > 0`. A function counts toward the diff when its
645    /// declaration line is changed.
646    pub functions: Vec<(u64, bool)>,
647}
648
649/// Run the TypeScript unit suite under vitest in `root` and return the per-file
650/// coverage detail for the four metrics — keyed by the absolute path vitest
651/// reports, the caller re-keying to `root`-relative to match the diff. Reads the
652/// Istanbul report for the diff-scoped floor: the per-statement /
653/// per-branch-arm / per-function `(line, covered)` detail the floor's ratio needs.
654/// `exclude` is the `coverage`-rule exemptions,
655/// dropped from the run so an exempt file's changed lines are lifted. `npx`
656/// resolves the project-local `vitest`, so it and `@vitest/coverage-v8` must be
657/// installed under `root`.
658pub fn measure_patch_typescript_detail(
659    root: &Path,
660    exclude: &[String],
661) -> Result<BTreeMap<String, TsPatchCoverage>> {
662    let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
663    istanbul_patch_detail(&json)
664}
665
666/// Pure: per-file [`TsPatchCoverage`] from a vitest v8 `coverage-final.json`
667/// (Istanbul) report. Keyed by the path vitest reports (absolute). A file present
668/// but with no statements/branches/functions maps to an empty `TsPatchCoverage`.
669fn istanbul_patch_detail(json: &str) -> Result<BTreeMap<String, TsPatchCoverage>> {
670    let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
671        .context("parsing vitest coverage-final (Istanbul) JSON report")?;
672    let mut out = BTreeMap::new();
673    for (path, file) in files {
674        let mut detail = TsPatchCoverage::default();
675        // Each statement → (start, end, covered): covered when its count is > 0.
676        for (id, span) in &file.statement_map {
677            let covered = file.s.get(id).is_some_and(|&count| count > 0);
678            detail
679                .statements
680                .push((span.start.line, span.end.line, covered));
681        }
682        // Each branch arm → (source_line, covered): the branch's location start line
683        // (shared by every arm) with that arm's count > 0. v8 may model a branch as
684        // a single arm (a `[count]` array) or several (`[arm0, arm1, …]`); one tuple
685        // per arm either way.
686        for (id, branch) in &file.branch_map {
687            let line = branch.loc.start.line;
688            if let Some(counts) = file.b.get(id) {
689                for &count in counts {
690                    detail.branch_arms.push((line, count > 0));
691                }
692            }
693        }
694        // Each function → (decl_line, covered): the declaration's start line with
695        // its call count > 0.
696        for (id, function) in &file.fn_map {
697            let covered = file.f.get(id).is_some_and(|&count| count > 0);
698            detail.functions.push((function.decl.start.line, covered));
699        }
700        out.insert(path, detail);
701    }
702    Ok(out)
703}
704
705// Rust (cargo llvm-cov).
706//
707// The Rust twin of the rules above. `cargo llvm-cov` reports LLVM source-based
708// coverage as regions + lines (branch coverage is still experimental), so the
709// Rust rule carries its own thresholds and `measure_rust` entry point; only the
710// `Outcome` type is shared. Mirroring the Python/TypeScript split, a pure
711// `evaluate_rust` over a parsed llvm-cov export and the thin subprocess layer
712// that produces one land with the implementation.
713
714/// The `cargo llvm-cov` coverage floors, from a `[rust].coverage` table (or the
715/// zero-config default). `lines` is always enforced; the rest are opt-in — `None`
716/// skips the check (the zero-config default floors lines only). A `branch`
717/// floor adds `--branch` to the run, which instruments only on a nightly
718/// toolchain.
719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
720pub struct RustThresholds {
721    pub regions: Option<u8>,
722    pub lines: u8,
723    pub functions: Option<u8>,
724    pub branch: Option<u8>,
725}
726
727/// A `cargo llvm-cov --json` export (LLVM's `llvm.coverage.json.export`), pared to
728/// the totals the check needs. A single run produces one `data` entry; unmodeled
729/// fields (per-file/per-function detail, `type`, `version`) are ignored.
730#[derive(Debug, Clone, Deserialize)]
731pub struct LlvmCovReport {
732    pub data: Vec<LlvmCovData>,
733}
734
735/// One export entry — only its `totals` are needed (`--summary-only` omits the
736/// per-file and per-function detail).
737#[derive(Debug, Clone, Copy, Deserialize)]
738pub struct LlvmCovData {
739    pub totals: LlvmCovTotals,
740}
741
742/// The `totals` block of an llvm-cov export — the metrics this rule can enforce:
743/// regions and lines always, `functions` and (under `--branch`) `branches` when
744/// their opt-in floors are set. llvm-cov also reports `instantiations` and
745/// `mcdc`, which the check ignores. `branches` is optional-with-default so an
746/// export from a run without branch instrumentation still parses (it then reads
747/// `count = 0`).
748#[derive(Debug, Clone, Copy, Deserialize)]
749pub struct LlvmCovTotals {
750    pub regions: LlvmCovMetric,
751    pub lines: LlvmCovMetric,
752    pub functions: LlvmCovMetric,
753    #[serde(default)]
754    pub branches: Option<LlvmCovMetric>,
755}
756
757/// One metric's totals from an llvm-cov export, pared to what the check needs: the
758/// denominator size and the covered percent.
759#[derive(Debug, Clone, Copy, Deserialize)]
760pub struct LlvmCovMetric {
761    /// Size of the denominator (regions or lines counted).
762    pub count: u64,
763    /// How many were covered.
764    pub covered: u64,
765    /// Covered percent — llvm-cov computes `100 * covered / count`.
766    pub percent: f64,
767}
768
769/// Parse a `cargo llvm-cov --json` export.
770pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
771    serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
772}
773
774/// Decide whether `report` meets both thresholds.
775///
776/// Fails when the run measured no regions at all (an empty denominator — a wrong
777/// path, or a crate that compiled nothing — is never a silent pass), otherwise
778/// checks regions and lines and fails listing each below its floor.
779pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
780    let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
781        return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
782    };
783    // Vacuous-run guard: every compiled crate has regions, so a zero region
784    // denominator means nothing was measured — failed rather than passed on an
785    // empty measurement (mirrors the TypeScript path).
786    if totals.regions.count == 0 {
787        return Outcome::Fail(
788            "the unit suite measured no code — check the path and that the suite runs".to_string(),
789        );
790    }
791    // `regions`, `functions`, and `branch` are opt-in: the zero-config
792    // default floors lines only, so each check is skipped unless a config set its
793    // floor.
794    let mut checks: Vec<(&str, f64, u8)> = Vec::new();
795    if let Some(regions) = thresholds.regions {
796        checks.push(("regions", totals.regions.percent, regions));
797    }
798    checks.push(("lines", totals.lines.percent, thresholds.lines));
799    if let Some(functions) = thresholds.functions {
800        checks.push(("functions", totals.functions.percent, functions));
801    }
802    if let Some(branch) = thresholds.branch {
803        // The floor's run added `--branch` (a failed instrumentation is a run
804        // error, surfaced before this point), so a zero branch denominator here
805        // means the crate has no branch points — vacuously satisfied, mirroring
806        // the diff-scoped floors' empty-denominator rule.
807        if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
808            checks.push(("branches", branches.percent, branch));
809        }
810    }
811    let mut shortfalls = Vec::new();
812    for (name, actual, required) in checks {
813        // A hair of tolerance so a percent that rounds to the floor isn't failed by
814        // float noise (matches the Python / TypeScript paths).
815        if actual + 1e-9 < f64::from(required) {
816            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
817        }
818    }
819    if shortfalls.is_empty() {
820        Outcome::Pass
821    } else {
822        Outcome::Fail(format!(
823            "coverage below thresholds: {}",
824            shortfalls.join(", ")
825        ))
826    }
827}
828
829/// Run the unit suite under `cargo llvm-cov` in `root` and check it against
830/// `thresholds`.
831///
832/// Shells out to `cargo llvm-cov --lib --json --summary-only`, omitting every path in
833/// `ignore` from the denominator (a single `--ignore-filename-regex`), then
834/// evaluates the export. `ignore` holds the `coverage`-rule exemptions resolved
835/// from config, as `root`-relative paths; `features` the `[rust] features` list to
836/// enable on the run. `cargo-llvm-cov` must be installed.
837pub fn measure_rust(
838    root: &Path,
839    thresholds: RustThresholds,
840    ignore: &[String],
841    features: &[String],
842) -> Result<Outcome> {
843    let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
844    Ok(evaluate_rust(&report, thresholds))
845}
846
847/// A `cargo llvm-cov` target directory under the temp dir — unique per call (so
848/// checks running in parallel don't collide) and removed on drop (so the build
849/// never leaks into the scanned tree). Passed to the run as `CARGO_TARGET_DIR`.
850struct TargetDir(PathBuf);
851
852impl TargetDir {
853    fn new() -> Self {
854        static COUNTER: AtomicU64 = AtomicU64::new(0);
855        let name = format!(
856            "testing-conventions-llvm-cov-{}-{}",
857            std::process::id(),
858            COUNTER.fetch_add(1, Ordering::Relaxed),
859        );
860        TargetDir(std::env::temp_dir().join(name))
861    }
862}
863
864impl Drop for TargetDir {
865    fn drop(&mut self) {
866        let _ = std::fs::remove_dir_all(&self.0);
867    }
868}
869
870/// Run cargo llvm-cov over the unit suite in `root` and return the parsed
871/// `--summary-only` export — the totals the floor checks. `branch` adds
872/// `--branch` for a configured branch floor.
873fn run_llvm_cov(
874    root: &Path,
875    ignore: &[String],
876    features: &[String],
877    branch: bool,
878) -> Result<LlvmCovReport> {
879    parse_llvm_cov_report(&run_cargo_llvm_cov(
880        root,
881        ignore,
882        &["--json", "--summary-only"],
883        features,
884        branch,
885    )?)
886}
887
888/// Run `cargo llvm-cov --lib` over the unit suite in `root` with the given coverage
889/// `format` args (`["--json", "--summary-only"]` for the whole-tree floor's totals,
890/// `["--json"]` for the diff-scoped floor's per-region detail) and return its
891/// stdout. Shared by the whole-tree floor and the diff-scoped floor,
892/// so both measure the same unit-only slice.
893///
894/// The build goes to an out-of-tree target dir (via `CARGO_TARGET_DIR`) so the
895/// scanned crate stays pristine; the `coverage`-rule exemptions become one
896/// `--ignore-filename-regex`; the `[rust] features` list is enabled on the run so
897/// `#[cfg(feature = ...)]` code is compiled and measured; and the outer
898/// run's instrumentation env is stripped for nested-run hygiene (the loop below
899/// explains why).
900fn run_cargo_llvm_cov(
901    root: &Path,
902    ignore: &[String],
903    format: &[&str],
904    features: &[String],
905    branch: bool,
906) -> Result<String> {
907    let target = TargetDir::new();
908
909    let mut command = Command::new("cargo");
910    command
911        .current_dir(root)
912        .arg("llvm-cov")
913        // `--lib` scopes the run to the unit suite — the library target with its
914        // inline `#[cfg(test)]` modules, the tool's definition of a Rust unit.
915        // cargo-llvm-cov's default runs every test target, which lets the
916        // integration tier under `tests/` pad the number.
917        .arg("--lib")
918        .args(format)
919        .env("CARGO_TARGET_DIR", &target.0);
920    if !features.is_empty() {
921        command.arg("--features").arg(features.join(","));
922    }
923    if branch {
924        // A configured branch floor measures branch outcomes; the flag
925        // instruments only on a nightly toolchain — the error below names that.
926        command.arg("--branch");
927    }
928    if let Some(regex) = ignore_filename_regex(root, ignore) {
929        command.arg("--ignore-filename-regex").arg(regex);
930    }
931    // Nested-run hygiene: when this check itself runs under `cargo llvm-cov` (the
932    // package's own coverage job), the outer run exports its instrumentation state
933    // into our environment — the coverage flags and profile path, and (because
934    // cargo-llvm-cov drives instrumentation through a rustc wrapper) a
935    // `RUSTC_WRAPPER` pointing back at `cargo-llvm-cov`. Inherited, that wrapper
936    // makes the inner run re-enter cargo-llvm-cov on every rustc invocation and
937    // never finish — it hangs compiling the scanned crate until the runner is
938    // OOM-killed. Strip the lot so the inner run instruments from a clean slate.
939    for var in [
940        "RUSTFLAGS",
941        "CARGO_ENCODED_RUSTFLAGS",
942        "RUSTDOCFLAGS",
943        "CARGO_ENCODED_RUSTDOCFLAGS",
944        "LLVM_PROFILE_FILE",
945        "CARGO_LLVM_COV",
946        "CARGO_LLVM_COV_SHOW_ENV",
947        "CARGO_LLVM_COV_TARGET_DIR",
948        "CARGO_LLVM_COV_BUILD_DIR",
949        "RUSTC_WRAPPER",
950        "RUSTC_WORKSPACE_WRAPPER",
951        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
952        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
953        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
954        // Toolchain hygiene: when this tool is itself spawned by a cargo
955        // process (a test harness, an xtask), cargo/rustup export the *spawning*
956        // toolchain into the environment, and rustup gives those variables
957        // precedence over the scanned crate's own `rust-toolchain.toml`. The
958        // scanned crate's pin must decide — a branch-floor crate pins nightly
959        // there — so the inherited selection is dropped and rustup resolves
960        // fresh from the crate's directory.
961        "RUSTUP_TOOLCHAIN",
962        "CARGO",
963        "RUSTC",
964    ] {
965        command.env_remove(var);
966    }
967    let output = command
968        .output()
969        .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
970    if !output.status.success() {
971        // A branch-floor run that fails is most often a stable toolchain (the
972        // `--branch` instrumentation is nightly-only), so name the requirement
973        // alongside the run's own output.
974        let hint = if branch {
975            "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
976             nightly toolchain — pin one in the crate's rust-toolchain.toml with \
977             llvm-tools-preview, or set a rustup directory override)"
978        } else {
979            ""
980        };
981        bail!(
982            "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{hint}\n{}{}",
983            root.display(),
984            String::from_utf8_lossy(&output.stdout),
985            String::from_utf8_lossy(&output.stderr),
986        );
987    }
988    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
989}
990
991/// Per-file region detail from a `cargo llvm-cov --json` export — the per-region
992/// counts the diff-scoped floor needs. Each entry carries one
993/// `(start_line, end_line, covered)` tuple per code region, so the pure
994/// [`crate::patch_coverage::evaluate_patch_rust`] can restrict both the regions and
995/// lines metrics to the changed lines.
996#[derive(Debug, Clone, Default)]
997pub struct RustPatchCoverage {
998    /// One per code region (a `kind == 0` region of the LLVM export):
999    /// `(start_line, end_line, covered)` — `covered` is the region's
1000    /// `executionCount > 0`. A region counts toward the diff when any line it spans
1001    /// is a changed line.
1002    pub regions: Vec<(u64, u64, bool)>,
1003}
1004
1005/// A full `cargo llvm-cov --json` export (LLVM's `llvm.coverage.json.export`),
1006/// modeling the per-function region detail the diff-scoped floor needs — separate
1007/// from [`LlvmCovReport`], which keeps only the `totals` the whole-tree floor
1008/// reads. A single run produces one `data` entry; unmodeled fields (`totals`,
1009/// `type`, `version`) are ignored.
1010#[derive(Debug, Clone, Deserialize)]
1011struct LlvmCovExport {
1012    data: Vec<LlvmCovExportData>,
1013}
1014
1015/// One export entry — its per-function `functions` block carries the regions (the
1016/// `--summary-only` runs that feed [`LlvmCovReport`] omit it), and its `files` block
1017/// names the measured files. `--ignore-filename-regex` drops an exempt file from
1018/// `files` but *not* from `functions` (the regions array is unfiltered), so the
1019/// `files` list is the allowlist [`llvm_cov_patch_detail`] restricts the regions to.
1020#[derive(Debug, Clone, Deserialize)]
1021struct LlvmCovExportData {
1022    files: Vec<LlvmCovExportFile>,
1023    functions: Vec<LlvmCovFunction>,
1024}
1025
1026/// One measured file in the export's `files` block — only its `filename` (the
1027/// absolute path) is needed, to build the not-ignored allowlist. The per-file
1028/// `segments` / `summary` detail is ignored (the regions come from `functions`).
1029#[derive(Debug, Clone, Deserialize)]
1030struct LlvmCovExportFile {
1031    filename: String,
1032}
1033
1034/// One function's coverage in the export: the source files it spans (`filenames`,
1035/// indexed by a region's `fileID`) and its regions. Each region is a flat array
1036/// `[lineStart, colStart, lineEnd, colEnd, executionCount, fileID, expandedFileID,
1037/// kind]`; the fields are read positionally in [`llvm_cov_patch_detail`].
1038#[derive(Debug, Clone, Deserialize)]
1039struct LlvmCovFunction {
1040    filenames: Vec<String>,
1041    regions: Vec<Vec<i64>>,
1042}
1043
1044/// Run the Rust unit suite under `cargo llvm-cov` in `root` and return the per-file
1045/// region detail — keyed by the absolute path llvm-cov reports, the caller re-keying
1046/// to `root`-relative to match the diff. Reads the full `--json` export for the
1047/// diff-scoped floor: the per-region `(line, covered)` detail the floor's
1048/// regions metric needs. `ignore` is the `coverage`-rule exemptions, dropped
1049/// from the run so an exempt file's changed lines are lifted. `cargo-llvm-cov` must
1050/// be installed.
1051pub fn measure_patch_rust_detail(
1052    root: &Path,
1053    ignore: &[String],
1054    features: &[String],
1055) -> Result<BTreeMap<String, RustPatchCoverage>> {
1056    // The diff-scoped floor judges regions + lines, so its run never adds
1057    // `--branch`.
1058    llvm_cov_patch_detail(&run_cargo_llvm_cov(
1059        root,
1060        ignore,
1061        &["--json"],
1062        features,
1063        false,
1064    )?)
1065}
1066
1067/// Pure: per-file [`RustPatchCoverage`] from a `cargo llvm-cov --json` export.
1068/// Keyed by the path llvm-cov reports (absolute). Walks every function's regions;
1069/// for each region:
1070///   - **skips** any region whose file is not in the export's `files` allowlist —
1071///     `--ignore-filename-regex` drops an exempt file from `files` (and the totals)
1072///     but leaves it in the unfiltered `functions` regions, so honoring the
1073///     exemption means intersecting with `files`. A run with nothing exempt lists
1074///     every measured file, so this is a no-op there.
1075///   - **skips** any region whose `kind` (index 7) is not `0` — only `kind == 0`
1076///     code regions count toward coverage (gap / expansion / skipped / branch
1077///     regions carry no line-coverage signal). The kept count (with nothing
1078///     ignored) matches the `totals.regions.count` a `--summary-only` run reports.
1079///   - reads `start_line = region[0]`, `end_line = region[2]`,
1080///     `covered = region[4] > 0`, and the file `filenames[region[5]]` (the
1081///     `fileID`), pushing `(start_line, end_line, covered)` under that file.
1082///
1083/// A region array with fewer than 8 elements (malformed — never seen from
1084/// llvm-cov) is skipped rather than panicking on an index, as is one whose `fileID`
1085/// is out of range for its `filenames`.
1086fn llvm_cov_patch_detail(json: &str) -> Result<BTreeMap<String, RustPatchCoverage>> {
1087    let export: LlvmCovExport =
1088        serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")?;
1089    let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1090    for data in &export.data {
1091        // The `files` block honors `--ignore-filename-regex`; the `functions` regions
1092        // do not, so restrict to the measured (not-ignored) files.
1093        let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1094        for function in &data.functions {
1095            for region in &function.regions {
1096                // A code region carries eight fields; anything shorter is malformed
1097                // (never emitted by llvm-cov) and skipped rather than indexed.
1098                if region.len() < 8 {
1099                    continue;
1100                }
1101                // Only `kind == 0` (a code region) contributes to line coverage;
1102                // gap (1) / expansion (2) / skipped / branch regions are ignored.
1103                if region[7] != 0 {
1104                    continue;
1105                }
1106                let file_id = region[5];
1107                let Ok(file_id) = usize::try_from(file_id) else {
1108                    continue;
1109                };
1110                let Some(file) = function.filenames.get(file_id) else {
1111                    continue;
1112                };
1113                // Skip a file the run ignored (absent from `files`) so a `coverage`
1114                // exemption drops its regions, lifting its changed lines.
1115                if !measured.contains(file.as_str()) {
1116                    continue;
1117                }
1118                let start = region[0].max(0) as u64;
1119                let end = region[2].max(0) as u64;
1120                let covered = region[4] > 0;
1121                out.entry(file.clone())
1122                    .or_default()
1123                    .regions
1124                    .push((start, end, covered));
1125            }
1126        }
1127    }
1128    Ok(out)
1129}
1130
1131/// The single `--ignore-filename-regex` value for the run, or `None` when nothing
1132/// is exempt. `cargo llvm-cov` takes one regex, so the `coverage`-exempt paths are
1133/// each regex-escaped (matched literally, not as a pattern) and joined with `|`. An
1134/// exempt file leaves the denominator with its reason recorded in config — an
1135/// auditable omission, not a silent ignore-glob.
1136///
1137/// llvm-cov reports absolute filenames and `--ignore-filename-regex` is a substring
1138/// search, so each `root`-relative exempt path is anchored to its full path under
1139/// `root` and terminated with `$`. Substring-matching an unanchored `src/a.rs` would
1140/// over-match a workspace member's `member/src/a.rs` (and a nested `src/xsrc/a.rs`);
1141/// the full-path anchor drops only the exempted file.
1142fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1143    if ignore.is_empty() {
1144        return None;
1145    }
1146    Some(
1147        ignore
1148            .iter()
1149            .map(|rel| {
1150                // Anchor to the file's absolute path: the exempt entry is validated
1151                // to exist under `root`, so `canonicalize` resolves it to the same
1152                // absolute form llvm-cov reports; the fallback keeps the anchor
1153                // deterministic when the path can't be resolved (e.g. in tests).
1154                let full = root.join(rel);
1155                let full = full.canonicalize().unwrap_or(full);
1156                format!("{}$", regex_escape(&full.to_string_lossy()))
1157            })
1158            .collect::<Vec<_>>()
1159            .join("|"),
1160    )
1161}
1162
1163/// Escape the regex metacharacters in `s` so it matches literally — an exempt path
1164/// carries `.` (and may carry other metacharacters) that must not read as regex.
1165fn regex_escape(s: &str) -> String {
1166    const META: &str = r"\.+*?()|[]{}^$";
1167    let mut out = String::with_capacity(s.len());
1168    for c in s.chars() {
1169        if META.contains(c) {
1170            out.push('\\');
1171        }
1172        out.push(c);
1173    }
1174    out
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1182        CoverageReport {
1183            totals: Totals {
1184                percent_covered,
1185                num_branches,
1186            },
1187            files: BTreeMap::new(),
1188        }
1189    }
1190
1191    #[test]
1192    fn passes_when_total_meets_the_floor() {
1193        assert_eq!(
1194            evaluate(
1195                &report(100.0, 12),
1196                Thresholds {
1197                    fail_under: 100,
1198                    branch: true
1199                }
1200            ),
1201            Outcome::Pass
1202        );
1203    }
1204
1205    #[test]
1206    fn fails_when_total_is_below_the_floor() {
1207        assert!(matches!(
1208            evaluate(
1209                &report(80.0, 12),
1210                Thresholds {
1211                    fail_under: 100,
1212                    branch: true
1213                }
1214            ),
1215            Outcome::Fail(_)
1216        ));
1217    }
1218
1219    #[test]
1220    fn fails_when_branch_required_but_unmeasured() {
1221        // branch=true but the report measured no branches → a misconfigured run.
1222        assert!(matches!(
1223            evaluate(
1224                &report(100.0, 0),
1225                Thresholds {
1226                    fail_under: 90,
1227                    branch: true
1228                }
1229            ),
1230            Outcome::Fail(_)
1231        ));
1232    }
1233
1234    #[test]
1235    fn parses_a_coverage_py_report() {
1236        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1237        let report = parse_report(json).expect("valid coverage.py json");
1238        assert_eq!(report.totals.percent_covered, 91.5);
1239        assert_eq!(report.totals.num_branches, 8);
1240    }
1241
1242    #[test]
1243    fn parses_the_per_file_block_for_patch_coverage() {
1244        // A realistic `coverage json` shape: a `files` map carrying the per-file
1245        // missing lines and `[src, dst]` branch pairs patch coverage reads.
1246        let json = r#"{
1247            "files": {
1248                "widget.py": {
1249                    "executed_lines": [1, 2, 3, 4, 6],
1250                    "summary": {"percent_covered": 85.0},
1251                    "missing_lines": [5],
1252                    "excluded_lines": [],
1253                    "missing_branches": [[4, 5]]
1254                }
1255            },
1256            "totals": {"percent_covered": 85.0, "num_branches": 4}
1257        }"#;
1258        let report = parse_report(json).expect("valid coverage.py json with files");
1259        let widget = report.files.get("widget.py").expect("widget.py is present");
1260        assert_eq!(widget.missing_lines, vec![5]);
1261        assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1262        // The floor still reads totals from the same report.
1263        assert_eq!(report.totals.percent_covered, 85.0);
1264    }
1265
1266    #[test]
1267    fn a_report_without_a_files_block_parses_with_an_empty_map() {
1268        // The floor path parses totals only; `files` defaults to empty.
1269        let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1270            .expect("valid coverage.py json");
1271        assert!(report.files.is_empty());
1272    }
1273
1274    #[test]
1275    fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1276        assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1277    }
1278
1279    #[test]
1280    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1281        // The caller passes already-resolved, sorted, `root`-relative paths.
1282        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1283        assert_eq!(
1284            build_omit(&exempt),
1285            "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1286        );
1287    }
1288
1289    fn metric(pct: f64) -> VitestMetric {
1290        VitestMetric {
1291            pct: Some(pct),
1292            total: 10,
1293        }
1294    }
1295
1296    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1297        VitestReport {
1298            total: VitestTotals {
1299                lines: metric(lines),
1300                branches: metric(branches),
1301                functions: metric(functions),
1302                statements: metric(statements),
1303            },
1304        }
1305    }
1306
1307    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1308        lines: 100,
1309        branches: 100,
1310        functions: 100,
1311        statements: 100,
1312    };
1313    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1314        lines: 80,
1315        branches: 75,
1316        functions: 80,
1317        statements: 80,
1318    };
1319
1320    #[test]
1321    fn typescript_passes_when_every_metric_meets_its_floor() {
1322        assert_eq!(
1323            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1324            Outcome::Pass
1325        );
1326    }
1327
1328    #[test]
1329    fn typescript_fails_on_the_one_metric_below_its_floor() {
1330        // 100% lines but only 66.66% branches (the `below` fixture's shape): the
1331        // branch floor catches what line coverage misses — and only `branches` is
1332        // named as a shortfall, not the metrics that met their floor.
1333        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1334        assert!(
1335            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1336            "got: {outcome:?}"
1337        );
1338    }
1339
1340    #[test]
1341    fn typescript_fail_message_names_every_metric_below() {
1342        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1343        assert!(
1344            matches!(&outcome, Outcome::Fail(message)
1345                if message.contains("lines")
1346                    && message.contains("branches")
1347                    && message.contains("functions")
1348                    && message.contains("statements")),
1349            "got: {outcome:?}"
1350        );
1351    }
1352
1353    #[test]
1354    fn typescript_tolerates_float_noise_at_the_floor() {
1355        // A percent a hair under the floor from rounding still passes.
1356        assert_eq!(
1357            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1358            Outcome::Pass
1359        );
1360    }
1361
1362    #[test]
1363    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1364        // Branch-free code measured alongside real code: branches has nothing to
1365        // cover (pct "Unknown") but lines/etc. are real and pass → overall pass.
1366        let report = VitestReport {
1367            total: VitestTotals {
1368                lines: metric(100.0),
1369                branches: VitestMetric {
1370                    pct: None,
1371                    total: 0,
1372                },
1373                functions: metric(100.0),
1374                statements: metric(100.0),
1375            },
1376        };
1377        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1378    }
1379
1380    #[test]
1381    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1382        // No lines in the denominator (everything excluded, or a wrong path): a
1383        // vacuous run is a failure, never a silent pass.
1384        let nothing = VitestMetric {
1385            pct: None,
1386            total: 0,
1387        };
1388        let report = VitestReport {
1389            total: VitestTotals {
1390                lines: nothing,
1391                branches: nothing,
1392                functions: nothing,
1393                statements: nothing,
1394            },
1395        };
1396        let outcome = evaluate_typescript(&report, TS_MID);
1397        assert!(
1398            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1399            "got: {outcome:?}"
1400        );
1401    }
1402
1403    #[test]
1404    fn parses_a_vitest_summary_report() {
1405        // A realistic `coverage-summary.json`: the four metrics plus the
1406        // `branchesTrue` block and a per-file entry the check ignores.
1407        let json = r#"{
1408            "total": {
1409                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1410                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1411                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1412                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1413                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1414            },
1415            "/abs/widget.ts": {
1416                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1417            }
1418        }"#;
1419        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1420        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
1421        assert_eq!(report.total.lines.pct, Some(80.0));
1422        assert_eq!(report.total.branches.pct, Some(66.66));
1423        assert_eq!(report.total.functions.total, 2);
1424    }
1425
1426    #[test]
1427    fn parses_an_unknown_pct_as_unmeasured() {
1428        let json = r#"{"total": {
1429            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1430            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1431            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1432            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1433        }}"#;
1434        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1435        assert_eq!(report.total.lines.pct, None);
1436        assert_eq!(report.total.lines.total, 0);
1437    }
1438
1439    #[test]
1440    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1441        // vitest only ever writes a number or "Unknown"; anything else (here a
1442        // bool) is a malformed report, surfaced as an error rather than guessed.
1443        let json = r#"{"total":{
1444            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1445            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1446            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1447            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1448        }}"#;
1449        assert!(parse_vitest_report(json).is_err());
1450    }
1451
1452    fn rust_metric(percent: f64) -> LlvmCovMetric {
1453        LlvmCovMetric {
1454            count: 10,
1455            covered: 10,
1456            percent,
1457        }
1458    }
1459
1460    fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1461        LlvmCovReport {
1462            data: vec![LlvmCovData {
1463                totals: LlvmCovTotals {
1464                    regions: rust_metric(regions),
1465                    lines: rust_metric(lines),
1466                    functions: rust_metric(lines),
1467                    branches: None,
1468                },
1469            }],
1470        }
1471    }
1472
1473    /// Like [`rust_report`] with explicit functions/branches metrics — the opt-in
1474    /// floors' tests. `branches: (count, percent)` so the vacuous
1475    /// zero-denominator case is constructible.
1476    fn rust_report_full(
1477        regions: f64,
1478        lines: f64,
1479        functions: f64,
1480        branches: (u64, f64),
1481    ) -> LlvmCovReport {
1482        let (count, percent) = branches;
1483        LlvmCovReport {
1484            data: vec![LlvmCovData {
1485                totals: LlvmCovTotals {
1486                    regions: rust_metric(regions),
1487                    lines: rust_metric(lines),
1488                    functions: rust_metric(functions),
1489                    branches: Some(LlvmCovMetric {
1490                        count,
1491                        covered: count,
1492                        percent,
1493                    }),
1494                },
1495            }],
1496        }
1497    }
1498
1499    const RUST_FULL: RustThresholds = RustThresholds {
1500        regions: Some(100),
1501        lines: 100,
1502        functions: None,
1503        branch: None,
1504    };
1505    const RUST_MID: RustThresholds = RustThresholds {
1506        regions: Some(80),
1507        lines: 85,
1508        functions: None,
1509        branch: None,
1510    };
1511
1512    #[test]
1513    fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1514        // The opt-in functions floor is judged on the export's functions
1515        // total: 66.67% fails a 100 floor naming the metric, and clears 60.
1516        let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1517        let floor = |functions| RustThresholds {
1518            regions: None,
1519            lines: 50,
1520            functions: Some(functions),
1521            branch: None,
1522        };
1523        assert!(matches!(
1524            evaluate_rust(&report, floor(100)),
1525            Outcome::Fail(message) if message.contains("functions")
1526        ));
1527        assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1528    }
1529
1530    #[test]
1531    fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1532        // The opt-in branch floor is judged on the branches total of a
1533        // `--branch` run: 50% fails a 100 floor naming the metric, and clears 50.
1534        let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1535        let floor = |branch| RustThresholds {
1536            regions: None,
1537            lines: 50,
1538            functions: None,
1539            branch: Some(branch),
1540        };
1541        assert!(matches!(
1542            evaluate_rust(&report, floor(100)),
1543            Outcome::Fail(message) if message.contains("branches")
1544        ));
1545        assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1546    }
1547
1548    #[test]
1549    fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1550        // A successful `--branch` run over a crate with no branch points reports a
1551        // zero branch denominator — vacuously satisfied, mirroring the diff-scoped
1552        // floors' empty-denominator rule (a failed instrumentation is a run error,
1553        // never a zero count here).
1554        let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1555        let floor = RustThresholds {
1556            regions: None,
1557            lines: 50,
1558            functions: None,
1559            branch: Some(100),
1560        };
1561        assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1562    }
1563
1564    #[test]
1565    fn rust_passes_when_both_metrics_meet_their_floor() {
1566        assert_eq!(
1567            evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1568            Outcome::Pass
1569        );
1570    }
1571
1572    #[test]
1573    fn rust_fails_on_the_one_metric_below_its_floor() {
1574        // 100% lines but only 70% regions: the regions floor catches what line
1575        // coverage misses — and only `regions` is named, not the metric that met
1576        // its floor.
1577        let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1578        assert!(
1579            matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1580            "got: {outcome:?}"
1581        );
1582    }
1583
1584    #[test]
1585    fn rust_fail_message_names_every_metric_below() {
1586        let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1587        assert!(
1588            matches!(&outcome, Outcome::Fail(message)
1589                if message.contains("regions") && message.contains("lines")),
1590            "got: {outcome:?}"
1591        );
1592    }
1593
1594    #[test]
1595    fn rust_skips_the_region_check_when_regions_is_opt_out() {
1596        // The zero-config default sets `regions: None`, so only lines are
1597        // enforced: a crate at 100% lines clears the floor even with low regions.
1598        let thresholds = RustThresholds {
1599            regions: None,
1600            lines: 100,
1601            functions: None,
1602            branch: None,
1603        };
1604        assert_eq!(
1605            evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1606            Outcome::Pass
1607        );
1608    }
1609
1610    #[test]
1611    fn rust_still_fails_lines_with_regions_opt_out() {
1612        // `regions: None` skips only the region check — the line floor still bites.
1613        let thresholds = RustThresholds {
1614            regions: None,
1615            lines: 100,
1616            functions: None,
1617            branch: None,
1618        };
1619        let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1620        assert!(
1621            matches!(&outcome, Outcome::Fail(message)
1622                if message.contains("lines") && !message.contains("regions")),
1623            "got: {outcome:?}"
1624        );
1625    }
1626
1627    #[test]
1628    fn rust_tolerates_float_noise_at_the_floor() {
1629        // A percent a hair under the floor from rounding still passes.
1630        assert_eq!(
1631            evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1632            Outcome::Pass
1633        );
1634    }
1635
1636    #[test]
1637    fn rust_fails_a_vacuous_run_that_measured_no_code() {
1638        // No regions in the denominator (a wrong path, or a crate that compiled
1639        // nothing): a vacuous run is a failure, never a silent pass.
1640        let nothing = LlvmCovMetric {
1641            count: 0,
1642            covered: 0,
1643            percent: 0.0,
1644        };
1645        let report = LlvmCovReport {
1646            data: vec![LlvmCovData {
1647                totals: LlvmCovTotals {
1648                    regions: nothing,
1649                    lines: nothing,
1650                    functions: nothing,
1651                    branches: None,
1652                },
1653            }],
1654        };
1655        let outcome = evaluate_rust(&report, RUST_MID);
1656        assert!(
1657            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1658            "got: {outcome:?}"
1659        );
1660    }
1661
1662    #[test]
1663    fn rust_fails_an_export_with_no_data() {
1664        // `cargo llvm-cov` always emits one `data` entry; an empty array is a
1665        // malformed run, failed rather than treated as a pass.
1666        let report = LlvmCovReport { data: vec![] };
1667        assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1668    }
1669
1670    #[test]
1671    fn parses_a_cargo_llvm_cov_report() {
1672        // A realistic `--json --summary-only` export: regions/lines (enforced) plus
1673        // the functions block and the `type`/`version` the check ignores.
1674        let json = r#"{
1675            "data": [{"totals": {
1676                "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1677                "lines": {"count": 20, "covered": 18, "percent": 90.0},
1678                "functions": {"count": 3, "covered": 3, "percent": 100.0}
1679            }}],
1680            "type": "llvm.coverage.json.export",
1681            "version": "2.0.1"
1682        }"#;
1683        let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1684        assert_eq!(report.data[0].totals.regions.percent, 75.0);
1685        assert_eq!(report.data[0].totals.lines.count, 20);
1686    }
1687
1688    #[test]
1689    fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1690        // A realistic full `--json` export: one function spanning two regions on
1691        // `/abs/grade.rs` — line 6 covered (execCount 1), line 10 the uncovered
1692        // `else` arm (execCount 0). Both are `kind == 0` code regions, indexed back
1693        // to `filenames[0]`.
1694        let json = r#"{
1695            "data": [{
1696                "files": [{"filename": "/abs/grade.rs"}],
1697                "functions": [{
1698                    "filenames": ["/abs/grade.rs"],
1699                    "regions": [
1700                        [6, 5, 6, 26, 1, 0, 0, 0],
1701                        [10, 9, 10, 17, 0, 0, 0, 0]
1702                    ]
1703                }],
1704                "totals": {}
1705            }],
1706            "type": "llvm.coverage.json.export",
1707            "version": "3.0.1"
1708        }"#;
1709        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1710        assert_eq!(
1711            out["/abs/grade.rs"].regions,
1712            vec![(6, 6, true), (10, 10, false)]
1713        );
1714    }
1715
1716    #[test]
1717    fn llvm_cov_patch_detail_skips_non_code_regions() {
1718        // Only `kind == 0` counts: a gap region (kind 1) and an expansion region
1719        // (kind 2) on the same function are ignored, leaving just the one code region.
1720        let json = r#"{
1721            "data": [{
1722                "files": [{"filename": "/abs/a.rs"}],
1723                "functions": [{
1724                    "filenames": ["/abs/a.rs"],
1725                    "regions": [
1726                        [1, 1, 1, 10, 2, 0, 0, 0],
1727                        [2, 1, 2, 10, 0, 0, 0, 1],
1728                        [3, 1, 3, 10, 0, 0, 0, 2]
1729                    ]
1730                }]
1731            }]
1732        }"#;
1733        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1734        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1735    }
1736
1737    #[test]
1738    fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1739        // A region's `fileID` (index 5) selects its file from the function's
1740        // `filenames`; two regions under the same function land in different files.
1741        let json = r#"{
1742            "data": [{
1743                "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1744                "functions": [{
1745                    "filenames": ["/abs/a.rs", "/abs/b.rs"],
1746                    "regions": [
1747                        [1, 1, 1, 5, 1, 0, 0, 0],
1748                        [9, 1, 9, 5, 0, 1, 1, 0]
1749                    ]
1750                }]
1751            }]
1752        }"#;
1753        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1754        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1755        assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1756    }
1757
1758    #[test]
1759    fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1760        // A region array shorter than the eight fields (never seen from llvm-cov) is
1761        // skipped rather than panicking on an index; the well-formed one survives.
1762        let json = r#"{
1763            "data": [{
1764                "files": [{"filename": "/abs/a.rs"}],
1765                "functions": [{
1766                    "filenames": ["/abs/a.rs"],
1767                    "regions": [
1768                        [4, 1, 4],
1769                        [5, 1, 5, 9, 1, 0, 0, 0]
1770                    ]
1771                }]
1772            }]
1773        }"#;
1774        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1775        assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1776    }
1777
1778    #[test]
1779    fn llvm_cov_patch_detail_spans_a_multiline_region() {
1780        // A region spanning lines 3–5 keeps both endpoints, so a changed line
1781        // anywhere in 3..=5 can count it.
1782        let json = r#"{
1783            "data": [{
1784                "files": [{"filename": "/abs/a.rs"}],
1785                "functions": [{
1786                    "filenames": ["/abs/a.rs"],
1787                    "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1788                }]
1789            }]
1790        }"#;
1791        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1792        assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1793    }
1794
1795    #[test]
1796    fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1797        // `--ignore-filename-regex` drops an exempt file from `files` but leaves its
1798        // regions in `functions`; restricting to the `files` allowlist lifts them, so
1799        // the ignored file contributes nothing while the kept file still does.
1800        let json = r#"{
1801            "data": [{
1802                "files": [{"filename": "/abs/kept.rs"}],
1803                "functions": [{
1804                    "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1805                    "regions": [
1806                        [1, 1, 1, 9, 1, 0, 0, 0],
1807                        [2, 1, 2, 9, 0, 1, 0, 0]
1808                    ]
1809                }]
1810            }]
1811        }"#;
1812        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1813        assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1814        assert!(!out.contains_key("/abs/ignored.rs"));
1815    }
1816
1817    #[test]
1818    fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1819        assert!(llvm_cov_patch_detail("{ not json").is_err());
1820    }
1821
1822    #[test]
1823    fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1824        assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
1825    }
1826
1827    #[test]
1828    fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
1829        // The caller passes already-resolved, `root`-relative paths; each is joined
1830        // under `root`, regex-escaped (the `.` becomes `\.`), and `$`-anchored, then
1831        // joined into one alternation. (`/repo` doesn't exist, so `canonicalize`
1832        // falls back to the plain join — deterministic for the assertion.)
1833        let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
1834        assert_eq!(
1835            ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
1836            Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
1837        );
1838    }
1839
1840    /// Model llvm-cov's substring `--ignore-filename-regex` for the fully-escaped,
1841    /// optionally end-anchored literals this tool emits: unescape the literal, honor a
1842    /// trailing `$` end-anchor, else substring-match. One matching alternative ignores
1843    /// the file.
1844    fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
1845        regex.split('|').any(|alt| {
1846            let (lit, anchored) = match alt.strip_suffix('$') {
1847                Some(head) => (head, true),
1848                None => (alt, false),
1849            };
1850            let lit = lit.replace('\\', "");
1851            if anchored {
1852                filename.ends_with(&lit)
1853            } else {
1854                filename.contains(&lit)
1855            }
1856        })
1857    }
1858
1859    #[test]
1860    fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
1861        // llvm-cov's `--ignore-filename-regex` is a substring search, so an entry for a
1862        // top-level `src/a.rs` must not also drop a workspace member's `member/src/a.rs`
1863        // — nor a nested `src/xsrc/a.rs` that merely shares the suffix.
1864        let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
1865        // The exempted file itself is still dropped.
1866        assert!(
1867            llvm_would_ignore(&regex, "/repo/src/a.rs"),
1868            "the exempted file must still be ignored: {regex}"
1869        );
1870        assert!(
1871            !llvm_would_ignore(&regex, "/repo/member/src/a.rs"),
1872            "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
1873        );
1874        assert!(
1875            !llvm_would_ignore(&regex, "/repo/src/xsrc/a.rs"),
1876            "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
1877        );
1878    }
1879}