Skip to main content

testing_conventions/
coverage.rs

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