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