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