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