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