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.
729        .arg("--lib")
730        .args(format)
731        .env("CARGO_TARGET_DIR", &target.0);
732    if !features.is_empty() {
733        command.arg("--features").arg(features.join(","));
734    }
735    if branch {
736        // Instruments only on a nightly toolchain — the error below names that.
737        command.arg("--branch");
738    }
739    if let Some(regex) = ignore_filename_regex(root, ignore) {
740        command.arg("--ignore-filename-regex").arg(regex);
741    }
742    // When this check runs under an outer `cargo llvm-cov`, an inherited
743    // `RUSTC_WRAPPER` makes the inner run re-enter cargo-llvm-cov on every rustc
744    // invocation and hang until the runner is OOM-killed. Strip the outer state.
745    for var in [
746        "RUSTFLAGS",
747        "CARGO_ENCODED_RUSTFLAGS",
748        "RUSTDOCFLAGS",
749        "CARGO_ENCODED_RUSTDOCFLAGS",
750        "LLVM_PROFILE_FILE",
751        "CARGO_LLVM_COV",
752        "CARGO_LLVM_COV_SHOW_ENV",
753        "CARGO_LLVM_COV_TARGET_DIR",
754        "CARGO_LLVM_COV_BUILD_DIR",
755        "RUSTC_WRAPPER",
756        "RUSTC_WORKSPACE_WRAPPER",
757        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
758        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
759        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
760        // rustup gives an inherited toolchain selection precedence over the scanned
761        // crate's own `rust-toolchain.toml`, so a spawning cargo would override the
762        // nightly a branch-floor crate pins there.
763        "RUSTUP_TOOLCHAIN",
764        "CARGO",
765        "RUSTC",
766    ] {
767        command.env_remove(var);
768    }
769    let output = command
770        .output()
771        .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
772    if !output.status.success() {
773        let hint = if branch {
774            "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
775             nightly toolchain — pin one in the crate's rust-toolchain.toml with \
776             llvm-tools-preview, or set a rustup directory override)"
777        } else {
778            ""
779        };
780        bail!(
781            "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{hint}\n{}{}",
782            root.display(),
783            String::from_utf8_lossy(&output.stdout),
784            String::from_utf8_lossy(&output.stderr),
785        );
786    }
787    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
788}
789
790/// Per-file region detail from a `cargo llvm-cov --json` export — what
791/// [`crate::patch_coverage::evaluate_patch_rust`] restricts to the changed lines.
792#[derive(Debug, Clone, Default)]
793pub struct RustPatchCoverage {
794    /// One per `kind == 0` code region: `(start_line, end_line, covered)`. A region
795    /// counts toward the diff when any line it spans is changed.
796    pub regions: Vec<(u64, u64, bool)>,
797}
798
799/// A full `cargo llvm-cov --json` export, modeling the per-function region detail the
800/// diff-scoped floor needs — separate from [`LlvmCovReport`], which keeps the totals.
801#[derive(Debug, Clone, Deserialize)]
802struct LlvmCovExport {
803    data: Vec<LlvmCovExportData>,
804}
805
806/// One export entry. `--ignore-filename-regex` drops an exempt file from `files` but
807/// *not* from `functions` (the regions array is unfiltered), so `files` is the
808/// allowlist [`llvm_cov_patch_detail`] restricts the regions to.
809#[derive(Debug, Clone, Deserialize)]
810struct LlvmCovExportData {
811    files: Vec<LlvmCovExportFile>,
812    functions: Vec<LlvmCovFunction>,
813}
814
815/// One measured file in the export's `files` block — only its absolute `filename` is
816/// needed, to build the not-ignored allowlist.
817#[derive(Debug, Clone, Deserialize)]
818struct LlvmCovExportFile {
819    filename: String,
820}
821
822/// One function's coverage: the files it spans (`filenames`, indexed by a region's
823/// `fileID`) and its regions. Each region is a flat array `[lineStart, colStart,
824/// lineEnd, colEnd, executionCount, fileID, expandedFileID, kind]`, read positionally.
825#[derive(Debug, Clone, Deserialize)]
826struct LlvmCovFunction {
827    filenames: Vec<String>,
828    regions: Vec<Vec<i64>>,
829}
830
831/// Run the Rust unit suite under `cargo llvm-cov` and return the per-file region
832/// detail, keyed by the absolute path llvm-cov reports. `ignore` is the
833/// `coverage`-rule exemptions, dropped so an exempt file's changed lines are lifted.
834pub fn measure_patch_rust_detail(
835    root: &Path,
836    ignore: &[String],
837    features: &[String],
838) -> Result<BTreeMap<String, RustPatchCoverage>> {
839    // The diff-scoped floor judges regions + lines, so its run never adds `--branch`.
840    let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
841    llvm_cov_patch_detail(&json)
842}
843
844/// Pure: per-file [`RustPatchCoverage`] from a `cargo llvm-cov --json` export, keyed
845/// by the absolute path llvm-cov reports. Only `kind == 0` code regions in the `files`
846/// allowlist count; a malformed short region is skipped rather than indexed.
847fn llvm_cov_patch_detail(json: &str) -> Result<BTreeMap<String, RustPatchCoverage>> {
848    let export: LlvmCovExport =
849        serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")?;
850    let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
851    for data in &export.data {
852        let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
853        for function in &data.functions {
854            for region in &function.regions {
855                if region.len() < 8 {
856                    continue;
857                }
858                // gap (1) / expansion (2) / branch regions carry no line-coverage signal.
859                if region[7] != 0 {
860                    continue;
861                }
862                let file_id = region[5];
863                let Ok(file_id) = usize::try_from(file_id) else {
864                    continue;
865                };
866                let Some(file) = function.filenames.get(file_id) else {
867                    continue;
868                };
869                // A `coverage` exemption drops the file's regions, lifting its lines.
870                if !measured.contains(file.as_str()) {
871                    continue;
872                }
873                let start = region[0].max(0) as u64;
874                let end = region[2].max(0) as u64;
875                let covered = region[4] > 0;
876                out.entry(file.clone())
877                    .or_default()
878                    .regions
879                    .push((start, end, covered));
880            }
881        }
882    }
883    Ok(out)
884}
885
886/// The single `--ignore-filename-regex` for the run, or `None` when nothing is exempt.
887/// It is a substring search over absolute filenames, so each exempt path is escaped,
888/// joined under `root`, and `$`-anchored — else it over-matches `member/src/a.rs`.
889fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
890    if ignore.is_empty() {
891        return None;
892    }
893    Some(
894        ignore
895            .iter()
896            .map(|rel| {
897                // The fallback keeps the anchor deterministic when the path can't be
898                // resolved (e.g. in tests).
899                let full = root.join(rel);
900                let full = full.canonicalize().unwrap_or(full);
901                format!("{}$", regex_escape(&full.to_string_lossy()))
902            })
903            .collect::<Vec<_>>()
904            .join("|"),
905    )
906}
907
908/// Escape `s`'s regex metacharacters so an exempt path matches literally.
909fn regex_escape(s: &str) -> String {
910    const META: &str = r"\.+*?()|[]{}^$";
911    let mut out = String::with_capacity(s.len());
912    for c in s.chars() {
913        if META.contains(c) {
914            out.push('\\');
915        }
916        out.push(c);
917    }
918    out
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
926        CoverageReport {
927            totals: Totals {
928                percent_covered,
929                num_branches,
930            },
931            files: BTreeMap::new(),
932        }
933    }
934
935    #[test]
936    fn passes_when_total_meets_the_floor() {
937        assert_eq!(
938            evaluate(
939                &report(100.0, 12),
940                Thresholds {
941                    fail_under: 100,
942                    branch: true
943                }
944            ),
945            Outcome::Pass
946        );
947    }
948
949    #[test]
950    fn fails_when_total_is_below_the_floor() {
951        assert!(matches!(
952            evaluate(
953                &report(80.0, 12),
954                Thresholds {
955                    fail_under: 100,
956                    branch: true
957                }
958            ),
959            Outcome::Fail(_)
960        ));
961    }
962
963    #[test]
964    fn passes_when_branch_required_and_none_are_measured() {
965        assert_eq!(
966            evaluate(
967                &report(100.0, 0),
968                Thresholds {
969                    fail_under: 100,
970                    branch: true
971                }
972            ),
973            Outcome::Pass
974        );
975    }
976
977    #[test]
978    fn parses_a_coverage_py_report() {
979        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
980        let report = parse_report(json).expect("valid coverage.py json");
981        assert_eq!(report.totals.percent_covered, 91.5);
982        assert_eq!(report.totals.num_branches, 8);
983    }
984
985    #[test]
986    fn parses_the_per_file_block_for_patch_coverage() {
987        let json = r#"{
988            "files": {
989                "widget.py": {
990                    "executed_lines": [1, 2, 3, 4, 6],
991                    "summary": {"percent_covered": 85.0},
992                    "missing_lines": [5],
993                    "excluded_lines": [],
994                    "missing_branches": [[4, 5]]
995                }
996            },
997            "totals": {"percent_covered": 85.0, "num_branches": 4}
998        }"#;
999        let report = parse_report(json).expect("valid coverage.py json with files");
1000        let widget = report.files.get("widget.py").expect("widget.py is present");
1001        assert_eq!(widget.missing_lines, vec![5]);
1002        assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1003        assert_eq!(report.totals.percent_covered, 85.0);
1004    }
1005
1006    #[test]
1007    fn a_report_without_a_files_block_parses_with_an_empty_map() {
1008        let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1009            .expect("valid coverage.py json");
1010        assert!(report.files.is_empty());
1011    }
1012
1013    #[test]
1014    fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1015        assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1016    }
1017
1018    #[test]
1019    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1020        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1021        assert_eq!(
1022            build_omit(&exempt),
1023            "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1024        );
1025    }
1026
1027    fn metric(pct: f64) -> VitestMetric {
1028        VitestMetric {
1029            pct: Some(pct),
1030            total: 10,
1031        }
1032    }
1033
1034    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1035        VitestReport {
1036            total: VitestTotals {
1037                lines: metric(lines),
1038                branches: metric(branches),
1039                functions: metric(functions),
1040                statements: metric(statements),
1041            },
1042        }
1043    }
1044
1045    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1046        lines: 100,
1047        branches: 100,
1048        functions: 100,
1049        statements: 100,
1050    };
1051    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1052        lines: 80,
1053        branches: 75,
1054        functions: 80,
1055        statements: 80,
1056    };
1057
1058    #[test]
1059    fn typescript_passes_when_every_metric_meets_its_floor() {
1060        assert_eq!(
1061            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1062            Outcome::Pass
1063        );
1064    }
1065
1066    #[test]
1067    fn typescript_fails_on_the_one_metric_below_its_floor() {
1068        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1069        assert!(
1070            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1071            "got: {outcome:?}"
1072        );
1073    }
1074
1075    #[test]
1076    fn typescript_fail_message_names_every_metric_below() {
1077        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1078        assert!(
1079            matches!(&outcome, Outcome::Fail(message)
1080                if message.contains("lines")
1081                    && message.contains("branches")
1082                    && message.contains("functions")
1083                    && message.contains("statements")),
1084            "got: {outcome:?}"
1085        );
1086    }
1087
1088    #[test]
1089    fn typescript_tolerates_float_noise_at_the_floor() {
1090        assert_eq!(
1091            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1092            Outcome::Pass
1093        );
1094    }
1095
1096    #[test]
1097    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1098        let report = VitestReport {
1099            total: VitestTotals {
1100                lines: metric(100.0),
1101                branches: VitestMetric {
1102                    pct: None,
1103                    total: 0,
1104                },
1105                functions: metric(100.0),
1106                statements: metric(100.0),
1107            },
1108        };
1109        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1110    }
1111
1112    #[test]
1113    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1114        let nothing = VitestMetric {
1115            pct: None,
1116            total: 0,
1117        };
1118        let report = VitestReport {
1119            total: VitestTotals {
1120                lines: nothing,
1121                branches: nothing,
1122                functions: nothing,
1123                statements: nothing,
1124            },
1125        };
1126        let outcome = evaluate_typescript(&report, TS_MID);
1127        assert!(
1128            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1129            "got: {outcome:?}"
1130        );
1131    }
1132
1133    #[test]
1134    fn parses_a_vitest_summary_report() {
1135        let json = r#"{
1136            "total": {
1137                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1138                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1139                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1140                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1141                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1142            },
1143            "/abs/widget.ts": {
1144                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1145            }
1146        }"#;
1147        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1148        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
1149        assert_eq!(report.total.lines.pct, Some(80.0));
1150        assert_eq!(report.total.branches.pct, Some(66.66));
1151        assert_eq!(report.total.functions.total, 2);
1152    }
1153
1154    #[test]
1155    fn parses_an_unknown_pct_as_unmeasured() {
1156        let json = r#"{"total": {
1157            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1158            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1159            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1160            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1161        }}"#;
1162        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1163        assert_eq!(report.total.lines.pct, None);
1164        assert_eq!(report.total.lines.total, 0);
1165    }
1166
1167    #[test]
1168    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1169        let json = r#"{"total":{
1170            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1171            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1172            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1173            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1174        }}"#;
1175        assert!(parse_vitest_report(json).is_err());
1176    }
1177
1178    fn rust_metric(percent: f64) -> LlvmCovMetric {
1179        LlvmCovMetric {
1180            count: 10,
1181            covered: 10,
1182            percent,
1183        }
1184    }
1185
1186    fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1187        LlvmCovReport {
1188            data: vec![LlvmCovData {
1189                totals: LlvmCovTotals {
1190                    regions: rust_metric(regions),
1191                    lines: rust_metric(lines),
1192                    functions: rust_metric(lines),
1193                    branches: None,
1194                },
1195            }],
1196        }
1197    }
1198
1199    /// Like [`rust_report`] with explicit functions/branches; `branches: (count,
1200    /// percent)` so the vacuous zero-denominator case is constructible.
1201    fn rust_report_full(
1202        regions: f64,
1203        lines: f64,
1204        functions: f64,
1205        branches: (u64, f64),
1206    ) -> LlvmCovReport {
1207        let (count, percent) = branches;
1208        LlvmCovReport {
1209            data: vec![LlvmCovData {
1210                totals: LlvmCovTotals {
1211                    regions: rust_metric(regions),
1212                    lines: rust_metric(lines),
1213                    functions: rust_metric(functions),
1214                    branches: Some(LlvmCovMetric {
1215                        count,
1216                        covered: count,
1217                        percent,
1218                    }),
1219                },
1220            }],
1221        }
1222    }
1223
1224    const RUST_FULL: RustThresholds = RustThresholds {
1225        regions: Some(100),
1226        lines: 100,
1227        functions: None,
1228        branch: None,
1229    };
1230    const RUST_MID: RustThresholds = RustThresholds {
1231        regions: Some(80),
1232        lines: 85,
1233        functions: None,
1234        branch: None,
1235    };
1236
1237    #[test]
1238    fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1239        let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1240        let floor = |functions| RustThresholds {
1241            regions: None,
1242            lines: 50,
1243            functions: Some(functions),
1244            branch: None,
1245        };
1246        assert!(matches!(
1247            evaluate_rust(&report, floor(100)),
1248            Outcome::Fail(message) if message.contains("functions")
1249        ));
1250        assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1251    }
1252
1253    #[test]
1254    fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1255        let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1256        let floor = |branch| RustThresholds {
1257            regions: None,
1258            lines: 50,
1259            functions: None,
1260            branch: Some(branch),
1261        };
1262        assert!(matches!(
1263            evaluate_rust(&report, floor(100)),
1264            Outcome::Fail(message) if message.contains("branches")
1265        ));
1266        assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1267    }
1268
1269    #[test]
1270    fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1271        let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1272        let floor = RustThresholds {
1273            regions: None,
1274            lines: 50,
1275            functions: None,
1276            branch: Some(100),
1277        };
1278        assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1279    }
1280
1281    #[test]
1282    fn rust_passes_when_both_metrics_meet_their_floor() {
1283        assert_eq!(
1284            evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1285            Outcome::Pass
1286        );
1287    }
1288
1289    #[test]
1290    fn rust_fails_on_the_one_metric_below_its_floor() {
1291        let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1292        assert!(
1293            matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1294            "got: {outcome:?}"
1295        );
1296    }
1297
1298    #[test]
1299    fn rust_fail_message_names_every_metric_below() {
1300        let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1301        assert!(
1302            matches!(&outcome, Outcome::Fail(message)
1303                if message.contains("regions") && message.contains("lines")),
1304            "got: {outcome:?}"
1305        );
1306    }
1307
1308    #[test]
1309    fn rust_skips_the_region_check_when_regions_is_opt_out() {
1310        let thresholds = RustThresholds {
1311            regions: None,
1312            lines: 100,
1313            functions: None,
1314            branch: None,
1315        };
1316        assert_eq!(
1317            evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1318            Outcome::Pass
1319        );
1320    }
1321
1322    #[test]
1323    fn rust_still_fails_lines_with_regions_opt_out() {
1324        let thresholds = RustThresholds {
1325            regions: None,
1326            lines: 100,
1327            functions: None,
1328            branch: None,
1329        };
1330        let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1331        assert!(
1332            matches!(&outcome, Outcome::Fail(message)
1333                if message.contains("lines") && !message.contains("regions")),
1334            "got: {outcome:?}"
1335        );
1336    }
1337
1338    #[test]
1339    fn rust_tolerates_float_noise_at_the_floor() {
1340        assert_eq!(
1341            evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1342            Outcome::Pass
1343        );
1344    }
1345
1346    #[test]
1347    fn rust_fails_a_vacuous_run_that_measured_no_code() {
1348        let nothing = LlvmCovMetric {
1349            count: 0,
1350            covered: 0,
1351            percent: 0.0,
1352        };
1353        let report = LlvmCovReport {
1354            data: vec![LlvmCovData {
1355                totals: LlvmCovTotals {
1356                    regions: nothing,
1357                    lines: nothing,
1358                    functions: nothing,
1359                    branches: None,
1360                },
1361            }],
1362        };
1363        let outcome = evaluate_rust(&report, RUST_MID);
1364        assert!(
1365            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1366            "got: {outcome:?}"
1367        );
1368    }
1369
1370    #[test]
1371    fn rust_fails_an_export_with_no_data() {
1372        let report = LlvmCovReport { data: vec![] };
1373        assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1374    }
1375
1376    #[test]
1377    fn parses_a_cargo_llvm_cov_report() {
1378        let json = r#"{
1379            "data": [{"totals": {
1380                "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1381                "lines": {"count": 20, "covered": 18, "percent": 90.0},
1382                "functions": {"count": 3, "covered": 3, "percent": 100.0}
1383            }}],
1384            "type": "llvm.coverage.json.export",
1385            "version": "2.0.1"
1386        }"#;
1387        let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1388        assert_eq!(report.data[0].totals.regions.percent, 75.0);
1389        assert_eq!(report.data[0].totals.lines.count, 20);
1390    }
1391
1392    #[test]
1393    fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1394        let json = r#"{
1395            "data": [{
1396                "files": [{"filename": "/abs/grade.rs"}],
1397                "functions": [{
1398                    "filenames": ["/abs/grade.rs"],
1399                    "regions": [
1400                        [6, 5, 6, 26, 1, 0, 0, 0],
1401                        [10, 9, 10, 17, 0, 0, 0, 0]
1402                    ]
1403                }],
1404                "totals": {}
1405            }],
1406            "type": "llvm.coverage.json.export",
1407            "version": "3.0.1"
1408        }"#;
1409        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1410        assert_eq!(
1411            out["/abs/grade.rs"].regions,
1412            vec![(6, 6, true), (10, 10, false)]
1413        );
1414    }
1415
1416    #[test]
1417    fn llvm_cov_patch_detail_skips_non_code_regions() {
1418        let json = r#"{
1419            "data": [{
1420                "files": [{"filename": "/abs/a.rs"}],
1421                "functions": [{
1422                    "filenames": ["/abs/a.rs"],
1423                    "regions": [
1424                        [1, 1, 1, 10, 2, 0, 0, 0],
1425                        [2, 1, 2, 10, 0, 0, 0, 1],
1426                        [3, 1, 3, 10, 0, 0, 0, 2]
1427                    ]
1428                }]
1429            }]
1430        }"#;
1431        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1432        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1433    }
1434
1435    #[test]
1436    fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1437        let json = r#"{
1438            "data": [{
1439                "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1440                "functions": [{
1441                    "filenames": ["/abs/a.rs", "/abs/b.rs"],
1442                    "regions": [
1443                        [1, 1, 1, 5, 1, 0, 0, 0],
1444                        [9, 1, 9, 5, 0, 1, 1, 0]
1445                    ]
1446                }]
1447            }]
1448        }"#;
1449        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1450        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1451        assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1452    }
1453
1454    #[test]
1455    fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1456        let json = r#"{
1457            "data": [{
1458                "files": [{"filename": "/abs/a.rs"}],
1459                "functions": [{
1460                    "filenames": ["/abs/a.rs"],
1461                    "regions": [
1462                        [4, 1, 4],
1463                        [5, 1, 5, 9, 1, 0, 0, 0]
1464                    ]
1465                }]
1466            }]
1467        }"#;
1468        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1469        assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1470    }
1471
1472    #[test]
1473    fn llvm_cov_patch_detail_spans_a_multiline_region() {
1474        let json = r#"{
1475            "data": [{
1476                "files": [{"filename": "/abs/a.rs"}],
1477                "functions": [{
1478                    "filenames": ["/abs/a.rs"],
1479                    "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1480                }]
1481            }]
1482        }"#;
1483        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1484        assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1485    }
1486
1487    #[test]
1488    fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1489        let json = r#"{
1490            "data": [{
1491                "files": [{"filename": "/abs/kept.rs"}],
1492                "functions": [{
1493                    "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1494                    "regions": [
1495                        [1, 1, 1, 9, 1, 0, 0, 0],
1496                        [2, 1, 2, 9, 0, 1, 0, 0]
1497                    ]
1498                }]
1499            }]
1500        }"#;
1501        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1502        assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1503        assert!(!out.contains_key("/abs/ignored.rs"));
1504    }
1505
1506    #[test]
1507    fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1508        assert!(llvm_cov_patch_detail("{ not json").is_err());
1509    }
1510
1511    #[test]
1512    fn llvm_cov_patch_detail_skips_a_negative_file_id() {
1513        let json = r#"{
1514            "data": [{
1515                "files": [{"filename": "/abs/a.rs"}],
1516                "functions": [{
1517                    "filenames": ["/abs/a.rs"],
1518                    "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
1519                }]
1520            }]
1521        }"#;
1522        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1523        assert!(out.is_empty(), "got: {out:?}");
1524    }
1525
1526    #[test]
1527    fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
1528        let json = r#"{
1529            "data": [{
1530                "files": [{"filename": "/abs/a.rs"}],
1531                "functions": [{
1532                    "filenames": ["/abs/a.rs"],
1533                    "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
1534                }]
1535            }]
1536        }"#;
1537        let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1538        assert!(out.is_empty(), "got: {out:?}");
1539    }
1540
1541    #[test]
1542    fn istanbul_patch_detail_reads_statements_arms_and_functions() {
1543        let json = r#"{
1544            "/abs/a.ts": {
1545                "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
1546                "s": {"0": 1},
1547                "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1548                "b": {"0": [1, 0]},
1549                "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
1550                "f": {"0": 0}
1551            }
1552        }"#;
1553        let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1554        let detail = &out["/abs/a.ts"];
1555        assert_eq!(detail.statements, vec![(1, 2, true)]);
1556        assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
1557        assert_eq!(detail.functions, vec![(7, false)]);
1558    }
1559
1560    #[test]
1561    fn istanbul_patch_detail_keeps_a_branch_without_counts() {
1562        let json = r#"{
1563            "/abs/a.ts": {
1564                "statementMap": {},
1565                "s": {},
1566                "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1567                "b": {},
1568                "fnMap": {},
1569                "f": {}
1570            }
1571        }"#;
1572        let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1573        assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
1574    }
1575
1576    #[test]
1577    fn default_excludes_that_are_not_json_name_the_output() {
1578        let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
1579        let msg = format!("{err:#}");
1580        assert!(msg.contains("not a JSON string array"), "got: {msg}");
1581        assert!(msg.contains("vitest warmed up first"), "got: {msg}");
1582    }
1583
1584    #[test]
1585    fn default_excludes_drop_a_nul_bearing_pattern() {
1586        let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
1587        assert_eq!(parsed, vec!["**/dist/**".to_string()]);
1588    }
1589
1590    #[test]
1591    fn a_missing_vitest_report_names_the_reporter() {
1592        let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
1593        let err = read_vitest_report(&path, "json").unwrap_err();
1594        assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
1595    }
1596
1597    #[test]
1598    fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1599        assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
1600    }
1601
1602    #[test]
1603    fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
1604        // `/repo` doesn't exist, so `canonicalize` falls back to the plain join.
1605        let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
1606        assert_eq!(
1607            ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
1608            Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
1609        );
1610    }
1611
1612    /// Model llvm-cov's substring `--ignore-filename-regex` for the escaped, optionally
1613    /// `$`-anchored literals this tool emits. One matching alternative ignores the file.
1614    fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
1615        regex.split('|').any(|alt| {
1616            let (lit, anchored) = match alt.strip_suffix('$') {
1617                Some(head) => (head, true),
1618                None => (alt, false),
1619            };
1620            let lit = lit.replace('\\', "");
1621            if anchored {
1622                filename.ends_with(&lit)
1623            } else {
1624                filename.contains(&lit)
1625            }
1626        })
1627    }
1628
1629    #[test]
1630    fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
1631        assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
1632        assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
1633    }
1634
1635    #[test]
1636    fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
1637        let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
1638        assert!(
1639            llvm_would_ignore(&regex, "/repo/src/a.rs"),
1640            "the exempted file must still be ignored: {regex}"
1641        );
1642        assert!(
1643            !llvm_would_ignore(&regex, "/repo/member/src/a.rs"),
1644            "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
1645        );
1646        assert!(
1647            !llvm_would_ignore(&regex, "/repo/src/xsrc/a.rs"),
1648            "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
1649        );
1650    }
1651}