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