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/// A run that measured nothing at all — a wrong path, or a suite that never ran.
81const NOTHING_MEASURED: &str =
82    "the unit suite measured no code — check the path and that the suite runs";
83
84/// `Some` naming the gap when `actual` falls below the `required` floor, else `None`.
85/// Tolerance so a percent that rounds to the floor isn't failed by float noise.
86pub(crate) fn shortfall(name: &str, actual: f64, required: u8) -> Option<String> {
87    if actual + 1e-9 < f64::from(required) {
88        Some(format!("{name} {actual:.2}% < {required}%"))
89    } else {
90        None
91    }
92}
93
94/// `Pass` when nothing fell short, else one `Fail` naming every shortfall.
95pub(crate) fn verdict(shortfalls: Vec<String>) -> Outcome {
96    if shortfalls.is_empty() {
97        Outcome::Pass
98    } else {
99        Outcome::Fail(format!(
100            "coverage below thresholds: {}",
101            shortfalls.join(", ")
102        ))
103    }
104}
105
106/// Parse a coverage.py JSON report (the output of `coverage json`).
107pub fn parse_report(json: &str) -> Result<CoverageReport> {
108    serde_json::from_str(json).context("parsing coverage.py JSON report")
109}
110
111/// Whether `report` meets `thresholds`. `coverage.py` always measures branches, so a
112/// zero-branch report means a branchless source, not a misconfigured run — vacuously
113/// full branch coverage, already folded into `percent_covered`.
114pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
115    let actual = report.totals.percent_covered;
116    let required = f64::from(thresholds.fail_under);
117    // Tolerance so a report that rounds to the floor isn't failed by float noise.
118    if actual + 1e-9 >= required {
119        Outcome::Pass
120    } else {
121        Outcome::Fail(format!(
122            "coverage {actual:.2}% is below the required {}%",
123            thresholds.fail_under
124        ))
125    }
126}
127
128/// Run the unit suite under coverage.py in `root` and check it against `thresholds`.
129/// `omit` is the `coverage`-rule exemptions as `root`-relative paths. The `coverage`
130/// CLI, with `pytest` importable, must be on `PATH`.
131pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
132    let report = run_coverage(root, omit)?;
133    Ok(evaluate(&report, thresholds))
134}
135
136/// Run the Python unit suite and return the per-file report, the denominator scoped
137/// to `root`'s sources (`--source=.`). `omit` is as in [`measure`].
138pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
139    run_coverage(root, omit)
140}
141
142/// A coverage.py data file under the temp dir — unique per call so parallel checks
143/// don't collide, and removed on drop so nothing leaks into the scanned tree.
144struct DataFile(PathBuf);
145
146impl DataFile {
147    fn new() -> Self {
148        static COUNTER: AtomicU64 = AtomicU64::new(0);
149        let name = format!(
150            "testing-conventions-{}-{}.coverage",
151            std::process::id(),
152            COUNTER.fetch_add(1, Ordering::Relaxed),
153        );
154        DataFile(std::env::temp_dir().join(name))
155    }
156}
157
158impl Drop for DataFile {
159    fn drop(&mut self) {
160        let _ = std::fs::remove_file(&self.0);
161    }
162}
163
164/// Run coverage.py over the unit suite in `root` and return the parsed report.
165/// `--source=.` scopes the denominator to `root`'s sources; without it coverage.py picks up
166/// an editable path dependency's tree. `--ignore=tests` leaves the suite tiers uncollected.
167fn run_coverage(root: &Path, omit: &[String]) -> Result<CoverageReport> {
168    let data = DataFile::new();
169    let omit = build_omit(omit);
170
171    // Byte-code and the pytest cache are suppressed so the scanned tree stays pristine.
172    let mut command = Command::new("coverage");
173    command
174        .current_dir(root)
175        .args(["run", "--branch", "--source=."])
176        .arg(format!("--omit={omit}"));
177    let run = command
178        .args([
179            "-m",
180            "pytest",
181            "-q",
182            "-p",
183            "no:cacheprovider",
184            "--ignore=tests",
185            ".",
186        ])
187        .env("COVERAGE_FILE", &data.0)
188        .env("PYTHONDONTWRITEBYTECODE", "1")
189        .output()
190        .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
191    if !run.status.success() {
192        bail!(
193            "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
194            root.display(),
195            String::from_utf8_lossy(&run.stdout),
196            String::from_utf8_lossy(&run.stderr),
197        );
198    }
199
200    let json = Command::new("coverage")
201        .current_dir(root)
202        .args(["json", "-o", "-"])
203        .env("COVERAGE_FILE", &data.0)
204        .output()
205        .context("running `coverage json`")?;
206    if !json.status.success() {
207        bail!(
208            "`coverage json` failed:\n{}",
209            String::from_utf8_lossy(&json.stderr),
210        );
211    }
212
213    parse_report(&String::from_utf8_lossy(&json.stdout))
214}
215
216/// The single comma-joined `--omit` for the run: the test and support globs plus every
217/// `coverage`-exempt path. coverage.py takes one `--omit` — repeated flags don't
218/// accumulate, so the patterns must be joined.
219fn build_omit(omit: &[String]) -> String {
220    [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
221        .into_iter()
222        .chain(omit.iter().cloned())
223        .collect::<Vec<_>>()
224        .join(",")
225}
226
227/// What vitest measures: every TypeScript source under the scanned root. The
228/// braces are a vitest (picomatch) glob, expanded by vitest, not the shell.
229const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
230
231/// The installed vitest's own default coverage excludes, resolved live via Node.
232/// Passing *any* `--coverage.exclude` replaces vitest's built-in list rather than
233/// extending it, so the defaults must be resolved and passed back explicitly.
234fn vitest_default_excludes(root: &Path) -> Result<Vec<String>> {
235    let run = Command::new("node")
236        .current_dir(root)
237        .args([
238            "-e",
239            "process.stdout.write(JSON.stringify(require('vitest/config').coverageConfigDefaults.exclude))",
240        ])
241        .output()
242        .context("resolving vitest's default coverage excludes via node")?;
243    if !run.status.success() {
244        bail!(
245            "could not resolve vitest's default coverage excludes in `{}`. The check runs the \
246             project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
247             must be installed in the project. node output:\n{}{}",
248            root.display(),
249            String::from_utf8_lossy(&run.stdout),
250            String::from_utf8_lossy(&run.stderr),
251        );
252    }
253    parse_default_excludes(&run.stdout)
254}
255
256/// The exclude patterns node printed, parsed and pared to the passable ones.
257fn parse_default_excludes(stdout: &[u8]) -> Result<Vec<String>> {
258    let excludes: Vec<String> = serde_json::from_slice(stdout).with_context(|| {
259        format!(
260            "vitest's default coverage excludes were not a JSON string array — got: {}",
261            String::from_utf8_lossy(stdout)
262        )
263    })?;
264    // A few of vitest's default patterns embed a literal NUL (its virtual-module
265    // markers, e.g. `**/\0*`), which can't be passed as a process argument at all.
266    Ok(excludes.into_iter().filter(|p| !p.contains('\0')).collect())
267}
268
269/// The four vitest coverage floors, from a `[typescript].coverage` table.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub struct TypeScriptThresholds {
272    pub lines: u8,
273    pub branches: u8,
274    pub functions: u8,
275    pub statements: u8,
276}
277
278/// A vitest `coverage-summary.json` report, pared to the `total` block.
279#[derive(Debug, Clone, Copy, Deserialize)]
280pub struct VitestReport {
281    pub total: VitestTotals,
282}
283
284/// The `total` block of a vitest json-summary report — the four metrics enforced.
285#[derive(Debug, Clone, Copy, Deserialize)]
286pub struct VitestTotals {
287    pub lines: VitestMetric,
288    pub branches: VitestMetric,
289    pub functions: VitestMetric,
290    pub statements: VitestMetric,
291}
292
293/// One metric's totals from a vitest json-summary block.
294#[derive(Debug, Clone, Copy, Deserialize)]
295pub struct VitestMetric {
296    /// Percent covered — `None` when nothing was measured, which vitest writes as
297    /// the string `"Unknown"` (and `total` is then `0`).
298    #[serde(deserialize_with = "deserialize_pct")]
299    pub pct: Option<f64>,
300    /// Size of the denominator (statements/branches/functions/lines counted).
301    pub total: u64,
302}
303
304/// A json-summary `pct`: a number for a measured metric, or the string `"Unknown"`
305/// (→ `None`) when the denominator is empty.
306fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
307where
308    D: serde::Deserializer<'de>,
309{
310    struct PctVisitor;
311    impl serde::de::Visitor<'_> for PctVisitor {
312        type Value = Option<f64>;
313
314        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
315            f.write_str("a coverage percent number or the string \"Unknown\"")
316        }
317
318        fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
319            Ok(Some(value))
320        }
321
322        // serde_json routes a whole-number percent here; percents are never negative.
323        fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
324            Ok(Some(value as f64))
325        }
326
327        // vitest writes the literal "Unknown" when the metric had nothing to measure.
328        fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
329            Ok(None)
330        }
331    }
332    deserializer.deserialize_any(PctVisitor)
333}
334
335/// Parse a vitest json-summary report (`coverage-summary.json`).
336pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
337    serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
338}
339
340/// Whether `report` meets every threshold. A run that measured no code at all fails
341/// rather than passing vacuously; one metric with an empty denominator amid a
342/// non-empty run has nothing to miss and is vacuously satisfied.
343pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
344    let total = &report.total;
345    // Every source file has lines, so a zero denominator means nothing was measured.
346    if total.lines.total == 0 {
347        return Outcome::Fail(NOTHING_MEASURED.to_string());
348    }
349    let checks = [
350        ("lines", total.lines, thresholds.lines),
351        ("branches", total.branches, thresholds.branches),
352        ("functions", total.functions, thresholds.functions),
353        ("statements", total.statements, thresholds.statements),
354    ];
355    verdict(
356        checks
357            .into_iter()
358            .filter_map(|(name, metric, required)| {
359                // An empty denominator (branch-free code) has nothing to cover —
360                // vacuously full.
361                shortfall(name, metric.pct.unwrap_or(100.0), required)
362            })
363            .collect(),
364    )
365}
366
367/// Run the unit suite under vitest coverage in `root` and check it against
368/// `thresholds`. `exclude` is the `coverage`-rule exemptions as `root`-relative paths;
369/// `npx` resolves the project-local `vitest` and `@vitest/coverage-v8`.
370pub fn measure_typescript(
371    root: &Path,
372    thresholds: TypeScriptThresholds,
373    exclude: &[String],
374) -> Result<Outcome> {
375    let report = run_vitest(root, exclude)?;
376    Ok(evaluate_typescript(&report, thresholds))
377}
378
379/// A vitest reports directory under the temp dir — unique per call so parallel checks
380/// don't collide, and removed on drop so nothing leaks into the scanned tree.
381struct ReportDir(PathBuf);
382
383impl ReportDir {
384    fn new() -> Self {
385        static COUNTER: AtomicU64 = AtomicU64::new(0);
386        let name = format!(
387            "testing-conventions-vitest-{}-{}",
388            std::process::id(),
389            COUNTER.fetch_add(1, Ordering::Relaxed),
390        );
391        ReportDir(std::env::temp_dir().join(name))
392    }
393}
394
395impl Drop for ReportDir {
396    fn drop(&mut self) {
397        let _ = std::fs::remove_dir_all(&self.0);
398    }
399}
400
401/// Run vitest over the unit suite in `root` and return the parsed floor report.
402fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
403    let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
404    parse_vitest_report(&json)
405}
406
407/// Run vitest coverage over the unit suite in `root` and return the contents of the
408/// `report_file` the `reporter` wrote.
409fn run_vitest_coverage(
410    root: &Path,
411    exclude: &[String],
412    reporter: &str,
413    report_file: &str,
414) -> Result<String> {
415    let reports = ReportDir::new();
416    let excludes = vitest_default_excludes(root)?;
417
418    let run = Command::new("npx")
419        .current_dir(root)
420        .args(vitest_coverage_argv(
421            reporter,
422            &reports.0,
423            excludes.iter().chain(exclude),
424        ))
425        // CI=1 keeps vitest non-interactive (no watch prompt, plain output).
426        .env("CI", "1")
427        .output()
428        .context("running `npx --no-install vitest run --coverage`")?;
429    vitest_exit(run.status.success(), root, &run.stdout, &run.stderr)?;
430    read_vitest_report(&reports.0.join(report_file), reporter)
431}
432
433/// `Ok` when the vitest run exited clean, else the failure naming both of its streams and why
434/// the check never downloads vitest. Held apart from [`run_vitest_coverage`] so that seam is
435/// branch-free: its body spawns, so no unit test can judge a replacement of the whole body,
436/// but this decision is pure and asserted. The sibling of [`llvm_cov_stdout`].
437fn vitest_exit(success: bool, root: &Path, stdout: &[u8], stderr: &[u8]) -> Result<()> {
438    if !success {
439        bail!(
440            "the unit suite did not run cleanly under vitest in `{}`. The check runs the \
441             project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
442             and `@vitest/coverage-v8` must be installed in the project. vitest output:\n{}{}",
443            root.display(),
444            String::from_utf8_lossy(stdout),
445            String::from_utf8_lossy(stderr),
446        );
447    }
448    Ok(())
449}
450
451/// The part of the `npx vitest run` argv that never varies between passes. Two entries are
452/// load-bearing against footguns rather than merely configuring the run:
453///
454/// - `--no-install`, never `--yes`: with `--yes` a missing vitest is silently downloaded,
455///   where every other arm fails clean on a missing binary.
456/// - the zeroed `coverage.thresholds` with `autoUpdate=false`: a consumer config's own
457///   thresholds neither decide the gate's exit nor rewrite the config file during a run.
458const VITEST_BASE_ARGV: [&str; 12] = [
459    "--no-install",
460    "vitest",
461    "run",
462    "--no-cache",
463    "--coverage.enabled",
464    "--coverage.provider=v8",
465    "--coverage.all=true",
466    "--coverage.thresholds.lines=0",
467    "--coverage.thresholds.branches=0",
468    "--coverage.thresholds.functions=0",
469    "--coverage.thresholds.statements=0",
470    "--coverage.thresholds.autoUpdate=false",
471];
472
473/// The `npx vitest run` argv for one coverage pass. `--coverage.all=true` counts source files
474/// the suite never imported, so an untested file is measured rather than vanishing.
475fn vitest_coverage_argv<'a>(
476    reporter: &str,
477    reports_dir: &Path,
478    exclude: impl Iterator<Item = &'a String>,
479) -> Vec<String> {
480    let mut argv: Vec<String> = VITEST_BASE_ARGV
481        .iter()
482        .map(|arg| (*arg).to_string())
483        .collect();
484    argv.push(format!("--coverage.reporter={reporter}"));
485    argv.push(format!(
486        "--coverage.reportsDirectory={}",
487        reports_dir.display()
488    ));
489    argv.push(format!("--coverage.include={TS_INCLUDE}"));
490    argv.extend(exclude.map(|path| format!("--coverage.exclude={path}")));
491    argv
492}
493
494/// The report the vitest run wrote, read back for parsing.
495fn read_vitest_report(path: &Path, reporter: &str) -> Result<String> {
496    std::fs::read_to_string(path).with_context(|| {
497        format!(
498            "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
499            path.display()
500        )
501    })
502}
503
504/// One file's entry in a vitest v8 `coverage-final.json` (Istanbul) report, pared to
505/// the statement / branch / function maps and their hit counts.
506#[derive(Debug, Clone, Deserialize)]
507struct IstanbulFile {
508    /// Statement id → source span; a `0` count in `s` means its lines are uncovered.
509    #[serde(rename = "statementMap", default)]
510    statement_map: BTreeMap<String, IstanbulSpan>,
511    /// Statement id → execution count.
512    #[serde(default)]
513    s: BTreeMap<String, u64>,
514    /// Branch id → location; a `0` among its `b` counts means a path never taken.
515    #[serde(rename = "branchMap", default)]
516    branch_map: BTreeMap<String, IstanbulBranch>,
517    /// Branch id → per-arm execution counts.
518    #[serde(default)]
519    b: BTreeMap<String, Vec<u64>>,
520    /// Function id → declaration location; a `0` count in `f` means never called.
521    #[serde(rename = "fnMap", default)]
522    fn_map: BTreeMap<String, IstanbulFn>,
523    /// Function id → execution count.
524    #[serde(default)]
525    f: BTreeMap<String, u64>,
526}
527
528/// A source span — only the 1-based line numbers matter to patch coverage.
529#[derive(Debug, Clone, Deserialize)]
530struct IstanbulSpan {
531    start: IstanbulPos,
532    end: IstanbulPos,
533}
534
535/// A position in a source span; the `column` is ignored.
536#[derive(Debug, Clone, Deserialize)]
537struct IstanbulPos {
538    line: u64,
539}
540
541/// A branch entry — only `loc.start.line`, the branch's source line, matters.
542#[derive(Debug, Clone, Deserialize)]
543struct IstanbulBranch {
544    loc: IstanbulSpan,
545}
546
547/// A function entry — only `decl.start.line` matters. vitest's v8 export shapes this
548/// as `{"name":.., "decl":{"start":{"line":N,..},..}, ..}`.
549#[derive(Debug, Clone, Deserialize)]
550struct IstanbulFn {
551    decl: IstanbulSpan,
552}
553
554/// Per-file detail from a vitest Istanbul report — the Istanbul maps reduced to the
555/// tuples [`crate::patch_coverage::evaluate_patch_typescript`] restricts to the diff.
556#[derive(Debug, Clone, Default)]
557pub struct TsPatchCoverage {
558    /// One per `statementMap` entry: `(start_line, end_line, covered)`. A statement
559    /// counts toward the diff when any line it spans is changed.
560    pub statements: Vec<(u64, u64, bool)>,
561    /// One per branch **arm**: `(source_line, covered)`, the source line shared by
562    /// every arm of a branch.
563    pub branch_arms: Vec<(u64, bool)>,
564    /// One per `fnMap` entry: `(decl_line, covered)`. A function counts toward the
565    /// diff when its declaration line is changed.
566    pub functions: Vec<(u64, bool)>,
567}
568
569/// Run the TypeScript unit suite under vitest and return the per-file detail for the
570/// four metrics, keyed by the absolute path vitest reports. `exclude` is the
571/// `coverage`-rule exemptions, dropped so an exempt file's changed lines are lifted.
572pub fn measure_patch_typescript_detail(
573    root: &Path,
574    exclude: &[String],
575) -> Result<BTreeMap<String, TsPatchCoverage>> {
576    let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
577    istanbul_patch_detail(&json)
578}
579
580/// Pure: per-file [`TsPatchCoverage`] from a vitest v8 Istanbul report, keyed by the
581/// absolute path vitest reports.
582fn istanbul_patch_detail(json: &str) -> Result<BTreeMap<String, TsPatchCoverage>> {
583    let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
584        .context("parsing vitest coverage-final (Istanbul) JSON report")?;
585    Ok(files
586        .into_iter()
587        .map(|(path, file)| (path, istanbul_file_detail(&file)))
588        .collect())
589}
590
591/// Every branch arm `file` records, as `(line, covered)` on its branch's start line. v8 models
592/// a branch as one arm (a `[count]` array) or several, and either way each arm counts.
593fn istanbul_branch_arms(file: &IstanbulFile) -> Vec<(u64, bool)> {
594    let mut out = Vec::new();
595    for (id, branch) in &file.branch_map {
596        let line = branch.loc.start.line;
597        for &count in file.b.get(id).into_iter().flatten() {
598            out.push((line, count > 0));
599        }
600    }
601    out
602}
603
604/// One Istanbul file entry as a [`TsPatchCoverage`]. A statement spans its start and end
605/// line and is covered on a non-zero hit count; a function is pinned to its declaration line.
606fn istanbul_file_detail(file: &IstanbulFile) -> TsPatchCoverage {
607    let mut detail = TsPatchCoverage::default();
608    for (id, span) in &file.statement_map {
609        let covered = file.s.get(id).is_some_and(|&count| count > 0);
610        detail
611            .statements
612            .push((span.start.line, span.end.line, covered));
613    }
614    detail.branch_arms = istanbul_branch_arms(file);
615    for (id, function) in &file.fn_map {
616        let covered = file.f.get(id).is_some_and(|&count| count > 0);
617        detail.functions.push((function.decl.start.line, covered));
618    }
619    detail
620}
621
622/// The `cargo llvm-cov` coverage floors, from a `[rust].coverage` table. `lines` is
623/// always enforced; the rest are opt-in, `None` skipping the check. A `branch` floor
624/// adds `--branch`, which instruments only on a nightly toolchain.
625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub struct RustThresholds {
627    pub regions: Option<u8>,
628    pub lines: u8,
629    pub functions: Option<u8>,
630    pub branch: Option<u8>,
631}
632
633/// A `cargo llvm-cov --json` export, pared to the totals the floor reads. A single
634/// run produces one `data` entry.
635#[derive(Debug, Clone, Deserialize)]
636pub struct LlvmCovReport {
637    pub data: Vec<LlvmCovData>,
638}
639
640/// One export entry — `--summary-only` omits everything but its `totals`.
641#[derive(Debug, Clone, Copy, Deserialize)]
642pub struct LlvmCovData {
643    pub totals: LlvmCovTotals,
644}
645
646/// The `totals` block of an llvm-cov export. `branches` is optional so an export from
647/// a run without branch instrumentation still parses.
648#[derive(Debug, Clone, Copy, Default, Deserialize)]
649pub struct LlvmCovTotals {
650    pub regions: LlvmCovMetric,
651    pub lines: LlvmCovMetric,
652    pub functions: LlvmCovMetric,
653    #[serde(default)]
654    pub branches: Option<LlvmCovMetric>,
655}
656
657/// One metric's totals from an llvm-cov export.
658#[derive(Debug, Clone, Copy, Default, Deserialize)]
659pub struct LlvmCovMetric {
660    /// Size of the denominator (regions or lines counted).
661    pub count: u64,
662    pub covered: u64,
663    pub percent: f64,
664}
665
666/// Parse a `cargo llvm-cov --json` export.
667pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
668    serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
669}
670
671/// Whether `report` meets its thresholds. A run that measured no regions at all — a
672/// wrong path, or a crate that compiled nothing — fails rather than passing vacuously.
673pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
674    let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
675        return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
676    };
677    // Every compiled crate has regions, so a zero denominator measured nothing.
678    if totals.regions.count == 0 {
679        return Outcome::Fail(NOTHING_MEASURED.to_string());
680    }
681    verdict(
682        rust_checks(totals, thresholds)
683            .into_iter()
684            .filter_map(|(name, actual, required)| shortfall(name, actual, required))
685            .collect(),
686    )
687}
688
689/// The floors that apply to `totals`, as `(metric, measured percent, required percent)`.
690/// The zero-config default floors lines only; the rest are opt-in.
691fn rust_checks(totals: &LlvmCovTotals, thresholds: RustThresholds) -> Vec<(&'static str, f64, u8)> {
692    let mut checks = Vec::new();
693    if let Some(regions) = thresholds.regions {
694        checks.push(("regions", totals.regions.percent, regions));
695    }
696    checks.push(("lines", totals.lines.percent, thresholds.lines));
697    if let Some(functions) = thresholds.functions {
698        checks.push(("functions", totals.functions.percent, functions));
699    }
700    if let Some(branch) = thresholds.branch {
701        // A failed instrumentation is a run error surfaced before this point, so a zero
702        // branch denominator means the crate has no branch points — vacuously satisfied.
703        if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
704            checks.push(("branches", branches.percent, branch));
705        }
706    }
707    checks
708}
709
710/// Run the unit suite under `cargo llvm-cov` in `root` and check it against
711/// `thresholds`. `ignore` is the `coverage`-rule exemptions as `root`-relative paths;
712/// `features` the `[rust] features` list to enable. `cargo-llvm-cov` must be installed.
713pub fn measure_rust(
714    root: &Path,
715    thresholds: RustThresholds,
716    ignore: &[String],
717    features: &[String],
718) -> Result<Outcome> {
719    let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
720    Ok(evaluate_rust(&report, thresholds))
721}
722
723/// A `CARGO_TARGET_DIR` under the temp dir — unique per call so parallel checks don't
724/// collide, and removed on drop so the build never leaks into the scanned tree.
725struct TargetDir(PathBuf);
726
727impl TargetDir {
728    fn new() -> Self {
729        static COUNTER: AtomicU64 = AtomicU64::new(0);
730        let name = format!(
731            "testing-conventions-llvm-cov-{}-{}",
732            std::process::id(),
733            COUNTER.fetch_add(1, Ordering::Relaxed),
734        );
735        TargetDir(std::env::temp_dir().join(name))
736    }
737}
738
739impl Drop for TargetDir {
740    fn drop(&mut self) {
741        let _ = std::fs::remove_dir_all(&self.0);
742    }
743}
744
745/// The totals the floor checks, less the items a `#[cfg(not(test))]` gate keeps out of the
746/// test build. `branch` adds `--branch` for a configured branch floor. The run exports in
747/// full rather than `--summary-only`, since the per-function detail is what locates those
748/// items in the totals.
749fn run_llvm_cov(
750    root: &Path,
751    ignore: &[String],
752    features: &[String],
753    branch: bool,
754) -> Result<LlvmCovReport> {
755    let (json, lcov) = run_cargo_llvm_cov_json_and_lcov(root, ignore, features, branch)?;
756    let hidden = hidden_lines_by_file(&json)?;
757    let mut report = llvm_cov_totals_less_hidden(&parse_llvm_cov_export(&json)?, &hidden);
758    let lines = lcov_lines_metric(&lcov, &hidden);
759    for data in &mut report.data {
760        data.totals.lines = lines;
761    }
762    Ok(report)
763}
764
765/// Every `DA:` record of an lcov export, as `line -> covered` per source file.
766fn lcov_lines(lcov: &str) -> BTreeMap<String, BTreeMap<u32, bool>> {
767    let mut out: BTreeMap<String, BTreeMap<u32, bool>> = BTreeMap::new();
768    let mut file: Option<&str> = None;
769    for record in lcov.lines() {
770        if let Some(name) = record.strip_prefix("SF:") {
771            file = Some(name);
772            continue;
773        }
774        let (Some(file), Some((number, covered))) = (file, lcov_da(record)) else {
775            continue;
776        };
777        *out.entry(file.to_string())
778            .or_default()
779            .entry(number)
780            .or_default() |= covered;
781    }
782    out
783}
784
785/// The `(line, covered)` a `DA:` record names, or `None` where the record is some other kind or
786/// carries numbers lcov's grammar does not allow.
787fn lcov_da(record: &str) -> Option<(u32, bool)> {
788    let (number, count) = record.strip_prefix("DA:")?.split_once(',')?;
789    Some((number.parse().ok()?, count.trim().parse::<u64>().ok()? > 0))
790}
791
792/// The line metric an lcov export reports, less the lines a `#[cfg(not(test))]` gate hides.
793///
794/// llvm-cov writes `DA:` records through the merged line view that `show`, `--text` and
795/// `--show-missing-lines` all read, where a line maps once however many instantiations carry
796/// it. A `--json` export's `totals` instead sum each instantiation group's own line tally, so a
797/// line two groups map counts twice — once covered and once not, when only one group ran it.
798/// That is how a 100% floor became unsatisfiable on a file where no view could name a missing
799/// line (#743): a crate built `--lib --bins` compiles its library twice.
800fn lcov_lines_metric(lcov: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovMetric {
801    let mut tally = Tally::default();
802    for (file, lines) in lcov_lines(lcov) {
803        let gated = hidden.get(&file);
804        for (number, covered) in lines {
805            if gated.is_some_and(|gated| gated.contains(&number)) {
806                continue;
807            }
808            tally.count += 1;
809            tally.covered += u64::from(covered);
810        }
811    }
812    as_metric(tally.count, tally.covered)
813}
814
815/// The 1-based lines a `#[cfg(not(test))]` gate hides, per source file the export measured.
816///
817/// The unit tier runs `--lib --bins`, which sets `cfg(test)`, so no test can execute a gated
818/// item. The `--bins` half links the library a second time as a plain dependency of the binary
819/// target's test harness, where `cfg(test)` is unset: the item is compiled and instrumented
820/// there and lands as 0-hit, or not, depending on how the linker partitioned that build. An
821/// unreadable file hides nothing, leaving every line it maps in the ratios.
822fn hidden_lines_by_file(json: &str) -> Result<BTreeMap<String, BTreeSet<u32>>> {
823    let export = parse_llvm_cov_export(json)?;
824    let mut out = BTreeMap::new();
825    for data in &export.data {
826        for file in &data.files {
827            let Ok(source) = std::fs::read_to_string(&file.filename) else {
828                continue;
829            };
830            let lines = crate::isolation::lines_hidden_from_tests(&source);
831            if !lines.is_empty() {
832                out.insert(file.filename.clone(), lines);
833            }
834        }
835    }
836    Ok(out)
837}
838
839/// One metric's count and covered pair, the shape every llvm-cov metric reports.
840#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
841struct Tally {
842    count: u64,
843    covered: u64,
844}
845
846impl Tally {
847    /// llvm-cov merges the records of one instantiation group by `max`, so a delta over that
848    /// group does too — two copies of the same function subtract once.
849    fn merge(&mut self, other: Tally) {
850        self.count = self.count.max(other.count);
851        self.covered = self.covered.max(other.covered);
852    }
853
854    fn add(&mut self, other: Tally) {
855        self.count += other.count;
856        self.covered += other.covered;
857    }
858}
859
860/// What the items a `#[cfg(not(test))]` gate hides contribute to an export's totals.
861#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
862struct HiddenTotals {
863    regions: Tally,
864    lines: Tally,
865    functions: Tally,
866    branches: Tally,
867}
868
869impl HiddenTotals {
870    fn merge(&mut self, other: HiddenTotals) {
871        self.regions.merge(other.regions);
872        self.lines.merge(other.lines);
873        self.functions.merge(other.functions);
874        self.branches.merge(other.branches);
875    }
876
877    fn add(&mut self, other: HiddenTotals) {
878        self.regions.add(other.regions);
879        self.lines.add(other.lines);
880        self.functions.add(other.functions);
881        self.branches.add(other.branches);
882    }
883}
884
885/// Pure: the export's totals less what its gated items contribute.
886fn llvm_cov_totals_less_hidden(
887    export: &LlvmCovExport,
888    hidden: &BTreeMap<String, BTreeSet<u32>>,
889) -> LlvmCovReport {
890    LlvmCovReport {
891        data: export
892            .data
893            .iter()
894            .map(|data| LlvmCovData {
895                totals: totals_less_hidden(data, hidden),
896            })
897            .collect(),
898    }
899}
900
901/// One export entry's totals with the gated items' share taken out of every metric.
902fn totals_less_hidden(
903    data: &LlvmCovExportData,
904    hidden: &BTreeMap<String, BTreeSet<u32>>,
905) -> LlvmCovTotals {
906    let delta = hidden_totals(data, hidden);
907    LlvmCovTotals {
908        regions: metric_less(data.totals.regions, delta.regions),
909        lines: metric_less(data.totals.lines, delta.lines),
910        functions: metric_less(data.totals.functions, delta.functions),
911        branches: data
912            .totals
913            .branches
914            .map(|metric| metric_less(metric, delta.branches)),
915    }
916}
917
918/// One metric less the gated items' share, its percent recomputed.
919fn metric_less(metric: LlvmCovMetric, delta: Tally) -> LlvmCovMetric {
920    let count = metric.count.saturating_sub(delta.count);
921    as_metric(
922        count,
923        metric.covered.saturating_sub(delta.covered).min(count),
924    )
925}
926
927/// A metric from its pair. An empty denominator reads 100%: with nothing left to measure there
928/// is nothing left to miss.
929fn as_metric(count: u64, covered: u64) -> LlvmCovMetric {
930    let percent = if count == 0 {
931        100.0
932    } else {
933        covered as f64 * 100.0 / count as f64
934    };
935    LlvmCovMetric {
936        count,
937        covered,
938        percent,
939    }
940}
941
942/// The gated items' contribution to one export entry. llvm-cov groups function records by their
943/// first region's start location, so the `--bins` half's second copy of an ungated function
944/// merges with the first; a gated item has no such twin and stands alone as 0-hit.
945fn hidden_totals(
946    data: &LlvmCovExportData,
947    hidden: &BTreeMap<String, BTreeSet<u32>>,
948) -> HiddenTotals {
949    let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
950    let mut groups: BTreeMap<FunctionGroup, HiddenTotals> = BTreeMap::new();
951    for function in &data.functions {
952        let Some((group, lines)) = hidden_group(function, &measured, hidden) else {
953            continue;
954        };
955        groups
956            .entry(group)
957            .or_default()
958            .merge(function_totals(function, lines));
959    }
960    groups.values().fold(HiddenTotals::default(), |mut acc, g| {
961        acc.add(*g);
962        acc
963    })
964}
965
966/// The group a gated function record contributes to, with its file's gated lines. A record
967/// outside the measured files, or starting on a line no gate hides, contributes nothing.
968fn hidden_group<'h>(
969    function: &LlvmCovFunction,
970    measured: &BTreeSet<&str>,
971    hidden: &'h BTreeMap<String, BTreeSet<u32>>,
972) -> Option<(FunctionGroup, &'h BTreeSet<u32>)> {
973    let (file, line, column) = function_start(function)?;
974    if !measured.contains(file.as_str()) {
975        return None;
976    }
977    let lines = hidden.get(&file)?;
978    if !lines.contains(&(line.max(0) as u32)) {
979        return None;
980    }
981    Some(((file, line, column), lines))
982}
983
984/// The `(file, line, column)` llvm-cov groups a function record under.
985type FunctionGroup = (String, i64, i64);
986
987/// The group llvm-cov files a function record under — its first region's start. A record with no
988/// region, or one naming a file outside its own list, has no group.
989fn function_start(function: &LlvmCovFunction) -> Option<FunctionGroup> {
990    let region = function.regions.first()?;
991    if region.len() < 8 {
992        return None;
993    }
994    let file = function.filenames.get(usize::try_from(region[5]).ok()?)?;
995    Some((file.clone(), region[0], region[1]))
996}
997
998/// One record's share: its code regions, the gated source lines they map, its own execution,
999/// and the two outcomes llvm-cov counts per branch region.
1000fn function_totals(function: &LlvmCovFunction, hidden: &BTreeSet<u32>) -> HiddenTotals {
1001    let code = code_regions(function);
1002    let lines = hidden_line_coverage(&code, hidden);
1003    HiddenTotals {
1004        regions: Tally {
1005            count: code.len() as u64,
1006            covered: code.iter().filter(|region| region[4] > 0).count() as u64,
1007        },
1008        lines: Tally {
1009            count: lines.len() as u64,
1010            covered: lines.values().filter(|covered| **covered).count() as u64,
1011        },
1012        functions: Tally {
1013            count: 1,
1014            covered: u64::from(function.count > 0),
1015        },
1016        branches: branch_tally(&function.branches),
1017    }
1018}
1019
1020/// The record's *code* regions — llvm-cov tags a region's kind in slot 7, and only kind 0
1021/// is code. Anything shorter than eight slots is not a region this reading understands.
1022fn code_regions(function: &LlvmCovFunction) -> Vec<&Vec<i64>> {
1023    function
1024        .regions
1025        .iter()
1026        .filter(|region| region.len() >= 8 && region[7] == 0)
1027        .collect()
1028}
1029
1030/// Which of the gated `hidden` lines `code` covers. A line spanned by several regions is
1031/// covered when *any* of them executed, so the entries OR together.
1032fn hidden_line_coverage(code: &[&Vec<i64>], hidden: &BTreeSet<u32>) -> BTreeMap<u32, bool> {
1033    let mut lines: BTreeMap<u32, bool> = BTreeMap::new();
1034    for region in code {
1035        for line in region[0].max(0) as u32..=region[2].max(0) as u32 {
1036            if hidden.contains(&line) {
1037                *lines.entry(line).or_default() |= region[4] > 0;
1038            }
1039        }
1040    }
1041    lines
1042}
1043
1044/// Two outcomes per branch region: llvm-cov counts the true and false arms separately.
1045fn branch_tally(branches: &[Vec<i64>]) -> Tally {
1046    let mut tally = Tally::default();
1047    for branch in branches.iter().filter(|branch| branch.len() >= 9) {
1048        tally.count += 2;
1049        tally.covered += u64::from(branch[4] > 0) + u64::from(branch[5] > 0);
1050    }
1051    tally
1052}
1053
1054/// Run `cargo llvm-cov --lib` over the unit suite in `root` with the given coverage
1055/// `format` args and return its stdout. Shared by the whole-tree floor and the
1056/// diff-scoped floor, so both measure the same unit-only slice.
1057fn run_cargo_llvm_cov(
1058    root: &Path,
1059    ignore: &[String],
1060    format: &[&str],
1061    features: &[String],
1062    branch: bool,
1063) -> Result<String> {
1064    run_in(&TargetDir::new(), root, ignore, format, features, branch)
1065}
1066
1067/// Both exports of a single run: `--json` for the per-function detail, then a `report --lcov`
1068/// pass over the profile that run left in the target dir. The second pass re-reads the profile
1069/// rather than running the suite again.
1070fn run_cargo_llvm_cov_json_and_lcov(
1071    root: &Path,
1072    ignore: &[String],
1073    features: &[String],
1074    branch: bool,
1075) -> Result<(String, String)> {
1076    let target = TargetDir::new();
1077    let json = run_in(&target, root, ignore, &["--json"], features, branch)?;
1078    let lcov = report_in(&target, root, ignore, &["--lcov"])?;
1079    Ok((json, lcov))
1080}
1081
1082/// Re-export the profile already in `target`. `report` runs no tests, so the two views a caller
1083/// compares come from one execution of the suite.
1084fn report_in(
1085    target: &TargetDir,
1086    root: &Path,
1087    ignore: &[String],
1088    format: &[&str],
1089) -> Result<String> {
1090    let mut command = Command::new("cargo");
1091    command
1092        .current_dir(root)
1093        .args(report_argv(format, ignore_filename_regex(root, ignore)))
1094        .env("CARGO_TARGET_DIR", &target.0);
1095    scrub_outer_llvm_cov(&mut command);
1096    let output = command
1097        .output()
1098        .context("running `cargo llvm-cov report` (is cargo-llvm-cov installed?)")?;
1099    llvm_cov_stdout(
1100        output.status.success(),
1101        &output.stdout,
1102        &output.stderr,
1103        &format!(
1104            "cargo llvm-cov could not re-export the profile it just wrote in `{}`:",
1105            root.display()
1106        ),
1107    )
1108}
1109
1110fn report_argv(format: &[&str], ignore_regex: Option<String>) -> Vec<String> {
1111    let mut argv = vec!["llvm-cov".to_string(), "report".to_string()];
1112    argv.extend(format.iter().map(|arg| (*arg).to_string()));
1113    if let Some(regex) = ignore_regex {
1114        argv.extend(["--ignore-filename-regex".to_string(), regex]);
1115    }
1116    argv
1117}
1118
1119/// An invocation's stdout, or `failure` carrying both of its streams when it exited non-zero.
1120fn llvm_cov_stdout(success: bool, stdout: &[u8], stderr: &[u8], failure: &str) -> Result<String> {
1121    if !success {
1122        bail!(
1123            "{failure}\n{}{}",
1124            String::from_utf8_lossy(stdout),
1125            String::from_utf8_lossy(stderr)
1126        );
1127    }
1128    Ok(String::from_utf8_lossy(stdout).into_owned())
1129}
1130
1131fn run_in(
1132    target: &TargetDir,
1133    root: &Path,
1134    ignore: &[String],
1135    format: &[&str],
1136    features: &[String],
1137    branch: bool,
1138) -> Result<String> {
1139    let mut command = Command::new("cargo");
1140    let argv = run_argv(
1141        format,
1142        features,
1143        branch,
1144        ignore_filename_regex(root, ignore),
1145    );
1146    aim_llvm_cov(&mut command, target, root, argv);
1147    let output = command
1148        .output()
1149        .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
1150    llvm_cov_stdout(
1151        output.status.success(),
1152        &output.stdout,
1153        &output.stderr,
1154        &llvm_cov_failure(root, branch),
1155    )
1156}
1157
1158/// Point `command` at `root` with `argv`, write its build into `target`, and strip an outer
1159/// `cargo llvm-cov` run's instrumentation state.
1160fn aim_llvm_cov(command: &mut Command, target: &TargetDir, root: &Path, argv: Vec<String>) {
1161    command
1162        .current_dir(root)
1163        .args(argv)
1164        .env("CARGO_TARGET_DIR", &target.0);
1165    scrub_outer_llvm_cov(command);
1166}
1167
1168/// What a `cargo llvm-cov` run that exited non-zero in `root` reports, the branch floor's
1169/// nightly requirement included where it ran with `--branch`.
1170fn llvm_cov_failure(root: &Path, branch: bool) -> String {
1171    format!(
1172        "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{}",
1173        root.display(),
1174        branch_hint(branch)
1175    )
1176}
1177
1178fn run_argv(
1179    format: &[&str],
1180    features: &[String],
1181    branch: bool,
1182    ignore_regex: Option<String>,
1183) -> Vec<String> {
1184    let mut argv = vec![
1185        "llvm-cov".to_string(),
1186        "--lib".to_string(),
1187        "--bins".to_string(),
1188    ];
1189    argv.extend(format.iter().map(|arg| (*arg).to_string()));
1190    if !features.is_empty() {
1191        argv.extend(["--features".to_string(), features.join(",")]);
1192    }
1193    if branch {
1194        argv.push("--branch".to_string());
1195    }
1196    if let Some(regex) = ignore_regex {
1197        argv.extend(["--ignore-filename-regex".to_string(), regex]);
1198    }
1199    argv
1200}
1201
1202fn branch_hint(branch: bool) -> &'static str {
1203    if branch {
1204        "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
1205         nightly toolchain — pin one in the crate's rust-toolchain.toml with \
1206         llvm-tools-preview, or set a rustup directory override)"
1207    } else {
1208        ""
1209    }
1210}
1211
1212/// Every variable a spawning cargo must not inherit from an outer `cargo llvm-cov` run. An
1213/// inherited `RUSTC_WRAPPER` re-enters cargo-llvm-cov on every rustc call until the runner is
1214/// OOM-killed, and an inherited `RUSTUP_TOOLCHAIN` overrides the scanned crate's own nightly pin.
1215const OUTER_LLVM_COV_VARS: &[&str] = &[
1216    "RUSTFLAGS",
1217    "CARGO_ENCODED_RUSTFLAGS",
1218    "RUSTDOCFLAGS",
1219    "CARGO_ENCODED_RUSTDOCFLAGS",
1220    "LLVM_PROFILE_FILE",
1221    "CARGO_LLVM_COV",
1222    "CARGO_LLVM_COV_SHOW_ENV",
1223    "CARGO_LLVM_COV_TARGET_DIR",
1224    "CARGO_LLVM_COV_BUILD_DIR",
1225    "RUSTC_WRAPPER",
1226    "RUSTC_WORKSPACE_WRAPPER",
1227    "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1228    "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1229    "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1230    "RUSTUP_TOOLCHAIN",
1231    "CARGO",
1232    "RUSTC",
1233];
1234
1235/// Strip the outer run's instrumentation state from `command`.
1236fn scrub_outer_llvm_cov(command: &mut Command) {
1237    for var in OUTER_LLVM_COV_VARS {
1238        command.env_remove(var);
1239    }
1240}
1241
1242/// Per-file region detail from a `cargo llvm-cov --json` export — what
1243/// [`crate::patch_coverage::evaluate_patch_rust`] restricts to the changed lines.
1244#[derive(Debug, Clone, Default)]
1245pub struct RustPatchCoverage {
1246    /// One per `kind == 0` code region: `(start_line, end_line, covered)`. A region
1247    /// counts toward the diff when any line it spans is changed.
1248    pub regions: Vec<(u64, u64, bool)>,
1249}
1250
1251/// A full `cargo llvm-cov --json` export, modeling the per-function region detail the
1252/// diff-scoped floor needs — separate from [`LlvmCovReport`], which keeps the totals.
1253#[derive(Debug, Clone, Deserialize)]
1254struct LlvmCovExport {
1255    data: Vec<LlvmCovExportData>,
1256}
1257
1258/// One export entry. `--ignore-filename-regex` drops an exempt file from `files` but
1259/// *not* from `functions` (the regions array is unfiltered), so `files` is the
1260/// allowlist [`llvm_cov_patch_detail`] restricts the regions to.
1261#[derive(Debug, Clone, Deserialize)]
1262struct LlvmCovExportData {
1263    files: Vec<LlvmCovExportFile>,
1264    functions: Vec<LlvmCovFunction>,
1265    /// The same block [`LlvmCovReport`] reads. Defaulted so a fixture that models only the
1266    /// region detail still parses.
1267    #[serde(default)]
1268    totals: LlvmCovTotals,
1269}
1270
1271/// One measured file in the export's `files` block — only its absolute `filename` is
1272/// needed, to build the not-ignored allowlist.
1273#[derive(Debug, Clone, Deserialize)]
1274struct LlvmCovExportFile {
1275    filename: String,
1276}
1277
1278/// One function's coverage: the files it spans (`filenames`, indexed by a region's
1279/// `fileID`) and its regions. Each region is a flat array `[lineStart, colStart,
1280/// lineEnd, colEnd, executionCount, fileID, expandedFileID, kind]`, read positionally.
1281/// A branch region sits in `branches` instead, with `falseExecutionCount` inserted at index 5.
1282#[derive(Debug, Clone, Deserialize)]
1283struct LlvmCovFunction {
1284    filenames: Vec<String>,
1285    regions: Vec<Vec<i64>>,
1286    /// How many times the function itself ran.
1287    #[serde(default)]
1288    count: u64,
1289    #[serde(default)]
1290    branches: Vec<Vec<i64>>,
1291}
1292
1293/// Run the Rust unit suite under `cargo llvm-cov` and return the per-file region
1294/// detail, keyed by the absolute path llvm-cov reports. `ignore` is the
1295/// `coverage`-rule exemptions, dropped so an exempt file's changed lines are lifted.
1296pub fn measure_patch_rust_detail(
1297    root: &Path,
1298    ignore: &[String],
1299    features: &[String],
1300) -> Result<BTreeMap<String, RustPatchCoverage>> {
1301    // The diff-scoped floor judges regions + lines, so its run never adds `--branch`.
1302    let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
1303    let hidden = hidden_lines_by_file(&json)?;
1304    llvm_cov_patch_detail(&json, &hidden)
1305}
1306
1307/// Parse a full `cargo llvm-cov --json` export.
1308fn parse_llvm_cov_export(json: &str) -> Result<LlvmCovExport> {
1309    serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")
1310}
1311
1312/// Pure: per-file [`RustPatchCoverage`] from a `cargo llvm-cov --json` export, keyed
1313/// by the absolute path llvm-cov reports.
1314fn llvm_cov_patch_detail(
1315    json: &str,
1316    hidden: &BTreeMap<String, BTreeSet<u32>>,
1317) -> Result<BTreeMap<String, RustPatchCoverage>> {
1318    let export = parse_llvm_cov_export(json)?;
1319    let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1320    for data in &export.data {
1321        let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1322        for function in &data.functions {
1323            for region in &function.regions {
1324                if let Some((file, start, end, covered)) =
1325                    patch_region(region, &function.filenames, &measured, hidden)
1326                {
1327                    out.entry(file)
1328                        .or_default()
1329                        .regions
1330                        .push((start, end, covered));
1331                }
1332            }
1333        }
1334    }
1335    Ok(out)
1336}
1337
1338/// One llvm-cov region's contribution as `(file, start line, end line, covered)`, or `None`
1339/// when it carries no line-coverage signal: only `kind == 0` code regions in the `measured`
1340/// allowlist count, a malformed short region is skipped rather than indexed, and a region
1341/// starting on a line `hidden` names is dropped — no test can execute it.
1342fn patch_region(
1343    region: &[i64],
1344    filenames: &[String],
1345    measured: &BTreeSet<&str>,
1346    hidden: &BTreeMap<String, BTreeSet<u32>>,
1347) -> Option<(String, u64, u64, bool)> {
1348    if region.len() < 8 {
1349        return None;
1350    }
1351    // gap (1) / expansion (2) / branch regions carry no line-coverage signal.
1352    if region[7] != 0 {
1353        return None;
1354    }
1355    let file = filenames.get(usize::try_from(region[5]).ok()?)?;
1356    // A `coverage` exemption drops the file's regions, lifting its lines.
1357    if !measured.contains(file.as_str()) {
1358        return None;
1359    }
1360    let start = region[0].max(0) as u64;
1361    // A gated item is instrumented in the bin target's test harness, where `cfg(test)`
1362    // is unset, and no test can reach it.
1363    if hidden
1364        .get(file)
1365        .is_some_and(|lines| lines.contains(&(start as u32)))
1366    {
1367        return None;
1368    }
1369    Some((file.clone(), start, region[2].max(0) as u64, region[4] > 0))
1370}
1371
1372/// The single `--ignore-filename-regex` for the run, or `None` when nothing is exempt.
1373/// It is a substring search over absolute filenames, so each exempt path is escaped,
1374/// joined under `root`, and `$`-anchored — else it over-matches `member/src/a.rs`.
1375fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1376    if ignore.is_empty() {
1377        return None;
1378    }
1379    Some(
1380        ignore
1381            .iter()
1382            .map(|rel| {
1383                // The fallback keeps the anchor deterministic when the path can't be
1384                // resolved (e.g. in tests).
1385                let full = root.join(rel);
1386                let full = full.canonicalize().unwrap_or(full);
1387                format!("{}$", regex_escape(&full.to_string_lossy()))
1388            })
1389            .collect::<Vec<_>>()
1390            .join("|"),
1391    )
1392}
1393
1394/// Escape `s`'s regex metacharacters so an exempt path matches literally.
1395fn regex_escape(s: &str) -> String {
1396    const META: &str = r"\.+*?()|[]{}^$";
1397    let mut out = String::with_capacity(s.len());
1398    for c in s.chars() {
1399        if META.contains(c) {
1400            out.push('\\');
1401        }
1402        out.push(c);
1403    }
1404    out
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410
1411    #[test]
1412    fn istanbul_detail_reads_statements_branches_and_functions() {
1413        let json = r#"{
1414            "/pkg/src/widget.ts": {
1415                "path": "/pkg/src/widget.ts",
1416                "statementMap": {
1417                    "0": {"start": {"line": 3}, "end": {"line": 5}},
1418                    "1": {"start": {"line": 9}, "end": {"line": 9}}
1419                },
1420                "s": {"0": 2, "1": 0},
1421                "branchMap": {"0": {"loc": {"start": {"line": 4}, "end": {"line": 7}}}},
1422                "b": {"0": [1, 0]},
1423                "fnMap": {
1424                    "0": {"decl": {"start": {"line": 2}, "end": {"line": 2}}},
1425                    "1": {"decl": {"start": {"line": 8}, "end": {"line": 8}}}
1426                },
1427                "f": {"0": 0, "1": 3}
1428            }
1429        }"#;
1430        let detail = istanbul_patch_detail(json).unwrap();
1431        let file = &detail["/pkg/src/widget.ts"];
1432        // A statement spans start..end, and is covered only on a *positive* hit count.
1433        assert!(file.statements.contains(&(3, 5, true)));
1434        assert!(file.statements.contains(&(9, 9, false)));
1435        // One tuple per arm, every arm on the branch's start line.
1436        assert_eq!(file.branch_arms, vec![(4, true), (4, false)]);
1437        // A function is pinned to its declaration line, not its body, and a positive call
1438        // count is the only thing that makes it covered.
1439        assert_eq!(file.functions, vec![(2, false), (8, true)]);
1440    }
1441
1442    #[test]
1443    fn a_zero_hit_count_is_uncovered_rather_than_covered() {
1444        let json = r#"{
1445            "/pkg/src/widget.ts": {
1446                "path": "/pkg/src/widget.ts",
1447                "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 1}}},
1448                "s": {"0": 0},
1449                "branchMap": {},
1450                "b": {},
1451                "fnMap": {},
1452                "f": {}
1453            }
1454        }"#;
1455        let detail = istanbul_patch_detail(json).unwrap();
1456        assert_eq!(detail["/pkg/src/widget.ts"].statements, vec![(1, 1, false)]);
1457    }
1458
1459    #[test]
1460    fn only_kind_zero_regions_count_as_code() {
1461        let function = LlvmCovFunction {
1462            filenames: vec!["a.rs".into()],
1463            // slot 7 is the region kind: 0 is code, anything else is not.
1464            regions: vec![vec![1, 0, 3, 0, 1, 0, 0, 0], vec![4, 0, 6, 0, 1, 0, 0, 2]],
1465            count: 1,
1466            branches: Vec::new(),
1467        };
1468        let code = code_regions(&function);
1469        assert_eq!(code.len(), 1);
1470        assert_eq!(code[0][0], 1);
1471    }
1472
1473    #[test]
1474    fn a_region_too_short_to_carry_a_kind_is_not_code() {
1475        let function = LlvmCovFunction {
1476            filenames: vec!["a.rs".into()],
1477            regions: vec![vec![1, 0, 3, 0, 1]],
1478            count: 1,
1479            branches: Vec::new(),
1480        };
1481        assert!(code_regions(&function).is_empty());
1482    }
1483
1484    #[test]
1485    fn several_regions_over_one_hidden_line_or_together() {
1486        let uncovered = vec![1i64, 0, 1, 0, 0, 0, 0, 0];
1487        let covered = vec![1i64, 0, 1, 0, 4, 0, 0, 0];
1488        let hidden: BTreeSet<u32> = [1].into_iter().collect();
1489        let code: Vec<&Vec<i64>> = vec![&uncovered, &covered];
1490        assert_eq!(hidden_line_coverage(&code, &hidden).get(&1), Some(&true));
1491
1492        let only_uncovered: Vec<&Vec<i64>> = vec![&uncovered];
1493        assert_eq!(
1494            hidden_line_coverage(&only_uncovered, &hidden).get(&1),
1495            Some(&false)
1496        );
1497    }
1498
1499    #[test]
1500    fn a_line_outside_the_gated_set_is_not_tallied() {
1501        let region = vec![1i64, 0, 9, 0, 1, 0, 0, 0];
1502        let code: Vec<&Vec<i64>> = vec![&region];
1503        let hidden: BTreeSet<u32> = [4].into_iter().collect();
1504        let lines = hidden_line_coverage(&code, &hidden);
1505        assert_eq!(lines.keys().copied().collect::<Vec<u32>>(), vec![4]);
1506    }
1507
1508    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1509        CoverageReport {
1510            totals: Totals {
1511                percent_covered,
1512                num_branches,
1513            },
1514            files: BTreeMap::new(),
1515        }
1516    }
1517
1518    #[test]
1519    fn passes_when_total_meets_the_floor() {
1520        assert_eq!(
1521            evaluate(
1522                &report(100.0, 12),
1523                Thresholds {
1524                    fail_under: 100,
1525                    branch: true
1526                }
1527            ),
1528            Outcome::Pass
1529        );
1530    }
1531
1532    #[test]
1533    fn fails_when_total_is_below_the_floor() {
1534        assert!(matches!(
1535            evaluate(
1536                &report(80.0, 12),
1537                Thresholds {
1538                    fail_under: 100,
1539                    branch: true
1540                }
1541            ),
1542            Outcome::Fail(_)
1543        ));
1544    }
1545
1546    #[test]
1547    fn passes_when_branch_required_and_none_are_measured() {
1548        assert_eq!(
1549            evaluate(
1550                &report(100.0, 0),
1551                Thresholds {
1552                    fail_under: 100,
1553                    branch: true
1554                }
1555            ),
1556            Outcome::Pass
1557        );
1558    }
1559
1560    #[test]
1561    fn parses_a_coverage_py_report() {
1562        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1563        let report = parse_report(json).expect("valid coverage.py json");
1564        assert_eq!(report.totals.percent_covered, 91.5);
1565        assert_eq!(report.totals.num_branches, 8);
1566    }
1567
1568    #[test]
1569    fn parses_the_per_file_block_for_patch_coverage() {
1570        let json = r#"{
1571            "files": {
1572                "widget.py": {
1573                    "executed_lines": [1, 2, 3, 4, 6],
1574                    "summary": {"percent_covered": 85.0},
1575                    "missing_lines": [5],
1576                    "excluded_lines": [],
1577                    "missing_branches": [[4, 5]]
1578                }
1579            },
1580            "totals": {"percent_covered": 85.0, "num_branches": 4}
1581        }"#;
1582        let report = parse_report(json).expect("valid coverage.py json with files");
1583        let widget = report.files.get("widget.py").expect("widget.py is present");
1584        assert_eq!(widget.missing_lines, vec![5]);
1585        assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1586        assert_eq!(report.totals.percent_covered, 85.0);
1587    }
1588
1589    #[test]
1590    fn a_report_without_a_files_block_parses_with_an_empty_map() {
1591        let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1592            .expect("valid coverage.py json");
1593        assert!(report.files.is_empty());
1594    }
1595
1596    #[test]
1597    fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1598        assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1599    }
1600
1601    #[test]
1602    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1603        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1604        assert_eq!(
1605            build_omit(&exempt),
1606            "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1607        );
1608    }
1609
1610    fn metric(pct: f64) -> VitestMetric {
1611        VitestMetric {
1612            pct: Some(pct),
1613            total: 10,
1614        }
1615    }
1616
1617    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1618        VitestReport {
1619            total: VitestTotals {
1620                lines: metric(lines),
1621                branches: metric(branches),
1622                functions: metric(functions),
1623                statements: metric(statements),
1624            },
1625        }
1626    }
1627
1628    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1629        lines: 100,
1630        branches: 100,
1631        functions: 100,
1632        statements: 100,
1633    };
1634    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1635        lines: 80,
1636        branches: 75,
1637        functions: 80,
1638        statements: 80,
1639    };
1640
1641    #[test]
1642    fn typescript_passes_when_every_metric_meets_its_floor() {
1643        assert_eq!(
1644            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1645            Outcome::Pass
1646        );
1647    }
1648
1649    #[test]
1650    fn typescript_fails_on_the_one_metric_below_its_floor() {
1651        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1652        assert!(
1653            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1654            "got: {outcome:?}"
1655        );
1656    }
1657
1658    #[test]
1659    fn typescript_fail_message_names_every_metric_below() {
1660        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1661        assert!(
1662            matches!(&outcome, Outcome::Fail(message)
1663                if message.contains("lines")
1664                    && message.contains("branches")
1665                    && message.contains("functions")
1666                    && message.contains("statements")),
1667            "got: {outcome:?}"
1668        );
1669    }
1670
1671    #[test]
1672    fn typescript_tolerates_float_noise_at_the_floor() {
1673        assert_eq!(
1674            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1675            Outcome::Pass
1676        );
1677    }
1678
1679    #[test]
1680    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1681        let report = VitestReport {
1682            total: VitestTotals {
1683                lines: metric(100.0),
1684                branches: VitestMetric {
1685                    pct: None,
1686                    total: 0,
1687                },
1688                functions: metric(100.0),
1689                statements: metric(100.0),
1690            },
1691        };
1692        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1693    }
1694
1695    #[test]
1696    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1697        let nothing = VitestMetric {
1698            pct: None,
1699            total: 0,
1700        };
1701        let report = VitestReport {
1702            total: VitestTotals {
1703                lines: nothing,
1704                branches: nothing,
1705                functions: nothing,
1706                statements: nothing,
1707            },
1708        };
1709        let outcome = evaluate_typescript(&report, TS_MID);
1710        assert!(
1711            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1712            "got: {outcome:?}"
1713        );
1714    }
1715
1716    #[test]
1717    fn parses_a_vitest_summary_report() {
1718        let json = r#"{
1719            "total": {
1720                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1721                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1722                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1723                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1724                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1725            },
1726            "/abs/widget.ts": {
1727                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1728            }
1729        }"#;
1730        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1731        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
1732        assert_eq!(report.total.lines.pct, Some(80.0));
1733        assert_eq!(report.total.branches.pct, Some(66.66));
1734        assert_eq!(report.total.functions.total, 2);
1735    }
1736
1737    #[test]
1738    fn parses_an_unknown_pct_as_unmeasured() {
1739        let json = r#"{"total": {
1740            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1741            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1742            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1743            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1744        }}"#;
1745        let report = parse_vitest_report(json).expect("valid vitest json-summary");
1746        assert_eq!(report.total.lines.pct, None);
1747        assert_eq!(report.total.lines.total, 0);
1748    }
1749
1750    #[test]
1751    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1752        let json = r#"{"total":{
1753            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1754            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1755            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1756            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1757        }}"#;
1758        assert!(parse_vitest_report(json).is_err());
1759    }
1760
1761    fn rust_metric(percent: f64) -> LlvmCovMetric {
1762        LlvmCovMetric {
1763            count: 10,
1764            covered: 10,
1765            percent,
1766        }
1767    }
1768
1769    fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1770        LlvmCovReport {
1771            data: vec![LlvmCovData {
1772                totals: LlvmCovTotals {
1773                    regions: rust_metric(regions),
1774                    lines: rust_metric(lines),
1775                    functions: rust_metric(lines),
1776                    branches: None,
1777                },
1778            }],
1779        }
1780    }
1781
1782    /// Like [`rust_report`] with explicit functions/branches; `branches: (count,
1783    /// percent)` so the vacuous zero-denominator case is constructible.
1784    fn rust_report_full(
1785        regions: f64,
1786        lines: f64,
1787        functions: f64,
1788        branches: (u64, f64),
1789    ) -> LlvmCovReport {
1790        let (count, percent) = branches;
1791        LlvmCovReport {
1792            data: vec![LlvmCovData {
1793                totals: LlvmCovTotals {
1794                    regions: rust_metric(regions),
1795                    lines: rust_metric(lines),
1796                    functions: rust_metric(functions),
1797                    branches: Some(LlvmCovMetric {
1798                        count,
1799                        covered: count,
1800                        percent,
1801                    }),
1802                },
1803            }],
1804        }
1805    }
1806
1807    const RUST_FULL: RustThresholds = RustThresholds {
1808        regions: Some(100),
1809        lines: 100,
1810        functions: None,
1811        branch: None,
1812    };
1813    const RUST_MID: RustThresholds = RustThresholds {
1814        regions: Some(80),
1815        lines: 85,
1816        functions: None,
1817        branch: None,
1818    };
1819
1820    #[test]
1821    fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1822        let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1823        let floor = |functions| RustThresholds {
1824            regions: None,
1825            lines: 50,
1826            functions: Some(functions),
1827            branch: None,
1828        };
1829        assert!(matches!(
1830            evaluate_rust(&report, floor(100)),
1831            Outcome::Fail(message) if message.contains("functions")
1832        ));
1833        assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1834    }
1835
1836    #[test]
1837    fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1838        let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1839        let floor = |branch| RustThresholds {
1840            regions: None,
1841            lines: 50,
1842            functions: None,
1843            branch: Some(branch),
1844        };
1845        assert!(matches!(
1846            evaluate_rust(&report, floor(100)),
1847            Outcome::Fail(message) if message.contains("branches")
1848        ));
1849        assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1850    }
1851
1852    #[test]
1853    fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1854        let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1855        let floor = RustThresholds {
1856            regions: None,
1857            lines: 50,
1858            functions: None,
1859            branch: Some(100),
1860        };
1861        assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1862    }
1863
1864    #[test]
1865    fn rust_passes_when_both_metrics_meet_their_floor() {
1866        assert_eq!(
1867            evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1868            Outcome::Pass
1869        );
1870    }
1871
1872    #[test]
1873    fn rust_fails_on_the_one_metric_below_its_floor() {
1874        let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1875        assert!(
1876            matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1877            "got: {outcome:?}"
1878        );
1879    }
1880
1881    #[test]
1882    fn rust_fail_message_names_every_metric_below() {
1883        let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1884        assert!(
1885            matches!(&outcome, Outcome::Fail(message)
1886                if message.contains("regions") && message.contains("lines")),
1887            "got: {outcome:?}"
1888        );
1889    }
1890
1891    #[test]
1892    fn rust_skips_the_region_check_when_regions_is_opt_out() {
1893        let thresholds = RustThresholds {
1894            regions: None,
1895            lines: 100,
1896            functions: None,
1897            branch: None,
1898        };
1899        assert_eq!(
1900            evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1901            Outcome::Pass
1902        );
1903    }
1904
1905    #[test]
1906    fn rust_still_fails_lines_with_regions_opt_out() {
1907        let thresholds = RustThresholds {
1908            regions: None,
1909            lines: 100,
1910            functions: None,
1911            branch: None,
1912        };
1913        let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1914        assert!(
1915            matches!(&outcome, Outcome::Fail(message)
1916                if message.contains("lines") && !message.contains("regions")),
1917            "got: {outcome:?}"
1918        );
1919    }
1920
1921    #[test]
1922    fn rust_tolerates_float_noise_at_the_floor() {
1923        assert_eq!(
1924            evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1925            Outcome::Pass
1926        );
1927    }
1928
1929    #[test]
1930    fn rust_fails_a_vacuous_run_that_measured_no_code() {
1931        let nothing = LlvmCovMetric {
1932            count: 0,
1933            covered: 0,
1934            percent: 0.0,
1935        };
1936        let report = LlvmCovReport {
1937            data: vec![LlvmCovData {
1938                totals: LlvmCovTotals {
1939                    regions: nothing,
1940                    lines: nothing,
1941                    functions: nothing,
1942                    branches: None,
1943                },
1944            }],
1945        };
1946        let outcome = evaluate_rust(&report, RUST_MID);
1947        assert!(
1948            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1949            "got: {outcome:?}"
1950        );
1951    }
1952
1953    #[test]
1954    fn rust_fails_an_export_with_no_data() {
1955        let report = LlvmCovReport { data: vec![] };
1956        assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1957    }
1958
1959    #[test]
1960    fn parses_a_cargo_llvm_cov_report() {
1961        let json = r#"{
1962            "data": [{"totals": {
1963                "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1964                "lines": {"count": 20, "covered": 18, "percent": 90.0},
1965                "functions": {"count": 3, "covered": 3, "percent": 100.0}
1966            }}],
1967            "type": "llvm.coverage.json.export",
1968            "version": "2.0.1"
1969        }"#;
1970        let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1971        assert_eq!(report.data[0].totals.regions.percent, 75.0);
1972        assert_eq!(report.data[0].totals.lines.count, 20);
1973    }
1974
1975    /// [`llvm_cov_patch_detail`] over an export with nothing gated.
1976    fn patch_detail(json: &str) -> BTreeMap<String, RustPatchCoverage> {
1977        llvm_cov_patch_detail(json, &BTreeMap::new()).expect("valid llvm-cov export")
1978    }
1979
1980    #[test]
1981    fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1982        let json = r#"{
1983            "data": [{
1984                "files": [{"filename": "/abs/grade.rs"}],
1985                "functions": [{
1986                    "filenames": ["/abs/grade.rs"],
1987                    "regions": [
1988                        [6, 5, 6, 26, 1, 0, 0, 0],
1989                        [10, 9, 10, 17, 0, 0, 0, 0]
1990                    ]
1991                }]
1992            }],
1993            "type": "llvm.coverage.json.export",
1994            "version": "3.0.1"
1995        }"#;
1996        let out = patch_detail(json);
1997        assert_eq!(
1998            out["/abs/grade.rs"].regions,
1999            vec![(6, 6, true), (10, 10, false)]
2000        );
2001    }
2002
2003    #[test]
2004    fn llvm_cov_patch_detail_skips_non_code_regions() {
2005        let json = r#"{
2006            "data": [{
2007                "files": [{"filename": "/abs/a.rs"}],
2008                "functions": [{
2009                    "filenames": ["/abs/a.rs"],
2010                    "regions": [
2011                        [1, 1, 1, 10, 2, 0, 0, 0],
2012                        [2, 1, 2, 10, 0, 0, 0, 1],
2013                        [3, 1, 3, 10, 0, 0, 0, 2]
2014                    ]
2015                }]
2016            }]
2017        }"#;
2018        let out = patch_detail(json);
2019        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
2020    }
2021
2022    #[test]
2023    fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
2024        let json = r#"{
2025            "data": [{
2026                "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
2027                "functions": [{
2028                    "filenames": ["/abs/a.rs", "/abs/b.rs"],
2029                    "regions": [
2030                        [1, 1, 1, 5, 1, 0, 0, 0],
2031                        [9, 1, 9, 5, 0, 1, 1, 0]
2032                    ]
2033                }]
2034            }]
2035        }"#;
2036        let out = patch_detail(json);
2037        assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
2038        assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
2039    }
2040
2041    #[test]
2042    fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
2043        let json = r#"{
2044            "data": [{
2045                "files": [{"filename": "/abs/a.rs"}],
2046                "functions": [{
2047                    "filenames": ["/abs/a.rs"],
2048                    "regions": [
2049                        [4, 1, 4],
2050                        [5, 1, 5, 9, 1, 0, 0, 0]
2051                    ]
2052                }]
2053            }]
2054        }"#;
2055        let out = patch_detail(json);
2056        assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
2057    }
2058
2059    #[test]
2060    fn llvm_cov_patch_detail_spans_a_multiline_region() {
2061        let json = r#"{
2062            "data": [{
2063                "files": [{"filename": "/abs/a.rs"}],
2064                "functions": [{
2065                    "filenames": ["/abs/a.rs"],
2066                    "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
2067                }]
2068            }]
2069        }"#;
2070        let out = patch_detail(json);
2071        assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
2072    }
2073
2074    #[test]
2075    fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
2076        let json = r#"{
2077            "data": [{
2078                "files": [{"filename": "/abs/kept.rs"}],
2079                "functions": [{
2080                    "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
2081                    "regions": [
2082                        [1, 1, 1, 9, 1, 0, 0, 0],
2083                        [2, 1, 2, 9, 0, 1, 0, 0]
2084                    ]
2085                }]
2086            }]
2087        }"#;
2088        let out = patch_detail(json);
2089        assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
2090        assert!(!out.contains_key("/abs/ignored.rs"));
2091    }
2092
2093    #[test]
2094    fn llvm_cov_patch_detail_malformed_json_is_an_error() {
2095        assert!(llvm_cov_patch_detail("{ not json", &BTreeMap::new()).is_err());
2096    }
2097
2098    #[test]
2099    fn llvm_cov_patch_detail_skips_a_negative_file_id() {
2100        let json = r#"{
2101            "data": [{
2102                "files": [{"filename": "/abs/a.rs"}],
2103                "functions": [{
2104                    "filenames": ["/abs/a.rs"],
2105                    "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
2106                }]
2107            }]
2108        }"#;
2109        let out = patch_detail(json);
2110        assert!(out.is_empty(), "got: {out:?}");
2111    }
2112
2113    #[test]
2114    fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
2115        let json = r#"{
2116            "data": [{
2117                "files": [{"filename": "/abs/a.rs"}],
2118                "functions": [{
2119                    "filenames": ["/abs/a.rs"],
2120                    "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
2121                }]
2122            }]
2123        }"#;
2124        let out = patch_detail(json);
2125        assert!(out.is_empty(), "got: {out:?}");
2126    }
2127
2128    #[test]
2129    fn istanbul_patch_detail_reads_statements_arms_and_functions() {
2130        let json = r#"{
2131            "/abs/a.ts": {
2132                "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
2133                "s": {"0": 1},
2134                "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
2135                "b": {"0": [1, 0]},
2136                "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
2137                "f": {"0": 0}
2138            }
2139        }"#;
2140        let out = istanbul_patch_detail(json).expect("valid Istanbul report");
2141        let detail = &out["/abs/a.ts"];
2142        assert_eq!(detail.statements, vec![(1, 2, true)]);
2143        assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
2144        assert_eq!(detail.functions, vec![(7, false)]);
2145    }
2146
2147    #[test]
2148    fn istanbul_patch_detail_keeps_a_branch_without_counts() {
2149        let json = r#"{
2150            "/abs/a.ts": {
2151                "statementMap": {},
2152                "s": {},
2153                "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
2154                "b": {},
2155                "fnMap": {},
2156                "f": {}
2157            }
2158        }"#;
2159        let out = istanbul_patch_detail(json).expect("valid Istanbul report");
2160        assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
2161    }
2162
2163    #[test]
2164    fn default_excludes_that_are_not_json_name_the_output() {
2165        let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
2166        let msg = format!("{err:#}");
2167        assert!(msg.contains("not a JSON string array"), "got: {msg}");
2168        assert!(msg.contains("vitest warmed up first"), "got: {msg}");
2169    }
2170
2171    #[test]
2172    fn default_excludes_drop_a_nul_bearing_pattern() {
2173        let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
2174        assert_eq!(parsed, vec!["**/dist/**".to_string()]);
2175    }
2176
2177    #[test]
2178    fn a_missing_vitest_report_names_the_reporter() {
2179        let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
2180        let err = read_vitest_report(&path, "json").unwrap_err();
2181        assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
2182    }
2183
2184    #[test]
2185    fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
2186        assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
2187    }
2188
2189    #[test]
2190    fn rust_run_argv_carries_format_features_branch_and_ignore_regex() {
2191        assert_eq!(
2192            run_argv(
2193                &["--json"],
2194                &["alpha".to_string(), "beta".to_string()],
2195                true,
2196                Some("src/ignored\\.rs$".to_string()),
2197            ),
2198            [
2199                "llvm-cov",
2200                "--lib",
2201                "--bins",
2202                "--json",
2203                "--features",
2204                "alpha,beta",
2205                "--branch",
2206                "--ignore-filename-regex",
2207                "src/ignored\\.rs$",
2208            ]
2209        );
2210        assert_eq!(
2211            run_argv(&["--lcov"], &[], false, None),
2212            ["llvm-cov", "--lib", "--bins", "--lcov"]
2213        );
2214    }
2215
2216    #[test]
2217    fn vitest_coverage_argv_names_the_reporter_the_report_dir_and_every_exclude() {
2218        let excludes = ["dist/**".to_string(), "src/generated/**".to_string()];
2219        let argv = vitest_coverage_argv("json-summary", Path::new("/tmp/reports"), excludes.iter());
2220
2221        assert_eq!(argv[0], "--no-install");
2222        assert_eq!(argv[1], "vitest");
2223        assert!(argv.contains(&"--coverage.all=true".to_string()));
2224        assert!(argv.contains(&"--coverage.thresholds.autoUpdate=false".to_string()));
2225        assert!(argv.contains(&"--coverage.reporter=json-summary".to_string()));
2226        assert!(argv.contains(&"--coverage.reportsDirectory=/tmp/reports".to_string()));
2227        assert!(argv.contains(&format!("--coverage.include={TS_INCLUDE}")));
2228        assert_eq!(
2229            argv.iter()
2230                .filter(|arg| arg.starts_with("--coverage.exclude="))
2231                .collect::<Vec<_>>(),
2232            [
2233                "--coverage.exclude=dist/**",
2234                "--coverage.exclude=src/generated/**"
2235            ]
2236        );
2237    }
2238
2239    #[test]
2240    fn vitest_coverage_argv_never_passes_yes_so_a_missing_vitest_fails_clean() {
2241        let argv = vitest_coverage_argv("json", Path::new("/tmp/r"), [].iter());
2242
2243        assert!(!argv.iter().any(|arg| arg == "--yes"));
2244    }
2245
2246    #[test]
2247    fn a_metric_at_or_above_its_floor_names_no_shortfall() {
2248        assert_eq!(shortfall("lines", 100.0, 100), None);
2249        assert_eq!(shortfall("lines", 90.0, 90), None);
2250        // A percent that rounds to the floor is not failed by float noise.
2251        assert_eq!(shortfall("lines", 89.999999999, 90), None);
2252    }
2253
2254    #[test]
2255    fn a_metric_below_its_floor_names_the_gap() {
2256        assert_eq!(
2257            shortfall("branches", 72.5, 80),
2258            Some("branches 72.50% < 80%".to_string())
2259        );
2260    }
2261
2262    #[test]
2263    fn a_verdict_passes_on_no_shortfalls_and_names_every_one_otherwise() {
2264        assert_eq!(verdict(Vec::new()), Outcome::Pass);
2265        assert_eq!(
2266            verdict(vec![
2267                "lines 1.00% < 2%".to_string(),
2268                "b 3.00% < 4%".to_string()
2269            ]),
2270            Outcome::Fail("coverage below thresholds: lines 1.00% < 2%, b 3.00% < 4%".to_string())
2271        );
2272    }
2273
2274    #[test]
2275    fn rust_checks_floor_lines_always_and_the_rest_only_when_configured() {
2276        let totals = rust_report_full(10.0, 20.0, 30.0, (4, 50.0)).data[0].totals;
2277
2278        assert_eq!(
2279            rust_checks(
2280                &totals,
2281                RustThresholds {
2282                    lines: 90,
2283                    regions: None,
2284                    functions: None,
2285                    branch: None,
2286                }
2287            ),
2288            [("lines", 20.0, 90)]
2289        );
2290        assert_eq!(
2291            rust_checks(
2292                &totals,
2293                RustThresholds {
2294                    lines: 90,
2295                    regions: Some(80),
2296                    functions: Some(70),
2297                    branch: Some(60),
2298                }
2299            ),
2300            [
2301                ("regions", 10.0, 80),
2302                ("lines", 20.0, 90),
2303                ("functions", 30.0, 70),
2304                ("branches", 50.0, 60),
2305            ]
2306        );
2307    }
2308
2309    #[test]
2310    fn a_branch_floor_over_a_branchless_crate_is_vacuously_satisfied() {
2311        let totals = rust_report_full(10.0, 20.0, 30.0, (0, 0.0)).data[0].totals;
2312
2313        assert_eq!(
2314            rust_checks(
2315                &totals,
2316                RustThresholds {
2317                    lines: 90,
2318                    regions: None,
2319                    functions: None,
2320                    branch: Some(60),
2321                }
2322            ),
2323            [("lines", 20.0, 90)]
2324        );
2325    }
2326
2327    #[test]
2328    fn a_code_region_in_a_measured_file_contributes_its_span_and_hit_state() {
2329        let names = ["src/a.rs".to_string()];
2330
2331        assert_eq!(
2332            patch_region(
2333                &[7, 1, 9, 20, 3, 0, 0, 0],
2334                &names,
2335                &BTreeSet::from(["src/a.rs"]),
2336                &BTreeMap::new()
2337            ),
2338            Some(("src/a.rs".to_string(), 7, 9, true))
2339        );
2340        assert_eq!(
2341            patch_region(
2342                &[7, 1, 9, 20, 0, 0, 0, 0],
2343                &names,
2344                &BTreeSet::from(["src/a.rs"]),
2345                &BTreeMap::new()
2346            ),
2347            Some(("src/a.rs".to_string(), 7, 9, false))
2348        );
2349    }
2350
2351    #[test]
2352    fn a_region_carrying_no_line_signal_contributes_nothing() {
2353        let names = ["src/a.rs".to_string()];
2354        let measured = BTreeSet::from(["src/a.rs"]);
2355        let nothing_hidden = BTreeMap::new();
2356        let reject = |region: &[i64]| patch_region(region, &names, &measured, &nothing_hidden);
2357
2358        // Too short to index.
2359        assert_eq!(reject(&[7, 1, 9, 20, 3, 0, 0]), None);
2360        // A gap region.
2361        assert_eq!(reject(&[7, 1, 9, 20, 3, 0, 0, 1]), None);
2362        // A file id outside the function's filename table.
2363        assert_eq!(reject(&[7, 1, 9, 20, 3, 9, 0, 0]), None);
2364        // A negative file id.
2365        assert_eq!(reject(&[7, 1, 9, 20, 3, -1, 0, 0]), None);
2366        // A file the `coverage` exemptions dropped from the measured set.
2367        assert_eq!(
2368            patch_region(
2369                &[7, 1, 9, 20, 3, 0, 0, 0],
2370                &names,
2371                &BTreeSet::new(),
2372                &nothing_hidden
2373            ),
2374            None
2375        );
2376        // A region starting on a `cfg(not(test))`-gated line.
2377        assert_eq!(
2378            patch_region(
2379                &[7, 1, 9, 20, 3, 0, 0, 0],
2380                &names,
2381                &measured,
2382                &BTreeMap::from([("src/a.rs".to_string(), BTreeSet::from([7u32]))])
2383            ),
2384            None
2385        );
2386    }
2387
2388    #[test]
2389    fn rust_report_argv_carries_format_and_optional_ignore_regex() {
2390        assert_eq!(
2391            report_argv(&["--lcov"], Some("src/ignored\\.rs$".to_string())),
2392            [
2393                "llvm-cov",
2394                "report",
2395                "--lcov",
2396                "--ignore-filename-regex",
2397                "src/ignored\\.rs$"
2398            ]
2399        );
2400        assert_eq!(
2401            report_argv(&["--json"], None),
2402            ["llvm-cov", "report", "--json"]
2403        );
2404    }
2405
2406    #[test]
2407    fn branch_hint_names_the_nightly_requirement_only_for_branch_coverage() {
2408        assert_eq!(branch_hint(false), "");
2409        assert!(branch_hint(true).contains("requires a nightly toolchain"));
2410    }
2411
2412    #[test]
2413    fn aiming_llvm_cov_points_cargo_at_the_crate_and_strips_the_outer_run() {
2414        let mut command = Command::new("cargo");
2415        command.env("RUSTC_WRAPPER", "outer");
2416        aim_llvm_cov(
2417            &mut command,
2418            &TargetDir(PathBuf::from("/tmp/tc-aim")),
2419            Path::new("/repo/crate"),
2420            vec!["llvm-cov".to_string(), "--json".to_string()],
2421        );
2422        assert_eq!(command.get_current_dir(), Some(Path::new("/repo/crate")));
2423        assert_eq!(
2424            command
2425                .get_args()
2426                .map(|arg| arg.to_string_lossy().into_owned())
2427                .collect::<Vec<_>>(),
2428            vec!["llvm-cov", "--json"]
2429        );
2430        let env = |name: &str| {
2431            command
2432                .get_envs()
2433                .find(|(key, _)| key.to_str() == Some(name))
2434                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
2435        };
2436        assert_eq!(
2437            env("CARGO_TARGET_DIR"),
2438            Some(Some("/tmp/tc-aim".to_string()))
2439        );
2440        // Set-and-cleared rather than simply absent: the child must not inherit ours either.
2441        assert_eq!(env("RUSTC_WRAPPER"), Some(None));
2442    }
2443
2444    #[test]
2445    fn a_failed_llvm_cov_run_names_the_crate_and_only_branch_runs_name_nightly() {
2446        let plain = llvm_cov_failure(Path::new("/repo/crate"), false);
2447        assert_eq!(
2448            plain,
2449            "the unit suite did not run cleanly under cargo llvm-cov in `/repo/crate`:"
2450        );
2451        let branched = llvm_cov_failure(Path::new("/repo/crate"), true);
2452        assert!(branched.starts_with(&plain), "got: {branched}");
2453        assert!(
2454            branched.contains("requires a nightly toolchain"),
2455            "got: {branched}"
2456        );
2457    }
2458
2459    #[test]
2460    fn scrub_removes_outer_llvm_cov_state_from_a_command() {
2461        let mut command = Command::new("cargo");
2462        for name in [
2463            "RUSTFLAGS",
2464            "CARGO_ENCODED_RUSTFLAGS",
2465            "RUSTDOCFLAGS",
2466            "CARGO_ENCODED_RUSTDOCFLAGS",
2467            "LLVM_PROFILE_FILE",
2468            "CARGO_LLVM_COV",
2469            "CARGO_LLVM_COV_SHOW_ENV",
2470            "CARGO_LLVM_COV_TARGET_DIR",
2471            "CARGO_LLVM_COV_BUILD_DIR",
2472            "RUSTC_WRAPPER",
2473            "RUSTC_WORKSPACE_WRAPPER",
2474            "__CARGO_LLVM_COV_RUSTC_WRAPPER",
2475            "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
2476            "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
2477            "RUSTUP_TOOLCHAIN",
2478            "CARGO",
2479            "RUSTC",
2480        ] {
2481            command.env(name, "outer");
2482        }
2483        command.env("KEEP", "value");
2484        scrub_outer_llvm_cov(&mut command);
2485        let envs: Vec<_> = command.get_envs().collect();
2486        assert_eq!(envs.len(), 18);
2487        assert!(envs
2488            .iter()
2489            .all(|(name, value)| { name == &"KEEP" || value.is_none() }));
2490        assert_eq!(
2491            envs.iter().find(|(name, _)| *name == "KEEP").unwrap().1,
2492            Some("value".as_ref())
2493        );
2494    }
2495
2496    #[test]
2497    fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
2498        // `/repo` doesn't exist, so `canonicalize` falls back to the plain join.
2499        let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
2500        assert_eq!(
2501            ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
2502            Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
2503        );
2504    }
2505
2506    /// Model llvm-cov's substring `--ignore-filename-regex` for the escaped, optionally
2507    /// `$`-anchored literals this tool emits. One matching alternative ignores the file.
2508    fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
2509        regex.split('|').any(|alt| {
2510            let (lit, anchored) = match alt.strip_suffix('$') {
2511                Some(head) => (head, true),
2512                None => (alt, false),
2513            };
2514            let lit = lit.replace('\\', "");
2515            if anchored {
2516                filename.ends_with(&lit)
2517            } else {
2518                filename.contains(&lit)
2519            }
2520        })
2521    }
2522
2523    #[test]
2524    fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
2525        assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
2526        assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
2527    }
2528
2529    #[test]
2530    fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
2531        let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
2532        assert!(
2533            llvm_would_ignore(&regex, "/repo/src/a.rs"),
2534            "the exempted file must still be ignored: {regex}"
2535        );
2536        assert!(
2537            !llvm_would_ignore(&regex, "/repo/member/src/a.rs"),
2538            "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
2539        );
2540        assert!(
2541            !llvm_would_ignore(&regex, "/repo/src/xsrc/a.rs"),
2542            "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
2543        );
2544    }
2545
2546    /// A `cargo llvm-cov --json` export of one file holding a gated `main` on lines 6-8 and a
2547    /// tested `report` on lines 11-13, with the `--bins` half's second, 0-hit copy of both.
2548    const GATED_EXPORT: &str = r#"{
2549        "data": [{
2550            "files": [{"filename": "/abs/entrypoint.rs"}],
2551            "functions": [
2552                {"name": "report", "count": 1, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2553                 "regions": [[11, 1, 11, 37, 1, 0, 0, 0], [12, 5, 12, 19, 1, 0, 0, 0],
2554                             [12, 20, 12, 30, 1, 0, 0, 0], [13, 1, 13, 2, 1, 0, 0, 0]]},
2555                {"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2556                 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2557                             [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2558                {"name": "report", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2559                 "regions": [[11, 1, 11, 37, 0, 0, 0, 0], [12, 5, 12, 19, 0, 0, 0, 0],
2560                             [12, 20, 12, 30, 0, 0, 0, 0], [13, 1, 13, 2, 0, 0, 0, 0]]}
2561            ],
2562            "totals": {
2563                "regions": {"count": 13, "covered": 9, "percent": 69.23},
2564                "lines": {"count": 9, "covered": 6, "percent": 66.67},
2565                "functions": {"count": 3, "covered": 2, "percent": 66.67}
2566            }
2567        }]
2568    }"#;
2569
2570    /// The lines `#[cfg(not(test))] fn main` spans in [`GATED_EXPORT`]'s source.
2571    fn gated_main() -> BTreeMap<String, BTreeSet<u32>> {
2572        BTreeMap::from([(
2573            "/abs/entrypoint.rs".to_string(),
2574            BTreeSet::from([5, 6, 7, 8]),
2575        )])
2576    }
2577
2578    /// [`llvm_cov_totals_less_hidden`] over an export string.
2579    fn totals_less(json: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovTotals {
2580        let export = parse_llvm_cov_export(json).expect("valid llvm-cov export");
2581        llvm_cov_totals_less_hidden(&export, hidden).data[0].totals
2582    }
2583
2584    #[test]
2585    fn a_gated_entry_point_leaves_every_ratio_full() {
2586        let totals = totals_less(GATED_EXPORT, &gated_main());
2587        assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2588        assert_eq!((totals.lines.count, totals.lines.covered), (6, 6));
2589        assert_eq!((totals.functions.count, totals.functions.covered), (2, 2));
2590        assert_eq!(totals.regions.percent, 100.0);
2591    }
2592
2593    #[test]
2594    fn an_export_with_nothing_gated_keeps_its_totals() {
2595        let totals = totals_less(GATED_EXPORT, &BTreeMap::new());
2596        assert_eq!((totals.regions.count, totals.regions.covered), (13, 9));
2597        assert_eq!((totals.lines.count, totals.lines.covered), (9, 6));
2598        assert_eq!((totals.functions.count, totals.functions.covered), (3, 2));
2599    }
2600
2601    #[test]
2602    fn two_copies_of_one_gated_item_subtract_once() {
2603        let json = GATED_EXPORT.replace(
2604            r#"{"name": "report", "count": 1"#,
2605            r#"{"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2606                 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2607                             [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2608                {"name": "report", "count": 1"#,
2609        );
2610        let totals = totals_less(&json, &gated_main());
2611        assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2612        assert_eq!(totals.functions.count, 2);
2613    }
2614
2615    #[test]
2616    fn a_record_with_no_region_to_place_it_is_not_subtracted() {
2617        let json = r#"{
2618            "data": [{
2619                "files": [{"filename": "/abs/a.rs"}],
2620                "functions": [{"name": "empty", "count": 0, "filenames": ["/abs/a.rs"],
2621                    "regions": [], "branches": []}],
2622                "totals": {
2623                    "regions": {"count": 2, "covered": 2, "percent": 100.0},
2624                    "lines": {"count": 2, "covered": 2, "percent": 100.0},
2625                    "functions": {"count": 1, "covered": 1, "percent": 100.0}
2626                }
2627            }]
2628        }"#;
2629        let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1]))]);
2630        assert_eq!(totals_less(json, &hidden).regions.count, 2);
2631    }
2632
2633    #[test]
2634    fn a_record_outside_the_files_allowlist_is_not_subtracted() {
2635        let hidden = BTreeMap::from([("/abs/other.rs".to_string(), BTreeSet::from([6]))]);
2636        let json = GATED_EXPORT.replace("\"/abs/entrypoint.rs\"],", "\"/abs/other.rs\"],");
2637        assert_eq!(totals_less(&json, &hidden).regions.count, 13);
2638    }
2639
2640    #[test]
2641    fn a_gated_branch_drops_both_of_its_arms() {
2642        let json = r#"{
2643            "data": [{
2644                "files": [{"filename": "/abs/a.rs"}],
2645                "functions": [{"name": "main", "count": 0, "filenames": ["/abs/a.rs"],
2646                    "regions": [[1, 1, 3, 2, 0, 0, 0, 0]],
2647                    "branches": [[2, 9, 2, 14, 0, 0, 0, 0, 4], [2, 9, 2, 14, 0, 0, 0, 0]]}],
2648                "totals": {
2649                    "regions": {"count": 5, "covered": 4, "percent": 80.0},
2650                    "lines": {"count": 9, "covered": 6, "percent": 66.67},
2651                    "functions": {"count": 3, "covered": 2, "percent": 66.67},
2652                    "branches": {"count": 6, "covered": 4, "percent": 66.67}
2653                }
2654            }]
2655        }"#;
2656        let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1, 2, 3]))]);
2657        let branches = totals_less(json, &hidden).branches.expect("branch totals");
2658        // The second entry is a region array, not a branch one; a short array carries no arms.
2659        assert_eq!((branches.count, branches.covered), (4, 4));
2660    }
2661
2662    #[test]
2663    fn a_metric_the_subtraction_empties_reads_full() {
2664        let metric = LlvmCovMetric {
2665            count: 4,
2666            covered: 0,
2667            percent: 0.0,
2668        };
2669        let emptied = metric_less(
2670            metric,
2671            Tally {
2672                count: 9,
2673                covered: 9,
2674            },
2675        );
2676        assert_eq!((emptied.count, emptied.covered), (0, 0));
2677        assert_eq!(emptied.percent, 100.0);
2678    }
2679
2680    /// Two instantiation groups of one file, the second never run. The `--json` totals sum the
2681    /// two — nine lines, one short — while lcov merges them into three, all covered.
2682    const TWO_GROUPS_LCOV: &str = "SF:/abs/a.rs\nDA:1,4\nDA:2,4\nDA:3,0\n\
2683                                   SF:/abs/a.rs\nDA:1,0\nDA:2,0\nDA:3,7\nend_of_record\n";
2684
2685    #[test]
2686    fn a_line_one_instantiation_ran_counts_once_covered() {
2687        let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &BTreeMap::new());
2688        assert_eq!((lines.count, lines.covered), (3, 3));
2689        assert_eq!(lines.percent, 100.0);
2690    }
2691
2692    #[test]
2693    fn a_gated_line_leaves_the_lcov_denominator() {
2694        let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([3]))]);
2695        let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &hidden);
2696        assert_eq!((lines.count, lines.covered), (2, 2));
2697    }
2698
2699    #[test]
2700    fn a_line_no_instantiation_ran_is_still_missing() {
2701        let lcov = "SF:/abs/a.rs\nDA:1,4\nDA:2,0\nend_of_record\n";
2702        let lines = lcov_lines_metric(lcov, &BTreeMap::new());
2703        assert_eq!((lines.count, lines.covered), (2, 1));
2704    }
2705
2706    #[test]
2707    fn records_outside_a_source_file_are_not_lines() {
2708        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";
2709        assert_eq!(
2710            lcov_lines(lcov),
2711            BTreeMap::from([("/abs/a.rs".to_string(), BTreeMap::from([(4, true)]))]),
2712            "a `DA:` before any `SF:`, and a malformed one after, name no line"
2713        );
2714    }
2715
2716    #[test]
2717    fn a_failed_export_carries_both_streams() {
2718        let err = llvm_cov_stdout(false, b"out\n", b"err\n", "could not re-export:")
2719            .expect_err("a non-zero exit is an error");
2720        assert_eq!(format!("{err}"), "could not re-export:\nout\nerr\n");
2721    }
2722
2723    #[test]
2724    fn an_export_measuring_nothing_reads_full() {
2725        let lines = lcov_lines_metric("", &BTreeMap::new());
2726        assert_eq!((lines.count, lines.covered), (0, 0));
2727        assert_eq!(lines.percent, 100.0);
2728    }
2729
2730    #[test]
2731    fn a_function_record_without_a_usable_first_region_has_no_group() {
2732        let short = LlvmCovFunction {
2733            filenames: vec!["/abs/a.rs".to_string()],
2734            regions: vec![vec![1, 1, 1, 2]],
2735            count: 0,
2736            branches: Vec::new(),
2737        };
2738        let unnamed = LlvmCovFunction {
2739            filenames: Vec::new(),
2740            regions: vec![vec![1, 1, 1, 2, 0, 7, 0, 0]],
2741            count: 0,
2742            branches: Vec::new(),
2743        };
2744        let empty = LlvmCovFunction {
2745            filenames: vec!["/abs/a.rs".to_string()],
2746            regions: Vec::new(),
2747            count: 0,
2748            branches: Vec::new(),
2749        };
2750        assert_eq!(function_start(&short), None);
2751        assert_eq!(function_start(&unnamed), None);
2752        assert_eq!(function_start(&empty), None);
2753    }
2754
2755    #[test]
2756    fn a_gated_region_drops_out_of_the_changed_line_detail() {
2757        let hidden =
2758            BTreeMap::from([("/abs/entrypoint.rs".to_string(), BTreeSet::from([6, 7, 8]))]);
2759        let detail = llvm_cov_patch_detail(GATED_EXPORT, &hidden).expect("valid export");
2760        assert_eq!(
2761            detail["/abs/entrypoint.rs"]
2762                .regions
2763                .iter()
2764                .map(|(start, _, _)| *start)
2765                .collect::<BTreeSet<_>>(),
2766            BTreeSet::from([11, 12, 13])
2767        );
2768    }
2769
2770    #[test]
2771    fn hidden_lines_come_from_the_sources_the_export_measured() {
2772        let dir = std::env::temp_dir().join(format!("tc-cov-hidden-{}", std::process::id()));
2773        std::fs::create_dir_all(&dir).unwrap();
2774        let gated = dir.join("gated.rs");
2775        std::fs::write(&gated, "#[cfg(not(test))]\nfn main() {}\n").unwrap();
2776        let json = format!(
2777            r#"{{"data": [{{"files": [{{"filename": "{}"}}, {{"filename": "{}"}}],
2778                 "functions": []}}]}}"#,
2779            gated.display(),
2780            dir.join("absent.rs").display(),
2781        );
2782        let hidden = hidden_lines_by_file(&json).expect("valid export");
2783        std::fs::remove_dir_all(&dir).ok();
2784        assert_eq!(
2785            hidden,
2786            BTreeMap::from([(gated.display().to_string(), BTreeSet::from([1, 2]))]),
2787            "an unreadable source hides nothing"
2788        );
2789    }
2790
2791    #[test]
2792    fn a_clean_vitest_exit_raises_nothing() {
2793        assert!(vitest_exit(true, Path::new("/tmp/project"), b"ignored", b"ignored").is_ok());
2794    }
2795
2796    #[test]
2797    fn a_failed_vitest_exit_names_the_project_both_streams_and_the_no_install_rule() {
2798        let err = vitest_exit(
2799            false,
2800            Path::new("/tmp/project"),
2801            b"out detail",
2802            b"err detail",
2803        )
2804        .unwrap_err()
2805        .to_string();
2806
2807        assert!(err.contains("/tmp/project"), "got: {err}");
2808        assert!(err.contains("out detail"), "got: {err}");
2809        assert!(err.contains("err detail"), "got: {err}");
2810        assert!(err.contains("npx --no-install"), "got: {err}");
2811        assert!(err.contains("@vitest/coverage-v8"), "got: {err}");
2812    }
2813}