Skip to main content

testing_conventions/
coverage.rs

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