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