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, Default, 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, Default, 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 totals the floor checks, less the items a `#[cfg(not(test))]` gate keeps out of the
695/// test build. `branch` adds `--branch` for a configured branch floor. The run exports in
696/// full rather than `--summary-only`, since the per-function detail is what locates those
697/// items in the totals.
698fn run_llvm_cov(
699    root: &Path,
700    ignore: &[String],
701    features: &[String],
702    branch: bool,
703) -> Result<LlvmCovReport> {
704    let (json, lcov) = run_cargo_llvm_cov_json_and_lcov(root, ignore, features, branch)?;
705    let hidden = hidden_lines_by_file(&json)?;
706    let mut report = llvm_cov_totals_less_hidden(&parse_llvm_cov_export(&json)?, &hidden);
707    let lines = lcov_lines_metric(&lcov, &hidden);
708    for data in &mut report.data {
709        data.totals.lines = lines;
710    }
711    Ok(report)
712}
713
714/// Every `DA:` record of an lcov export, as `line -> covered` per source file.
715fn lcov_lines(lcov: &str) -> BTreeMap<String, BTreeMap<u32, bool>> {
716    let mut out: BTreeMap<String, BTreeMap<u32, bool>> = BTreeMap::new();
717    let mut file: Option<&str> = None;
718    for record in lcov.lines() {
719        if let Some(name) = record.strip_prefix("SF:") {
720            file = Some(name);
721        } else if let Some((number, count)) =
722            record.strip_prefix("DA:").and_then(|da| da.split_once(','))
723        {
724            let (Some(file), Ok(number), Ok(count)) =
725                (file, number.parse::<u32>(), count.trim().parse::<u64>())
726            else {
727                continue;
728            };
729            *out.entry(file.to_string())
730                .or_default()
731                .entry(number)
732                .or_default() |= count > 0;
733        }
734    }
735    out
736}
737
738/// The line metric an lcov export reports, less the lines a `#[cfg(not(test))]` gate hides.
739///
740/// llvm-cov writes `DA:` records through the merged line view that `show`, `--text` and
741/// `--show-missing-lines` all read, where a line maps once however many instantiations carry
742/// it. A `--json` export's `totals` instead sum each instantiation group's own line tally, so a
743/// line two groups map counts twice — once covered and once not, when only one group ran it.
744/// That is how a 100% floor became unsatisfiable on a file where no view could name a missing
745/// line (#743): a crate built `--lib --bins` compiles its library twice.
746fn lcov_lines_metric(lcov: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovMetric {
747    let mut tally = Tally::default();
748    for (file, lines) in lcov_lines(lcov) {
749        let gated = hidden.get(&file);
750        for (number, covered) in lines {
751            if gated.is_some_and(|gated| gated.contains(&number)) {
752                continue;
753            }
754            tally.count += 1;
755            tally.covered += u64::from(covered);
756        }
757    }
758    as_metric(tally.count, tally.covered)
759}
760
761/// The 1-based lines a `#[cfg(not(test))]` gate hides, per source file the export measured.
762///
763/// The unit tier runs `--lib --bins`, which sets `cfg(test)`, so no test can execute a gated
764/// item. The `--bins` half links the library a second time as a plain dependency of the binary
765/// target's test harness, where `cfg(test)` is unset: the item is compiled and instrumented
766/// there and lands as 0-hit, or not, depending on how the linker partitioned that build. An
767/// unreadable file hides nothing, leaving every line it maps in the ratios.
768fn hidden_lines_by_file(json: &str) -> Result<BTreeMap<String, BTreeSet<u32>>> {
769    let export = parse_llvm_cov_export(json)?;
770    let mut out = BTreeMap::new();
771    for data in &export.data {
772        for file in &data.files {
773            let Ok(source) = std::fs::read_to_string(&file.filename) else {
774                continue;
775            };
776            let lines = crate::isolation::lines_hidden_from_tests(&source);
777            if !lines.is_empty() {
778                out.insert(file.filename.clone(), lines);
779            }
780        }
781    }
782    Ok(out)
783}
784
785/// One metric's count and covered pair, the shape every llvm-cov metric reports.
786#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
787struct Tally {
788    count: u64,
789    covered: u64,
790}
791
792impl Tally {
793    /// llvm-cov merges the records of one instantiation group by `max`, so a delta over that
794    /// group does too — two copies of the same function subtract once.
795    fn merge(&mut self, other: Tally) {
796        self.count = self.count.max(other.count);
797        self.covered = self.covered.max(other.covered);
798    }
799
800    fn add(&mut self, other: Tally) {
801        self.count += other.count;
802        self.covered += other.covered;
803    }
804}
805
806/// What the items a `#[cfg(not(test))]` gate hides contribute to an export's totals.
807#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
808struct HiddenTotals {
809    regions: Tally,
810    lines: Tally,
811    functions: Tally,
812    branches: Tally,
813}
814
815impl HiddenTotals {
816    fn merge(&mut self, other: HiddenTotals) {
817        self.regions.merge(other.regions);
818        self.lines.merge(other.lines);
819        self.functions.merge(other.functions);
820        self.branches.merge(other.branches);
821    }
822
823    fn add(&mut self, other: HiddenTotals) {
824        self.regions.add(other.regions);
825        self.lines.add(other.lines);
826        self.functions.add(other.functions);
827        self.branches.add(other.branches);
828    }
829}
830
831/// Pure: the export's totals less what its gated items contribute.
832fn llvm_cov_totals_less_hidden(
833    export: &LlvmCovExport,
834    hidden: &BTreeMap<String, BTreeSet<u32>>,
835) -> LlvmCovReport {
836    LlvmCovReport {
837        data: export
838            .data
839            .iter()
840            .map(|data| LlvmCovData {
841                totals: totals_less_hidden(data, hidden),
842            })
843            .collect(),
844    }
845}
846
847/// One export entry's totals with the gated items' share taken out of every metric.
848fn totals_less_hidden(
849    data: &LlvmCovExportData,
850    hidden: &BTreeMap<String, BTreeSet<u32>>,
851) -> LlvmCovTotals {
852    let delta = hidden_totals(data, hidden);
853    LlvmCovTotals {
854        regions: metric_less(data.totals.regions, delta.regions),
855        lines: metric_less(data.totals.lines, delta.lines),
856        functions: metric_less(data.totals.functions, delta.functions),
857        branches: data
858            .totals
859            .branches
860            .map(|metric| metric_less(metric, delta.branches)),
861    }
862}
863
864/// One metric less the gated items' share, its percent recomputed.
865fn metric_less(metric: LlvmCovMetric, delta: Tally) -> LlvmCovMetric {
866    let count = metric.count.saturating_sub(delta.count);
867    as_metric(
868        count,
869        metric.covered.saturating_sub(delta.covered).min(count),
870    )
871}
872
873/// A metric from its pair. An empty denominator reads 100%: with nothing left to measure there
874/// is nothing left to miss.
875fn as_metric(count: u64, covered: u64) -> LlvmCovMetric {
876    let percent = if count == 0 {
877        100.0
878    } else {
879        covered as f64 * 100.0 / count as f64
880    };
881    LlvmCovMetric {
882        count,
883        covered,
884        percent,
885    }
886}
887
888/// The gated items' contribution to one export entry. llvm-cov groups function records by their
889/// first region's start location, so the `--bins` half's second copy of an ungated function
890/// merges with the first; a gated item has no such twin and stands alone as 0-hit.
891fn hidden_totals(
892    data: &LlvmCovExportData,
893    hidden: &BTreeMap<String, BTreeSet<u32>>,
894) -> HiddenTotals {
895    let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
896    let mut groups: BTreeMap<(String, i64, i64), HiddenTotals> = BTreeMap::new();
897    for function in &data.functions {
898        let Some((file, line, column)) = function_start(function) else {
899            continue;
900        };
901        if !measured.contains(file.as_str()) {
902            continue;
903        }
904        let Some(lines) = hidden.get(&file) else {
905            continue;
906        };
907        if !lines.contains(&(line.max(0) as u32)) {
908            continue;
909        }
910        groups
911            .entry((file, line, column))
912            .or_default()
913            .merge(function_totals(function, lines));
914    }
915    groups.values().fold(HiddenTotals::default(), |mut acc, g| {
916        acc.add(*g);
917        acc
918    })
919}
920
921/// The `(file, line, column)` llvm-cov groups a function record under — its first region's
922/// start. A record with no region, or one naming a file outside its own list, has no group.
923fn function_start(function: &LlvmCovFunction) -> Option<(String, i64, i64)> {
924    let region = function.regions.first()?;
925    if region.len() < 8 {
926        return None;
927    }
928    let file = function.filenames.get(usize::try_from(region[5]).ok()?)?;
929    Some((file.clone(), region[0], region[1]))
930}
931
932/// One record's share: its code regions, the gated source lines they map, its own execution,
933/// and the two outcomes llvm-cov counts per branch region.
934fn function_totals(function: &LlvmCovFunction, hidden: &BTreeSet<u32>) -> HiddenTotals {
935    let code: Vec<&Vec<i64>> = function
936        .regions
937        .iter()
938        .filter(|region| region.len() >= 8 && region[7] == 0)
939        .collect();
940    let mut lines: BTreeMap<u32, bool> = BTreeMap::new();
941    for region in &code {
942        for line in region[0].max(0) as u32..=region[2].max(0) as u32 {
943            if hidden.contains(&line) {
944                *lines.entry(line).or_default() |= region[4] > 0;
945            }
946        }
947    }
948    HiddenTotals {
949        regions: Tally {
950            count: code.len() as u64,
951            covered: code.iter().filter(|region| region[4] > 0).count() as u64,
952        },
953        lines: Tally {
954            count: lines.len() as u64,
955            covered: lines.values().filter(|covered| **covered).count() as u64,
956        },
957        functions: Tally {
958            count: 1,
959            covered: u64::from(function.count > 0),
960        },
961        branches: branch_tally(&function.branches),
962    }
963}
964
965/// Two outcomes per branch region: llvm-cov counts the true and false arms separately.
966fn branch_tally(branches: &[Vec<i64>]) -> Tally {
967    let mut tally = Tally::default();
968    for branch in branches.iter().filter(|branch| branch.len() >= 9) {
969        tally.count += 2;
970        tally.covered += u64::from(branch[4] > 0) + u64::from(branch[5] > 0);
971    }
972    tally
973}
974
975/// Run `cargo llvm-cov --lib` over the unit suite in `root` with the given coverage
976/// `format` args and return its stdout. Shared by the whole-tree floor and the
977/// diff-scoped floor, so both measure the same unit-only slice.
978fn run_cargo_llvm_cov(
979    root: &Path,
980    ignore: &[String],
981    format: &[&str],
982    features: &[String],
983    branch: bool,
984) -> Result<String> {
985    run_in(&TargetDir::new(), root, ignore, format, features, branch)
986}
987
988/// Both exports of a single run: `--json` for the per-function detail, then a `report --lcov`
989/// pass over the profile that run left in the target dir. The second pass re-reads the profile
990/// rather than running the suite again.
991fn run_cargo_llvm_cov_json_and_lcov(
992    root: &Path,
993    ignore: &[String],
994    features: &[String],
995    branch: bool,
996) -> Result<(String, String)> {
997    let target = TargetDir::new();
998    let json = run_in(&target, root, ignore, &["--json"], features, branch)?;
999    let lcov = report_in(&target, root, ignore, &["--lcov"])?;
1000    Ok((json, lcov))
1001}
1002
1003/// Re-export the profile already in `target`. `report` runs no tests, so the two views a caller
1004/// compares come from one execution of the suite.
1005fn report_in(
1006    target: &TargetDir,
1007    root: &Path,
1008    ignore: &[String],
1009    format: &[&str],
1010) -> Result<String> {
1011    let mut command = Command::new("cargo");
1012    command
1013        .current_dir(root)
1014        .args(report_argv(format, ignore_filename_regex(root, ignore)))
1015        .env("CARGO_TARGET_DIR", &target.0);
1016    scrub_outer_llvm_cov(&mut command);
1017    let output = command
1018        .output()
1019        .context("running `cargo llvm-cov report` (is cargo-llvm-cov installed?)")?;
1020    llvm_cov_stdout(
1021        output.status.success(),
1022        &output.stdout,
1023        &output.stderr,
1024        &format!(
1025            "cargo llvm-cov could not re-export the profile it just wrote in `{}`:",
1026            root.display()
1027        ),
1028    )
1029}
1030
1031fn report_argv(format: &[&str], ignore_regex: Option<String>) -> Vec<String> {
1032    let mut argv = vec!["llvm-cov".to_string(), "report".to_string()];
1033    argv.extend(format.iter().map(|arg| (*arg).to_string()));
1034    if let Some(regex) = ignore_regex {
1035        argv.extend(["--ignore-filename-regex".to_string(), regex]);
1036    }
1037    argv
1038}
1039
1040/// An invocation's stdout, or `failure` carrying both of its streams when it exited non-zero.
1041fn llvm_cov_stdout(success: bool, stdout: &[u8], stderr: &[u8], failure: &str) -> Result<String> {
1042    if !success {
1043        bail!(
1044            "{failure}\n{}{}",
1045            String::from_utf8_lossy(stdout),
1046            String::from_utf8_lossy(stderr)
1047        );
1048    }
1049    Ok(String::from_utf8_lossy(stdout).into_owned())
1050}
1051
1052fn run_in(
1053    target: &TargetDir,
1054    root: &Path,
1055    ignore: &[String],
1056    format: &[&str],
1057    features: &[String],
1058    branch: bool,
1059) -> Result<String> {
1060    let mut command = Command::new("cargo");
1061    command
1062        .current_dir(root)
1063        .args(run_argv(
1064            format,
1065            features,
1066            branch,
1067            ignore_filename_regex(root, ignore),
1068        ))
1069        .env("CARGO_TARGET_DIR", &target.0);
1070    scrub_outer_llvm_cov(&mut command);
1071    let output = command
1072        .output()
1073        .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
1074    llvm_cov_stdout(
1075        output.status.success(),
1076        &output.stdout,
1077        &output.stderr,
1078        &format!(
1079            "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{}",
1080            root.display(),
1081            branch_hint(branch)
1082        ),
1083    )
1084}
1085
1086fn run_argv(
1087    format: &[&str],
1088    features: &[String],
1089    branch: bool,
1090    ignore_regex: Option<String>,
1091) -> Vec<String> {
1092    let mut argv = vec![
1093        "llvm-cov".to_string(),
1094        "--lib".to_string(),
1095        "--bins".to_string(),
1096    ];
1097    argv.extend(format.iter().map(|arg| (*arg).to_string()));
1098    if !features.is_empty() {
1099        argv.extend(["--features".to_string(), features.join(",")]);
1100    }
1101    if branch {
1102        argv.push("--branch".to_string());
1103    }
1104    if let Some(regex) = ignore_regex {
1105        argv.extend(["--ignore-filename-regex".to_string(), regex]);
1106    }
1107    argv
1108}
1109
1110fn branch_hint(branch: bool) -> &'static str {
1111    if branch {
1112        "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
1113         nightly toolchain — pin one in the crate's rust-toolchain.toml with \
1114         llvm-tools-preview, or set a rustup directory override)"
1115    } else {
1116        ""
1117    }
1118}
1119
1120/// Strip the outer run's instrumentation state from `command`.
1121///
1122/// When this check runs under an outer `cargo llvm-cov`, an inherited `RUSTC_WRAPPER` makes the
1123/// inner run re-enter cargo-llvm-cov on every rustc invocation and hang until the runner is
1124/// OOM-killed.
1125fn scrub_outer_llvm_cov(command: &mut Command) {
1126    for var in [
1127        "RUSTFLAGS",
1128        "CARGO_ENCODED_RUSTFLAGS",
1129        "RUSTDOCFLAGS",
1130        "CARGO_ENCODED_RUSTDOCFLAGS",
1131        "LLVM_PROFILE_FILE",
1132        "CARGO_LLVM_COV",
1133        "CARGO_LLVM_COV_SHOW_ENV",
1134        "CARGO_LLVM_COV_TARGET_DIR",
1135        "CARGO_LLVM_COV_BUILD_DIR",
1136        "RUSTC_WRAPPER",
1137        "RUSTC_WORKSPACE_WRAPPER",
1138        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1139        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1140        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1141        // rustup gives an inherited toolchain selection precedence over the scanned
1142        // crate's own `rust-toolchain.toml`, so a spawning cargo would override the
1143        // nightly a branch-floor crate pins there.
1144        "RUSTUP_TOOLCHAIN",
1145        "CARGO",
1146        "RUSTC",
1147    ] {
1148        command.env_remove(var);
1149    }
1150}
1151
1152/// Per-file region detail from a `cargo llvm-cov --json` export — what
1153/// [`crate::patch_coverage::evaluate_patch_rust`] restricts to the changed lines.
1154#[derive(Debug, Clone, Default)]
1155pub struct RustPatchCoverage {
1156    /// One per `kind == 0` code region: `(start_line, end_line, covered)`. A region
1157    /// counts toward the diff when any line it spans is changed.
1158    pub regions: Vec<(u64, u64, bool)>,
1159}
1160
1161/// A full `cargo llvm-cov --json` export, modeling the per-function region detail the
1162/// diff-scoped floor needs — separate from [`LlvmCovReport`], which keeps the totals.
1163#[derive(Debug, Clone, Deserialize)]
1164struct LlvmCovExport {
1165    data: Vec<LlvmCovExportData>,
1166}
1167
1168/// One export entry. `--ignore-filename-regex` drops an exempt file from `files` but
1169/// *not* from `functions` (the regions array is unfiltered), so `files` is the
1170/// allowlist [`llvm_cov_patch_detail`] restricts the regions to.
1171#[derive(Debug, Clone, Deserialize)]
1172struct LlvmCovExportData {
1173    files: Vec<LlvmCovExportFile>,
1174    functions: Vec<LlvmCovFunction>,
1175    /// The same block [`LlvmCovReport`] reads. Defaulted so a fixture that models only the
1176    /// region detail still parses.
1177    #[serde(default)]
1178    totals: LlvmCovTotals,
1179}
1180
1181/// One measured file in the export's `files` block — only its absolute `filename` is
1182/// needed, to build the not-ignored allowlist.
1183#[derive(Debug, Clone, Deserialize)]
1184struct LlvmCovExportFile {
1185    filename: String,
1186}
1187
1188/// One function's coverage: the files it spans (`filenames`, indexed by a region's
1189/// `fileID`) and its regions. Each region is a flat array `[lineStart, colStart,
1190/// lineEnd, colEnd, executionCount, fileID, expandedFileID, kind]`, read positionally.
1191/// A branch region sits in `branches` instead, with `falseExecutionCount` inserted at index 5.
1192#[derive(Debug, Clone, Deserialize)]
1193struct LlvmCovFunction {
1194    filenames: Vec<String>,
1195    regions: Vec<Vec<i64>>,
1196    /// How many times the function itself ran.
1197    #[serde(default)]
1198    count: u64,
1199    #[serde(default)]
1200    branches: Vec<Vec<i64>>,
1201}
1202
1203/// Run the Rust unit suite under `cargo llvm-cov` and return the per-file region
1204/// detail, keyed by the absolute path llvm-cov reports. `ignore` is the
1205/// `coverage`-rule exemptions, dropped so an exempt file's changed lines are lifted.
1206pub fn measure_patch_rust_detail(
1207    root: &Path,
1208    ignore: &[String],
1209    features: &[String],
1210) -> Result<BTreeMap<String, RustPatchCoverage>> {
1211    // The diff-scoped floor judges regions + lines, so its run never adds `--branch`.
1212    let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
1213    let hidden = hidden_lines_by_file(&json)?;
1214    llvm_cov_patch_detail(&json, &hidden)
1215}
1216
1217/// Parse a full `cargo llvm-cov --json` export.
1218fn parse_llvm_cov_export(json: &str) -> Result<LlvmCovExport> {
1219    serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")
1220}
1221
1222/// Pure: per-file [`RustPatchCoverage`] from a `cargo llvm-cov --json` export, keyed
1223/// by the absolute path llvm-cov reports. Only `kind == 0` code regions in the `files`
1224/// allowlist count; a malformed short region is skipped rather than indexed, and a region
1225/// starting on a line `hidden` names is dropped — no test can execute it.
1226fn llvm_cov_patch_detail(
1227    json: &str,
1228    hidden: &BTreeMap<String, BTreeSet<u32>>,
1229) -> Result<BTreeMap<String, RustPatchCoverage>> {
1230    let export = parse_llvm_cov_export(json)?;
1231    let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1232    for data in &export.data {
1233        let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1234        for function in &data.functions {
1235            for region in &function.regions {
1236                if region.len() < 8 {
1237                    continue;
1238                }
1239                // gap (1) / expansion (2) / branch regions carry no line-coverage signal.
1240                if region[7] != 0 {
1241                    continue;
1242                }
1243                let file_id = region[5];
1244                let Ok(file_id) = usize::try_from(file_id) else {
1245                    continue;
1246                };
1247                let Some(file) = function.filenames.get(file_id) else {
1248                    continue;
1249                };
1250                // A `coverage` exemption drops the file's regions, lifting its lines.
1251                if !measured.contains(file.as_str()) {
1252                    continue;
1253                }
1254                let start = region[0].max(0) as u64;
1255                let end = region[2].max(0) as u64;
1256                // A gated item is instrumented in the bin target's test harness, where
1257                // `cfg(test)` is unset, and no test can reach it.
1258                if hidden
1259                    .get(file)
1260                    .is_some_and(|lines| lines.contains(&(start as u32)))
1261                {
1262                    continue;
1263                }
1264                let covered = region[4] > 0;
1265                out.entry(file.clone())
1266                    .or_default()
1267                    .regions
1268                    .push((start, end, covered));
1269            }
1270        }
1271    }
1272    Ok(out)
1273}
1274
1275/// The single `--ignore-filename-regex` for the run, or `None` when nothing is exempt.
1276/// It is a substring search over absolute filenames, so each exempt path is escaped,
1277/// joined under `root`, and `$`-anchored — else it over-matches `member/src/a.rs`.
1278fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1279    if ignore.is_empty() {
1280        return None;
1281    }
1282    Some(
1283        ignore
1284            .iter()
1285            .map(|rel| {
1286                // The fallback keeps the anchor deterministic when the path can't be
1287                // resolved (e.g. in tests).
1288                let full = root.join(rel);
1289                let full = full.canonicalize().unwrap_or(full);
1290                format!("{}$", regex_escape(&full.to_string_lossy()))
1291            })
1292            .collect::<Vec<_>>()
1293            .join("|"),
1294    )
1295}
1296
1297/// Escape `s`'s regex metacharacters so an exempt path matches literally.
1298fn regex_escape(s: &str) -> String {
1299    const META: &str = r"\.+*?()|[]{}^$";
1300    let mut out = String::with_capacity(s.len());
1301    for c in s.chars() {
1302        if META.contains(c) {
1303            out.push('\\');
1304        }
1305        out.push(c);
1306    }
1307    out
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313
1314    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1315        CoverageReport {
1316            totals: Totals {
1317                percent_covered,
1318                num_branches,
1319            },
1320            files: BTreeMap::new(),
1321        }
1322    }
1323
1324    #[test]
1325    fn passes_when_total_meets_the_floor() {
1326        assert_eq!(
1327            evaluate(
1328                &report(100.0, 12),
1329                Thresholds {
1330                    fail_under: 100,
1331                    branch: true
1332                }
1333            ),
1334            Outcome::Pass
1335        );
1336    }
1337
1338    #[test]
1339    fn fails_when_total_is_below_the_floor() {
1340        assert!(matches!(
1341            evaluate(
1342                &report(80.0, 12),
1343                Thresholds {
1344                    fail_under: 100,
1345                    branch: true
1346                }
1347            ),
1348            Outcome::Fail(_)
1349        ));
1350    }
1351
1352    #[test]
1353    fn passes_when_branch_required_and_none_are_measured() {
1354        assert_eq!(
1355            evaluate(
1356                &report(100.0, 0),
1357                Thresholds {
1358                    fail_under: 100,
1359                    branch: true
1360                }
1361            ),
1362            Outcome::Pass
1363        );
1364    }
1365
1366    #[test]
1367    fn parses_a_coverage_py_report() {
1368        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1369        let report = parse_report(json).expect("valid coverage.py json");
1370        assert_eq!(report.totals.percent_covered, 91.5);
1371        assert_eq!(report.totals.num_branches, 8);
1372    }
1373
1374    #[test]
1375    fn parses_the_per_file_block_for_patch_coverage() {
1376        let json = r#"{
1377            "files": {
1378                "widget.py": {
1379                    "executed_lines": [1, 2, 3, 4, 6],
1380                    "summary": {"percent_covered": 85.0},
1381                    "missing_lines": [5],
1382                    "excluded_lines": [],
1383                    "missing_branches": [[4, 5]]
1384                }
1385            },
1386            "totals": {"percent_covered": 85.0, "num_branches": 4}
1387        }"#;
1388        let report = parse_report(json).expect("valid coverage.py json with files");
1389        let widget = report.files.get("widget.py").expect("widget.py is present");
1390        assert_eq!(widget.missing_lines, vec![5]);
1391        assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1392        assert_eq!(report.totals.percent_covered, 85.0);
1393    }
1394
1395    #[test]
1396    fn a_report_without_a_files_block_parses_with_an_empty_map() {
1397        let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1398            .expect("valid coverage.py json");
1399        assert!(report.files.is_empty());
1400    }
1401
1402    #[test]
1403    fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1404        assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1405    }
1406
1407    #[test]
1408    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1409        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1410        assert_eq!(
1411            build_omit(&exempt),
1412            "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1413        );
1414    }
1415
1416    fn metric(pct: f64) -> VitestMetric {
1417        VitestMetric {
1418            pct: Some(pct),
1419            total: 10,
1420        }
1421    }
1422
1423    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1424        VitestReport {
1425            total: VitestTotals {
1426                lines: metric(lines),
1427                branches: metric(branches),
1428                functions: metric(functions),
1429                statements: metric(statements),
1430            },
1431        }
1432    }
1433
1434    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1435        lines: 100,
1436        branches: 100,
1437        functions: 100,
1438        statements: 100,
1439    };
1440    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1441        lines: 80,
1442        branches: 75,
1443        functions: 80,
1444        statements: 80,
1445    };
1446
1447    #[test]
1448    fn typescript_passes_when_every_metric_meets_its_floor() {
1449        assert_eq!(
1450            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1451            Outcome::Pass
1452        );
1453    }
1454
1455    #[test]
1456    fn typescript_fails_on_the_one_metric_below_its_floor() {
1457        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1458        assert!(
1459            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1460            "got: {outcome:?}"
1461        );
1462    }
1463
1464    #[test]
1465    fn typescript_fail_message_names_every_metric_below() {
1466        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1467        assert!(
1468            matches!(&outcome, Outcome::Fail(message)
1469                if message.contains("lines")
1470                    && message.contains("branches")
1471                    && message.contains("functions")
1472                    && message.contains("statements")),
1473            "got: {outcome:?}"
1474        );
1475    }
1476
1477    #[test]
1478    fn typescript_tolerates_float_noise_at_the_floor() {
1479        assert_eq!(
1480            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1481            Outcome::Pass
1482        );
1483    }
1484
1485    #[test]
1486    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1487        let report = VitestReport {
1488            total: VitestTotals {
1489                lines: metric(100.0),
1490                branches: VitestMetric {
1491                    pct: None,
1492                    total: 0,
1493                },
1494                functions: metric(100.0),
1495                statements: metric(100.0),
1496            },
1497        };
1498        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1499    }
1500
1501    #[test]
1502    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1503        let nothing = VitestMetric {
1504            pct: None,
1505            total: 0,
1506        };
1507        let report = VitestReport {
1508            total: VitestTotals {
1509                lines: nothing,
1510                branches: nothing,
1511                functions: nothing,
1512                statements: nothing,
1513            },
1514        };
1515        let outcome = evaluate_typescript(&report, TS_MID);
1516        assert!(
1517            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1518            "got: {outcome:?}"
1519        );
1520    }
1521
1522    #[test]
1523    fn parses_a_vitest_summary_report() {
1524        let json = r#"{
1525            "total": {
1526                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1527                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1528                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1529                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1530                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1531            },
1532            "/abs/widget.ts": {
1533                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1534            }
1535        }"#;
1536        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1537        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
1538        assert_eq!(report.total.lines.pct, Some(80.0));
1539        assert_eq!(report.total.branches.pct, Some(66.66));
1540        assert_eq!(report.total.functions.total, 2);
1541    }
1542
1543    #[test]
1544    fn parses_an_unknown_pct_as_unmeasured() {
1545        let json = r#"{"total": {
1546            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1547            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1548            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1549            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1550        }}"#;
1551        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1552        assert_eq!(report.total.lines.pct, None);
1553        assert_eq!(report.total.lines.total, 0);
1554    }
1555
1556    #[test]
1557    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1558        let json = r#"{"total":{
1559            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1560            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1561            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1562            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1563        }}"#;
1564        assert!(parse_vitest_report(json).is_err());
1565    }
1566
1567    fn rust_metric(percent: f64) -> LlvmCovMetric {
1568        LlvmCovMetric {
1569            count: 10,
1570            covered: 10,
1571            percent,
1572        }
1573    }
1574
1575    fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1576        LlvmCovReport {
1577            data: vec![LlvmCovData {
1578                totals: LlvmCovTotals {
1579                    regions: rust_metric(regions),
1580                    lines: rust_metric(lines),
1581                    functions: rust_metric(lines),
1582                    branches: None,
1583                },
1584            }],
1585        }
1586    }
1587
1588    /// Like [`rust_report`] with explicit functions/branches; `branches: (count,
1589    /// percent)` so the vacuous zero-denominator case is constructible.
1590    fn rust_report_full(
1591        regions: f64,
1592        lines: f64,
1593        functions: f64,
1594        branches: (u64, f64),
1595    ) -> LlvmCovReport {
1596        let (count, percent) = branches;
1597        LlvmCovReport {
1598            data: vec![LlvmCovData {
1599                totals: LlvmCovTotals {
1600                    regions: rust_metric(regions),
1601                    lines: rust_metric(lines),
1602                    functions: rust_metric(functions),
1603                    branches: Some(LlvmCovMetric {
1604                        count,
1605                        covered: count,
1606                        percent,
1607                    }),
1608                },
1609            }],
1610        }
1611    }
1612
1613    const RUST_FULL: RustThresholds = RustThresholds {
1614        regions: Some(100),
1615        lines: 100,
1616        functions: None,
1617        branch: None,
1618    };
1619    const RUST_MID: RustThresholds = RustThresholds {
1620        regions: Some(80),
1621        lines: 85,
1622        functions: None,
1623        branch: None,
1624    };
1625
1626    #[test]
1627    fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1628        let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1629        let floor = |functions| RustThresholds {
1630            regions: None,
1631            lines: 50,
1632            functions: Some(functions),
1633            branch: None,
1634        };
1635        assert!(matches!(
1636            evaluate_rust(&report, floor(100)),
1637            Outcome::Fail(message) if message.contains("functions")
1638        ));
1639        assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1640    }
1641
1642    #[test]
1643    fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1644        let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1645        let floor = |branch| RustThresholds {
1646            regions: None,
1647            lines: 50,
1648            functions: None,
1649            branch: Some(branch),
1650        };
1651        assert!(matches!(
1652            evaluate_rust(&report, floor(100)),
1653            Outcome::Fail(message) if message.contains("branches")
1654        ));
1655        assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1656    }
1657
1658    #[test]
1659    fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1660        let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1661        let floor = RustThresholds {
1662            regions: None,
1663            lines: 50,
1664            functions: None,
1665            branch: Some(100),
1666        };
1667        assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1668    }
1669
1670    #[test]
1671    fn rust_passes_when_both_metrics_meet_their_floor() {
1672        assert_eq!(
1673            evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1674            Outcome::Pass
1675        );
1676    }
1677
1678    #[test]
1679    fn rust_fails_on_the_one_metric_below_its_floor() {
1680        let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1681        assert!(
1682            matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1683            "got: {outcome:?}"
1684        );
1685    }
1686
1687    #[test]
1688    fn rust_fail_message_names_every_metric_below() {
1689        let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1690        assert!(
1691            matches!(&outcome, Outcome::Fail(message)
1692                if message.contains("regions") && message.contains("lines")),
1693            "got: {outcome:?}"
1694        );
1695    }
1696
1697    #[test]
1698    fn rust_skips_the_region_check_when_regions_is_opt_out() {
1699        let thresholds = RustThresholds {
1700            regions: None,
1701            lines: 100,
1702            functions: None,
1703            branch: None,
1704        };
1705        assert_eq!(
1706            evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1707            Outcome::Pass
1708        );
1709    }
1710
1711    #[test]
1712    fn rust_still_fails_lines_with_regions_opt_out() {
1713        let thresholds = RustThresholds {
1714            regions: None,
1715            lines: 100,
1716            functions: None,
1717            branch: None,
1718        };
1719        let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1720        assert!(
1721            matches!(&outcome, Outcome::Fail(message)
1722                if message.contains("lines") && !message.contains("regions")),
1723            "got: {outcome:?}"
1724        );
1725    }
1726
1727    #[test]
1728    fn rust_tolerates_float_noise_at_the_floor() {
1729        assert_eq!(
1730            evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1731            Outcome::Pass
1732        );
1733    }
1734
1735    #[test]
1736    fn rust_fails_a_vacuous_run_that_measured_no_code() {
1737        let nothing = LlvmCovMetric {
1738            count: 0,
1739            covered: 0,
1740            percent: 0.0,
1741        };
1742        let report = LlvmCovReport {
1743            data: vec![LlvmCovData {
1744                totals: LlvmCovTotals {
1745                    regions: nothing,
1746                    lines: nothing,
1747                    functions: nothing,
1748                    branches: None,
1749                },
1750            }],
1751        };
1752        let outcome = evaluate_rust(&report, RUST_MID);
1753        assert!(
1754            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1755            "got: {outcome:?}"
1756        );
1757    }
1758
1759    #[test]
1760    fn rust_fails_an_export_with_no_data() {
1761        let report = LlvmCovReport { data: vec![] };
1762        assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1763    }
1764
1765    #[test]
1766    fn parses_a_cargo_llvm_cov_report() {
1767        let json = r#"{
1768            "data": [{"totals": {
1769                "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1770                "lines": {"count": 20, "covered": 18, "percent": 90.0},
1771                "functions": {"count": 3, "covered": 3, "percent": 100.0}
1772            }}],
1773            "type": "llvm.coverage.json.export",
1774            "version": "2.0.1"
1775        }"#;
1776        let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1777        assert_eq!(report.data[0].totals.regions.percent, 75.0);
1778        assert_eq!(report.data[0].totals.lines.count, 20);
1779    }
1780
1781    /// [`llvm_cov_patch_detail`] over an export with nothing gated.
1782    fn patch_detail(json: &str) -> BTreeMap<String, RustPatchCoverage> {
1783        llvm_cov_patch_detail(json, &BTreeMap::new()).expect("valid llvm-cov export")
1784    }
1785
1786    #[test]
1787    fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1788        let json = r#"{
1789            "data": [{
1790                "files": [{"filename": "/abs/grade.rs"}],
1791                "functions": [{
1792                    "filenames": ["/abs/grade.rs"],
1793                    "regions": [
1794                        [6, 5, 6, 26, 1, 0, 0, 0],
1795                        [10, 9, 10, 17, 0, 0, 0, 0]
1796                    ]
1797                }]
1798            }],
1799            "type": "llvm.coverage.json.export",
1800            "version": "3.0.1"
1801        }"#;
1802        let out = patch_detail(json);
1803        assert_eq!(
1804            out["/abs/grade.rs"].regions,
1805            vec![(6, 6, true), (10, 10, false)]
1806        );
1807    }
1808
1809    #[test]
1810    fn llvm_cov_patch_detail_skips_non_code_regions() {
1811        let json = r#"{
1812            "data": [{
1813                "files": [{"filename": "/abs/a.rs"}],
1814                "functions": [{
1815                    "filenames": ["/abs/a.rs"],
1816                    "regions": [
1817                        [1, 1, 1, 10, 2, 0, 0, 0],
1818                        [2, 1, 2, 10, 0, 0, 0, 1],
1819                        [3, 1, 3, 10, 0, 0, 0, 2]
1820                    ]
1821                }]
1822            }]
1823        }"#;
1824        let out = patch_detail(json);
1825        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1826    }
1827
1828    #[test]
1829    fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1830        let json = r#"{
1831            "data": [{
1832                "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1833                "functions": [{
1834                    "filenames": ["/abs/a.rs", "/abs/b.rs"],
1835                    "regions": [
1836                        [1, 1, 1, 5, 1, 0, 0, 0],
1837                        [9, 1, 9, 5, 0, 1, 1, 0]
1838                    ]
1839                }]
1840            }]
1841        }"#;
1842        let out = patch_detail(json);
1843        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1844        assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1845    }
1846
1847    #[test]
1848    fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1849        let json = r#"{
1850            "data": [{
1851                "files": [{"filename": "/abs/a.rs"}],
1852                "functions": [{
1853                    "filenames": ["/abs/a.rs"],
1854                    "regions": [
1855                        [4, 1, 4],
1856                        [5, 1, 5, 9, 1, 0, 0, 0]
1857                    ]
1858                }]
1859            }]
1860        }"#;
1861        let out = patch_detail(json);
1862        assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1863    }
1864
1865    #[test]
1866    fn llvm_cov_patch_detail_spans_a_multiline_region() {
1867        let json = r#"{
1868            "data": [{
1869                "files": [{"filename": "/abs/a.rs"}],
1870                "functions": [{
1871                    "filenames": ["/abs/a.rs"],
1872                    "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1873                }]
1874            }]
1875        }"#;
1876        let out = patch_detail(json);
1877        assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1878    }
1879
1880    #[test]
1881    fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1882        let json = r#"{
1883            "data": [{
1884                "files": [{"filename": "/abs/kept.rs"}],
1885                "functions": [{
1886                    "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1887                    "regions": [
1888                        [1, 1, 1, 9, 1, 0, 0, 0],
1889                        [2, 1, 2, 9, 0, 1, 0, 0]
1890                    ]
1891                }]
1892            }]
1893        }"#;
1894        let out = patch_detail(json);
1895        assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1896        assert!(!out.contains_key("/abs/ignored.rs"));
1897    }
1898
1899    #[test]
1900    fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1901        assert!(llvm_cov_patch_detail("{ not json", &BTreeMap::new()).is_err());
1902    }
1903
1904    #[test]
1905    fn llvm_cov_patch_detail_skips_a_negative_file_id() {
1906        let json = r#"{
1907            "data": [{
1908                "files": [{"filename": "/abs/a.rs"}],
1909                "functions": [{
1910                    "filenames": ["/abs/a.rs"],
1911                    "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
1912                }]
1913            }]
1914        }"#;
1915        let out = patch_detail(json);
1916        assert!(out.is_empty(), "got: {out:?}");
1917    }
1918
1919    #[test]
1920    fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
1921        let json = r#"{
1922            "data": [{
1923                "files": [{"filename": "/abs/a.rs"}],
1924                "functions": [{
1925                    "filenames": ["/abs/a.rs"],
1926                    "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
1927                }]
1928            }]
1929        }"#;
1930        let out = patch_detail(json);
1931        assert!(out.is_empty(), "got: {out:?}");
1932    }
1933
1934    #[test]
1935    fn istanbul_patch_detail_reads_statements_arms_and_functions() {
1936        let json = r#"{
1937            "/abs/a.ts": {
1938                "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
1939                "s": {"0": 1},
1940                "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1941                "b": {"0": [1, 0]},
1942                "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
1943                "f": {"0": 0}
1944            }
1945        }"#;
1946        let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1947        let detail = &out["/abs/a.ts"];
1948        assert_eq!(detail.statements, vec![(1, 2, true)]);
1949        assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
1950        assert_eq!(detail.functions, vec![(7, false)]);
1951    }
1952
1953    #[test]
1954    fn istanbul_patch_detail_keeps_a_branch_without_counts() {
1955        let json = r#"{
1956            "/abs/a.ts": {
1957                "statementMap": {},
1958                "s": {},
1959                "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1960                "b": {},
1961                "fnMap": {},
1962                "f": {}
1963            }
1964        }"#;
1965        let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1966        assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
1967    }
1968
1969    #[test]
1970    fn default_excludes_that_are_not_json_name_the_output() {
1971        let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
1972        let msg = format!("{err:#}");
1973        assert!(msg.contains("not a JSON string array"), "got: {msg}");
1974        assert!(msg.contains("vitest warmed up first"), "got: {msg}");
1975    }
1976
1977    #[test]
1978    fn default_excludes_drop_a_nul_bearing_pattern() {
1979        let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
1980        assert_eq!(parsed, vec!["**/dist/**".to_string()]);
1981    }
1982
1983    #[test]
1984    fn a_missing_vitest_report_names_the_reporter() {
1985        let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
1986        let err = read_vitest_report(&path, "json").unwrap_err();
1987        assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
1988    }
1989
1990    #[test]
1991    fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1992        assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
1993    }
1994
1995    #[test]
1996    fn rust_run_argv_carries_format_features_branch_and_ignore_regex() {
1997        assert_eq!(
1998            run_argv(
1999                &["--json"],
2000                &["alpha".to_string(), "beta".to_string()],
2001                true,
2002                Some("src/ignored\\.rs$".to_string()),
2003            ),
2004            [
2005                "llvm-cov",
2006                "--lib",
2007                "--bins",
2008                "--json",
2009                "--features",
2010                "alpha,beta",
2011                "--branch",
2012                "--ignore-filename-regex",
2013                "src/ignored\\.rs$",
2014            ]
2015        );
2016        assert_eq!(
2017            run_argv(&["--lcov"], &[], false, None),
2018            ["llvm-cov", "--lib", "--bins", "--lcov"]
2019        );
2020    }
2021
2022    #[test]
2023    fn rust_report_argv_carries_format_and_optional_ignore_regex() {
2024        assert_eq!(
2025            report_argv(&["--lcov"], Some("src/ignored\\.rs$".to_string())),
2026            [
2027                "llvm-cov",
2028                "report",
2029                "--lcov",
2030                "--ignore-filename-regex",
2031                "src/ignored\\.rs$"
2032            ]
2033        );
2034        assert_eq!(
2035            report_argv(&["--json"], None),
2036            ["llvm-cov", "report", "--json"]
2037        );
2038    }
2039
2040    #[test]
2041    fn branch_hint_names_the_nightly_requirement_only_for_branch_coverage() {
2042        assert_eq!(branch_hint(false), "");
2043        assert!(branch_hint(true).contains("requires a nightly toolchain"));
2044    }
2045
2046    #[test]
2047    fn scrub_removes_outer_llvm_cov_state_from_a_command() {
2048        let mut command = Command::new("cargo");
2049        for name in [
2050            "RUSTFLAGS",
2051            "CARGO_ENCODED_RUSTFLAGS",
2052            "RUSTDOCFLAGS",
2053            "CARGO_ENCODED_RUSTDOCFLAGS",
2054            "LLVM_PROFILE_FILE",
2055            "CARGO_LLVM_COV",
2056            "CARGO_LLVM_COV_SHOW_ENV",
2057            "CARGO_LLVM_COV_TARGET_DIR",
2058            "CARGO_LLVM_COV_BUILD_DIR",
2059            "RUSTC_WRAPPER",
2060            "RUSTC_WORKSPACE_WRAPPER",
2061            "__CARGO_LLVM_COV_RUSTC_WRAPPER",
2062            "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
2063            "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
2064            "RUSTUP_TOOLCHAIN",
2065            "CARGO",
2066            "RUSTC",
2067        ] {
2068            command.env(name, "outer");
2069        }
2070        command.env("KEEP", "value");
2071        scrub_outer_llvm_cov(&mut command);
2072        let envs: Vec<_> = command.get_envs().collect();
2073        assert_eq!(envs.len(), 18);
2074        assert!(envs
2075            .iter()
2076            .all(|(name, value)| { name == &"KEEP" || value.is_none() }));
2077        assert_eq!(
2078            envs.iter().find(|(name, _)| *name == "KEEP").unwrap().1,
2079            Some("value".as_ref())
2080        );
2081    }
2082
2083    #[test]
2084    fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
2085        // `/repo` doesn't exist, so `canonicalize` falls back to the plain join.
2086        let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
2087        assert_eq!(
2088            ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
2089            Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
2090        );
2091    }
2092
2093    /// Model llvm-cov's substring `--ignore-filename-regex` for the escaped, optionally
2094    /// `$`-anchored literals this tool emits. One matching alternative ignores the file.
2095    fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
2096        regex.split('|').any(|alt| {
2097            let (lit, anchored) = match alt.strip_suffix('$') {
2098                Some(head) => (head, true),
2099                None => (alt, false),
2100            };
2101            let lit = lit.replace('\\', "");
2102            if anchored {
2103                filename.ends_with(&lit)
2104            } else {
2105                filename.contains(&lit)
2106            }
2107        })
2108    }
2109
2110    #[test]
2111    fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
2112        assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
2113        assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
2114    }
2115
2116    #[test]
2117    fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
2118        let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
2119        assert!(
2120            llvm_would_ignore(&regex, "/repo/src/a.rs"),
2121            "the exempted file must still be ignored: {regex}"
2122        );
2123        assert!(
2124            !llvm_would_ignore(&regex, "/repo/member/src/a.rs"),
2125            "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
2126        );
2127        assert!(
2128            !llvm_would_ignore(&regex, "/repo/src/xsrc/a.rs"),
2129            "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
2130        );
2131    }
2132
2133    /// A `cargo llvm-cov --json` export of one file holding a gated `main` on lines 6-8 and a
2134    /// tested `report` on lines 11-13, with the `--bins` half's second, 0-hit copy of both.
2135    const GATED_EXPORT: &str = r#"{
2136        "data": [{
2137            "files": [{"filename": "/abs/entrypoint.rs"}],
2138            "functions": [
2139                {"name": "report", "count": 1, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2140                 "regions": [[11, 1, 11, 37, 1, 0, 0, 0], [12, 5, 12, 19, 1, 0, 0, 0],
2141                             [12, 20, 12, 30, 1, 0, 0, 0], [13, 1, 13, 2, 1, 0, 0, 0]]},
2142                {"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2143                 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2144                             [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2145                {"name": "report", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2146                 "regions": [[11, 1, 11, 37, 0, 0, 0, 0], [12, 5, 12, 19, 0, 0, 0, 0],
2147                             [12, 20, 12, 30, 0, 0, 0, 0], [13, 1, 13, 2, 0, 0, 0, 0]]}
2148            ],
2149            "totals": {
2150                "regions": {"count": 13, "covered": 9, "percent": 69.23},
2151                "lines": {"count": 9, "covered": 6, "percent": 66.67},
2152                "functions": {"count": 3, "covered": 2, "percent": 66.67}
2153            }
2154        }]
2155    }"#;
2156
2157    /// The lines `#[cfg(not(test))] fn main` spans in [`GATED_EXPORT`]'s source.
2158    fn gated_main() -> BTreeMap<String, BTreeSet<u32>> {
2159        BTreeMap::from([(
2160            "/abs/entrypoint.rs".to_string(),
2161            BTreeSet::from([5, 6, 7, 8]),
2162        )])
2163    }
2164
2165    /// [`llvm_cov_totals_less_hidden`] over an export string.
2166    fn totals_less(json: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovTotals {
2167        let export = parse_llvm_cov_export(json).expect("valid llvm-cov export");
2168        llvm_cov_totals_less_hidden(&export, hidden).data[0].totals
2169    }
2170
2171    #[test]
2172    fn a_gated_entry_point_leaves_every_ratio_full() {
2173        let totals = totals_less(GATED_EXPORT, &gated_main());
2174        assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2175        assert_eq!((totals.lines.count, totals.lines.covered), (6, 6));
2176        assert_eq!((totals.functions.count, totals.functions.covered), (2, 2));
2177        assert_eq!(totals.regions.percent, 100.0);
2178    }
2179
2180    #[test]
2181    fn an_export_with_nothing_gated_keeps_its_totals() {
2182        let totals = totals_less(GATED_EXPORT, &BTreeMap::new());
2183        assert_eq!((totals.regions.count, totals.regions.covered), (13, 9));
2184        assert_eq!((totals.lines.count, totals.lines.covered), (9, 6));
2185        assert_eq!((totals.functions.count, totals.functions.covered), (3, 2));
2186    }
2187
2188    #[test]
2189    fn two_copies_of_one_gated_item_subtract_once() {
2190        let json = GATED_EXPORT.replace(
2191            r#"{"name": "report", "count": 1"#,
2192            r#"{"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2193                 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2194                             [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2195                {"name": "report", "count": 1"#,
2196        );
2197        let totals = totals_less(&json, &gated_main());
2198        assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2199        assert_eq!(totals.functions.count, 2);
2200    }
2201
2202    #[test]
2203    fn a_record_with_no_region_to_place_it_is_not_subtracted() {
2204        let json = r#"{
2205            "data": [{
2206                "files": [{"filename": "/abs/a.rs"}],
2207                "functions": [{"name": "empty", "count": 0, "filenames": ["/abs/a.rs"],
2208                    "regions": [], "branches": []}],
2209                "totals": {
2210                    "regions": {"count": 2, "covered": 2, "percent": 100.0},
2211                    "lines": {"count": 2, "covered": 2, "percent": 100.0},
2212                    "functions": {"count": 1, "covered": 1, "percent": 100.0}
2213                }
2214            }]
2215        }"#;
2216        let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1]))]);
2217        assert_eq!(totals_less(json, &hidden).regions.count, 2);
2218    }
2219
2220    #[test]
2221    fn a_record_outside_the_files_allowlist_is_not_subtracted() {
2222        let hidden = BTreeMap::from([("/abs/other.rs".to_string(), BTreeSet::from([6]))]);
2223        let json = GATED_EXPORT.replace("\"/abs/entrypoint.rs\"],", "\"/abs/other.rs\"],");
2224        assert_eq!(totals_less(&json, &hidden).regions.count, 13);
2225    }
2226
2227    #[test]
2228    fn a_gated_branch_drops_both_of_its_arms() {
2229        let json = r#"{
2230            "data": [{
2231                "files": [{"filename": "/abs/a.rs"}],
2232                "functions": [{"name": "main", "count": 0, "filenames": ["/abs/a.rs"],
2233                    "regions": [[1, 1, 3, 2, 0, 0, 0, 0]],
2234                    "branches": [[2, 9, 2, 14, 0, 0, 0, 0, 4], [2, 9, 2, 14, 0, 0, 0, 0]]}],
2235                "totals": {
2236                    "regions": {"count": 5, "covered": 4, "percent": 80.0},
2237                    "lines": {"count": 9, "covered": 6, "percent": 66.67},
2238                    "functions": {"count": 3, "covered": 2, "percent": 66.67},
2239                    "branches": {"count": 6, "covered": 4, "percent": 66.67}
2240                }
2241            }]
2242        }"#;
2243        let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1, 2, 3]))]);
2244        let branches = totals_less(json, &hidden).branches.expect("branch totals");
2245        // The second entry is a region array, not a branch one; a short array carries no arms.
2246        assert_eq!((branches.count, branches.covered), (4, 4));
2247    }
2248
2249    #[test]
2250    fn a_metric_the_subtraction_empties_reads_full() {
2251        let metric = LlvmCovMetric {
2252            count: 4,
2253            covered: 0,
2254            percent: 0.0,
2255        };
2256        let emptied = metric_less(
2257            metric,
2258            Tally {
2259                count: 9,
2260                covered: 9,
2261            },
2262        );
2263        assert_eq!((emptied.count, emptied.covered), (0, 0));
2264        assert_eq!(emptied.percent, 100.0);
2265    }
2266
2267    /// Two instantiation groups of one file, the second never run. The `--json` totals sum the
2268    /// two — nine lines, one short — while lcov merges them into three, all covered.
2269    const TWO_GROUPS_LCOV: &str = "SF:/abs/a.rs\nDA:1,4\nDA:2,4\nDA:3,0\n\
2270                                   SF:/abs/a.rs\nDA:1,0\nDA:2,0\nDA:3,7\nend_of_record\n";
2271
2272    #[test]
2273    fn a_line_one_instantiation_ran_counts_once_covered() {
2274        let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &BTreeMap::new());
2275        assert_eq!((lines.count, lines.covered), (3, 3));
2276        assert_eq!(lines.percent, 100.0);
2277    }
2278
2279    #[test]
2280    fn a_gated_line_leaves_the_lcov_denominator() {
2281        let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([3]))]);
2282        let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &hidden);
2283        assert_eq!((lines.count, lines.covered), (2, 2));
2284    }
2285
2286    #[test]
2287    fn a_line_no_instantiation_ran_is_still_missing() {
2288        let lcov = "SF:/abs/a.rs\nDA:1,4\nDA:2,0\nend_of_record\n";
2289        let lines = lcov_lines_metric(lcov, &BTreeMap::new());
2290        assert_eq!((lines.count, lines.covered), (2, 1));
2291    }
2292
2293    #[test]
2294    fn records_outside_a_source_file_are_not_lines() {
2295        let lcov = "DA:1,4\nSF:/abs/a.rs\nFN:1,main\nDA:x,4\nDA:2\nDA:3,y\nDA:4,1\nLF:4\n";
2296        assert_eq!(
2297            lcov_lines(lcov),
2298            BTreeMap::from([("/abs/a.rs".to_string(), BTreeMap::from([(4, true)]))]),
2299            "a `DA:` before any `SF:`, and a malformed one after, name no line"
2300        );
2301    }
2302
2303    #[test]
2304    fn a_failed_export_carries_both_streams() {
2305        let err = llvm_cov_stdout(false, b"out\n", b"err\n", "could not re-export:")
2306            .expect_err("a non-zero exit is an error");
2307        assert_eq!(format!("{err}"), "could not re-export:\nout\nerr\n");
2308    }
2309
2310    #[test]
2311    fn an_export_measuring_nothing_reads_full() {
2312        let lines = lcov_lines_metric("", &BTreeMap::new());
2313        assert_eq!((lines.count, lines.covered), (0, 0));
2314        assert_eq!(lines.percent, 100.0);
2315    }
2316
2317    #[test]
2318    fn a_function_record_without_a_usable_first_region_has_no_group() {
2319        let short = LlvmCovFunction {
2320            filenames: vec!["/abs/a.rs".to_string()],
2321            regions: vec![vec![1, 1, 1, 2]],
2322            count: 0,
2323            branches: Vec::new(),
2324        };
2325        let unnamed = LlvmCovFunction {
2326            filenames: Vec::new(),
2327            regions: vec![vec![1, 1, 1, 2, 0, 7, 0, 0]],
2328            count: 0,
2329            branches: Vec::new(),
2330        };
2331        let empty = LlvmCovFunction {
2332            filenames: vec!["/abs/a.rs".to_string()],
2333            regions: Vec::new(),
2334            count: 0,
2335            branches: Vec::new(),
2336        };
2337        assert_eq!(function_start(&short), None);
2338        assert_eq!(function_start(&unnamed), None);
2339        assert_eq!(function_start(&empty), None);
2340    }
2341
2342    #[test]
2343    fn a_gated_region_drops_out_of_the_changed_line_detail() {
2344        let hidden =
2345            BTreeMap::from([("/abs/entrypoint.rs".to_string(), BTreeSet::from([6, 7, 8]))]);
2346        let detail = llvm_cov_patch_detail(GATED_EXPORT, &hidden).expect("valid export");
2347        assert_eq!(
2348            detail["/abs/entrypoint.rs"]
2349                .regions
2350                .iter()
2351                .map(|(start, _, _)| *start)
2352                .collect::<BTreeSet<_>>(),
2353            BTreeSet::from([11, 12, 13])
2354        );
2355    }
2356
2357    #[test]
2358    fn hidden_lines_come_from_the_sources_the_export_measured() {
2359        let dir = std::env::temp_dir().join(format!("tc-cov-hidden-{}", std::process::id()));
2360        std::fs::create_dir_all(&dir).unwrap();
2361        let gated = dir.join("gated.rs");
2362        std::fs::write(&gated, "#[cfg(not(test))]\nfn main() {}\n").unwrap();
2363        let json = format!(
2364            r#"{{"data": [{{"files": [{{"filename": "{}"}}, {{"filename": "{}"}}],
2365                 "functions": []}}]}}"#,
2366            gated.display(),
2367            dir.join("absent.rs").display(),
2368        );
2369        let hidden = hidden_lines_by_file(&json).expect("valid export");
2370        std::fs::remove_dir_all(&dir).ok();
2371        assert_eq!(
2372            hidden,
2373            BTreeMap::from([(gated.display().to_string(), BTreeSet::from([1, 2]))]),
2374            "an unreadable source hides nothing"
2375        );
2376    }
2377}