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