testing_conventions/coverage.rs
1//! Coverage rule (Python — issue #26; TypeScript — issue #31; Rust — issue #37; exemptions — issue #32).
2//!
3//! Enforces the README's Coverage rule: a library's unit suite must meet the
4//! configured floor, with test files excluded from the denominator. This module
5//! is the deterministic core — given a parsed coverage report and the thresholds
6//! from config, an `evaluate` function decides pass/fail. Producing the report
7//! (shelling out to the language's coverage tool) is a thin layer on top, kept
8//! separate so the guarantee is testable without that toolchain installed.
9//!
10//! Python (#26) uses coverage.py: a single total, branch coverage on. Given a
11//! [`CoverageReport`] and [`Thresholds`], [`evaluate`] decides pass/fail, and
12//! [`measure`] shells out to `coverage`. TypeScript (#31) is the twin: vitest
13//! reports four independent metrics (lines / branches / functions / statements),
14//! so it carries its own [`TypeScriptThresholds`], [`VitestReport`], and
15//! [`evaluate_typescript`] / [`measure_typescript`] pair — sharing only the
16//! [`Outcome`] type. Its subprocess layer shells out to `vitest`. Rust (#37) is
17//! the third twin: `cargo llvm-cov` reports regions/lines (branch coverage is
18//! experimental), so it carries [`RustThresholds`], [`LlvmCovReport`], and
19//! [`evaluate_rust`] / [`measure_rust`]; its subprocess layer shells out to
20//! `cargo llvm-cov`.
21//!
22//! Files exempted from coverage in config (issue #32) are omitted from the
23//! denominator alongside the test files; the caller resolves them
24//! ([`crate::config::resolve_exempt`]) and passes their paths to [`measure`] /
25//! [`measure_typescript`] / [`measure_rust`].
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29use std::process::Command;
30use std::sync::atomic::{AtomicU64, Ordering};
31
32use anyhow::{bail, Context, Result};
33use serde::Deserialize;
34
35/// Always omitted from the coverage denominator: colocated unit tests are the
36/// suite, never a subject of it.
37const TEST_OMIT: &str = "*_test.py";
38
39/// Also always omitted: `conftest.py` holds pytest fixtures (test support), never
40/// a coverage subject. `*conftest.py` matches it at any depth, mirroring the
41/// `*_test.py` glob. (#112)
42const SUPPORT_OMIT: &str = "*conftest.py";
43
44/// The coverage floor to enforce, from a `[<language>].coverage` table.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Thresholds {
47 /// Minimum total coverage percent the unit suite must meet.
48 pub fail_under: u8,
49 /// Whether branch coverage must be measured (and folded into the total).
50 pub branch: bool,
51}
52
53/// A coverage.py JSON report (`coverage json`), pared to what the checks need:
54/// the `totals` (the floor) and the per-file `files` block (patch
55/// coverage, #132). Unmodeled fields (metadata, per-function/class data) are
56/// ignored.
57#[derive(Debug, Clone, Deserialize)]
58pub struct CoverageReport {
59 pub totals: Totals,
60 /// Per-file line/branch detail, keyed by the path coverage.py reports
61 /// (relative to the measured root). Additive: `#[serde(default)]`, so a report
62 /// parsed for the floor alone (the inline tests) needs no `files`.
63 #[serde(default)]
64 pub files: BTreeMap<String, FileCoverage>,
65}
66
67/// Per-file coverage detail from a coverage.py report (one `files` entry) — what
68/// patch coverage (#132) reads to decide whether a changed line is covered.
69/// Unmodeled fields (the summary, per-function/class data) are ignored.
70#[derive(Debug, Clone, Default, Deserialize)]
71pub struct FileCoverage {
72 /// Executable lines the suite ran.
73 #[serde(default)]
74 pub executed_lines: Vec<u64>,
75 /// Executable lines the suite never ran — an uncovered changed line is one of
76 /// these.
77 #[serde(default)]
78 pub missing_lines: Vec<u64>,
79 /// Lines excluded from coverage (e.g. `# pragma: no cover`); never a miss.
80 #[serde(default)]
81 pub excluded_lines: Vec<u64>,
82 /// `[source_line, dest_line]` pairs for branches the suite never took; `dest`
83 /// may be negative (a function / loop exit). Only the source line matters to
84 /// patch coverage. Empty when branch coverage was off.
85 #[serde(default)]
86 pub missing_branches: Vec<Vec<i64>>,
87 /// `[source_line, dest_line]` pairs for branches the suite DID take (coverage.py
88 /// emits these alongside `missing_branches` under `--branch`). The diff-scoped
89 /// floor (#162) counts an arc toward changed-line branch coverage when its source
90 /// line is in the diff; with `missing_branches` it gives branch coverage over the
91 /// changed lines. Empty when branch coverage was off.
92 #[serde(default)]
93 pub executed_branches: Vec<Vec<i64>>,
94}
95
96/// The `totals` block of a coverage.py report.
97#[derive(Debug, Clone, Deserialize)]
98pub struct Totals {
99 /// Total covered percent — line coverage, plus branch when measured.
100 pub percent_covered: f64,
101 /// Branches measured; `0` when branch coverage was not enabled.
102 #[serde(default)]
103 pub num_branches: u64,
104}
105
106/// The result of checking a report against the thresholds.
107#[derive(Debug, Clone, PartialEq)]
108pub enum Outcome {
109 /// The floor is met.
110 Pass,
111 /// The floor is not met; the message explains why (actual vs. required).
112 Fail(String),
113}
114
115/// Parse a coverage.py JSON report (the output of `coverage json`).
116pub fn parse_report(json: &str) -> Result<CoverageReport> {
117 serde_json::from_str(json).context("parsing coverage.py JSON report")
118}
119
120/// Decide whether `report` meets `thresholds`.
121///
122/// Fails when total coverage is below `fail_under`, or when branch coverage was
123/// required but the report measured no branches (a misconfigured run).
124pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
125 if thresholds.branch && report.totals.num_branches == 0 {
126 return Outcome::Fail(
127 "branch coverage is required but the report measured no branches".to_string(),
128 );
129 }
130 let actual = report.totals.percent_covered;
131 let required = f64::from(thresholds.fail_under);
132 // A hair of tolerance so a report that rounds to the floor (e.g. 99.999…%
133 // for a 100% target) isn't failed by float noise.
134 if actual + 1e-9 >= required {
135 Outcome::Pass
136 } else {
137 Outcome::Fail(format!(
138 "coverage {actual:.2}% is below the required {}%",
139 thresholds.fail_under
140 ))
141 }
142}
143
144/// Run the unit suite under coverage.py in `root` and check it against
145/// `thresholds`.
146///
147/// Shells out to `coverage run --branch` (omitting `*_test.py` and every path in
148/// `omit` from the denominator) then `coverage json`, and evaluates the report.
149/// `omit` holds the `coverage`-rule exemptions resolved from config, as
150/// `root`-relative paths. The `coverage` CLI — with `pytest` importable — must be
151/// on `PATH`.
152pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
153 let report = run_coverage(root, omit, false)?;
154 Ok(evaluate(&report, thresholds))
155}
156
157/// Run the Python unit suite under coverage.py in `root` with **every** source
158/// under `root` measured (`coverage run --source=.`) and return the parsed report
159/// — so an untested source shows in the `files` block as wholly uncovered rather
160/// than vanishing. The per-file detail is what patch coverage (#132) reads; `omit`
161/// is as in [`measure`] (an exempt file stays out of the run, so its changed
162/// lines are lifted).
163pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
164 run_coverage(root, omit, true)
165}
166
167/// Run the Python unit suite under coverage.py in `root` and return the parsed report
168/// with its per-file `files` detail — measuring only the files the suite imports (no
169/// `--source=.`), exactly as the whole-tree floor [`measure`] does. The line-scoped
170/// exemption path (#226) reads this: it recomputes the floor over the measured lines
171/// minus the exempt ones, so it must see the same file set [`measure`] does (an
172/// untested-but-unimported file is out of scope for both), not the wider `--source=.`
173/// set [`measure_patch_report`] uses for the diff. `omit` is as in [`measure`].
174pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
175 run_coverage(root, omit, false)
176}
177
178/// A coverage.py data file under the temp dir — unique per call (so checks
179/// running in parallel don't collide) and removed on drop (so nothing leaks
180/// into the scanned tree).
181struct DataFile(PathBuf);
182
183impl DataFile {
184 fn new() -> Self {
185 static COUNTER: AtomicU64 = AtomicU64::new(0);
186 let name = format!(
187 "testing-conventions-{}-{}.coverage",
188 std::process::id(),
189 COUNTER.fetch_add(1, Ordering::Relaxed),
190 );
191 DataFile(std::env::temp_dir().join(name))
192 }
193}
194
195impl Drop for DataFile {
196 fn drop(&mut self) {
197 let _ = std::fs::remove_file(&self.0);
198 }
199}
200
201/// Run coverage.py over the unit suite in `root` and return the parsed report.
202///
203/// `include_all_sources` adds `--source=.` so coverage measures every source
204/// under `root` — even one no test imports, which then appears in the `files`
205/// block as wholly uncovered. The floor passes `false` (measuring only imported
206/// files, so its total is unchanged); patch coverage passes `true`.
207fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
208 let data = DataFile::new();
209 let omit = build_omit(omit);
210
211 // Branch coverage on; measure the sources in `root` with the test files —
212 // and any `coverage`-waived files — omitted from the denominator. Byte-code
213 // and the pytest cache are suppressed so the scanned tree stays pristine.
214 let mut command = Command::new("coverage");
215 command
216 .current_dir(root)
217 .args(["run", "--branch"])
218 .arg(format!("--omit={omit}"));
219 if include_all_sources {
220 command.arg("--source=.");
221 }
222 let run = command
223 .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
224 .env("COVERAGE_FILE", &data.0)
225 .env("PYTHONDONTWRITEBYTECODE", "1")
226 .output()
227 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
228 if !run.status.success() {
229 bail!(
230 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
231 root.display(),
232 String::from_utf8_lossy(&run.stdout),
233 String::from_utf8_lossy(&run.stderr),
234 );
235 }
236
237 // Emit the report to stdout and parse it.
238 let json = Command::new("coverage")
239 .current_dir(root)
240 .args(["json", "-o", "-"])
241 .env("COVERAGE_FILE", &data.0)
242 .output()
243 .context("running `coverage json`")?;
244 if !json.status.success() {
245 bail!(
246 "`coverage json` failed:\n{}",
247 String::from_utf8_lossy(&json.stderr),
248 );
249 }
250
251 parse_report(&String::from_utf8_lossy(&json.stdout))
252}
253
254/// The single comma-joined `--omit` value for the coverage run: always the test
255/// glob `*_test.py` and the support glob `*conftest.py`, plus every
256/// `coverage`-exempt path from config. (coverage.py takes one `--omit` — repeated
257/// flags don't accumulate, so the patterns must be joined.) An exempt file leaves
258/// the denominator with its reason recorded in config — an auditable omission, not
259/// a silent ignore-glob.
260fn build_omit(omit: &[String]) -> String {
261 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
262 .into_iter()
263 .chain(omit.iter().cloned())
264 .collect::<Vec<_>>()
265 .join(",")
266}
267
268// ---------------------------------------------------------------------------
269// TypeScript (vitest) — issue #31.
270//
271// The TypeScript twin of the Python rule above. vitest reports four independent
272// metrics rather than Python's single total-plus-branch, so it carries its own
273// thresholds, report shape, and evaluate/measure pair; only `Outcome` is shared.
274// The split is the same: a pure `evaluate_typescript` over a parsed json-summary
275// report, and a thin `measure_typescript` that shells out to vitest to produce
276// one — so the enforcement core is testable without a Node toolchain.
277// ---------------------------------------------------------------------------
278
279/// What vitest measures: every TypeScript source under the scanned root. The
280/// braces are a vitest (picomatch) glob, expanded by vitest, not the shell.
281const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
282
283/// The project's own installed vitest's default coverage excludes (test files,
284/// declaration files, build-tool config files, `dist/`, `node_modules/`, …),
285/// resolved live via Node rather than hand-maintained here.
286///
287/// Passing *any* `--coverage.exclude` value to vitest replaces its built-in
288/// default list rather than extending it — so a rule-owned exclude flag (the
289/// colocated test glob; a config-driven `coverage` exemption) would otherwise
290/// silently un-exclude every default the provider ships, including its own
291/// `**/{vite,vitest,eslint,...}.config.*` pattern. That default list is
292/// exactly the ecosystem knowledge this tool has no business re-enumerating —
293/// it's resolved from whatever vitest version `root` actually has installed,
294/// so it can never go stale relative to it.
295fn vitest_default_excludes(root: &Path) -> Result<Vec<String>> {
296 let run = Command::new("node")
297 .current_dir(root)
298 .args([
299 "-e",
300 "process.stdout.write(JSON.stringify(require('vitest/config').coverageConfigDefaults.exclude))",
301 ])
302 .output()
303 .context("resolving vitest's default coverage excludes via node")?;
304 if !run.status.success() {
305 bail!(
306 "could not resolve vitest's default coverage excludes in `{}`. The rule runs the \
307 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
308 must be installed in the project. node output:\n{}{}",
309 root.display(),
310 String::from_utf8_lossy(&run.stdout),
311 String::from_utf8_lossy(&run.stderr),
312 );
313 }
314 let excludes: Vec<String> = serde_json::from_slice(&run.stdout).with_context(|| {
315 format!(
316 "vitest's default coverage excludes were not a JSON string array — got: {}",
317 String::from_utf8_lossy(&run.stdout)
318 )
319 })?;
320 // A couple of vitest's own default patterns embed a literal NUL byte (its
321 // virtual-module boundary markers, e.g. `**/\0*`) — meaningless as a glob
322 // against real files, and a NUL byte can't be passed as a process argument
323 // at all, so those entries are dropped rather than sent to `Command::arg`.
324 Ok(excludes.into_iter().filter(|p| !p.contains('\0')).collect())
325}
326
327/// The four vitest coverage floors, from a `[typescript].coverage` table. Each
328/// is an independent percent the unit suite must meet — vitest measures all four.
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub struct TypeScriptThresholds {
331 pub lines: u8,
332 pub branches: u8,
333 pub functions: u8,
334 pub statements: u8,
335}
336
337/// A vitest `coverage-summary.json` report, pared to the `total` block the check
338/// needs. Per-file entries and unmodeled fields are ignored.
339#[derive(Debug, Clone, Copy, Deserialize)]
340pub struct VitestReport {
341 pub total: VitestTotals,
342}
343
344/// The `total` block of a vitest json-summary report — the four metrics this
345/// rule enforces. vitest also emits `branchesTrue`, which the check ignores.
346#[derive(Debug, Clone, Copy, Deserialize)]
347pub struct VitestTotals {
348 pub lines: VitestMetric,
349 pub branches: VitestMetric,
350 pub functions: VitestMetric,
351 pub statements: VitestMetric,
352}
353
354/// One metric's totals from a vitest json-summary block, pared to what the check
355/// needs: the covered percent and the denominator size.
356#[derive(Debug, Clone, Copy, Deserialize)]
357pub struct VitestMetric {
358 /// Percent covered — `None` when nothing was measured, which vitest writes as
359 /// the string `"Unknown"` (and `total` is then `0`).
360 #[serde(deserialize_with = "deserialize_pct")]
361 pub pct: Option<f64>,
362 /// Size of the denominator (statements/branches/functions/lines counted).
363 pub total: u64,
364}
365
366/// Deserialize a json-summary `pct`: a number for a measured metric (vitest
367/// emits whole percents as JSON integers and fractional ones as floats), or the
368/// string `"Unknown"` (→ `None`) when the denominator is empty.
369fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
370where
371 D: serde::Deserializer<'de>,
372{
373 struct PctVisitor;
374 impl serde::de::Visitor<'_> for PctVisitor {
375 type Value = Option<f64>;
376
377 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
378 f.write_str("a coverage percent number or the string \"Unknown\"")
379 }
380
381 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
382 Ok(Some(value))
383 }
384
385 // serde_json hands a whole-number percent (e.g. `100`) to `visit_u64`;
386 // percents are never negative, so `visit_i64` is not needed.
387 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
388 Ok(Some(value as f64))
389 }
390
391 // Any non-numeric percent (vitest writes the literal "Unknown") means the
392 // metric had nothing to measure.
393 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
394 Ok(None)
395 }
396 }
397 deserializer.deserialize_any(PctVisitor)
398}
399
400/// Parse a vitest json-summary report (`coverage-summary.json`).
401pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
402 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
403}
404
405/// Decide whether `report` meets every threshold in `thresholds`.
406///
407/// Fails when the run measured no code at all (an empty line denominator — a
408/// wrong path, or a suite that touched nothing — is never a silent pass),
409/// otherwise checks each of the four metrics and fails listing every one below
410/// its floor. A metric whose denominator is empty *amid* a non-empty run (e.g.
411/// branch-free code measured alongside real code) has nothing to miss and is
412/// vacuously satisfied.
413pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
414 let total = &report.total;
415 // Vacuous-run guard: every source file has lines, so a zero line-denominator
416 // means nothing was measured — a misconfigured run (wrong path, or every file
417 // excluded), failed rather than passed on an empty measurement.
418 if total.lines.total == 0 {
419 return Outcome::Fail(
420 "the unit suite measured no code — check the path and that the suite runs".to_string(),
421 );
422 }
423 let checks = [
424 ("lines", total.lines, thresholds.lines),
425 ("branches", total.branches, thresholds.branches),
426 ("functions", total.functions, thresholds.functions),
427 ("statements", total.statements, thresholds.statements),
428 ];
429 let mut shortfalls = Vec::new();
430 for (name, metric, required) in checks {
431 // A metric with an empty denominator (e.g. branch-free code) has nothing
432 // to cover and is vacuously full; a measured one compares its percent.
433 let actual = metric.pct.unwrap_or(100.0);
434 // A hair of tolerance so a percent that rounds to the floor isn't failed
435 // by float noise (matches the Python path).
436 if actual + 1e-9 < f64::from(required) {
437 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
438 }
439 }
440 if shortfalls.is_empty() {
441 Outcome::Pass
442 } else {
443 Outcome::Fail(format!(
444 "coverage below thresholds: {}",
445 shortfalls.join(", ")
446 ))
447 }
448}
449
450/// Run the unit suite under vitest coverage in `root` and check it against
451/// `thresholds`.
452///
453/// Shells out to `npx vitest run` with v8 coverage and the json-summary reporter,
454/// excluding `*.test.*`, declaration files, and every path in `exclude` from the
455/// denominator, then evaluates the report. `exclude` holds the `coverage`-rule
456/// exemptions resolved from config, as `root`-relative paths. `npx` resolves the
457/// project-local `vitest`, so it and `@vitest/coverage-v8` must be installed
458/// under `root`.
459pub fn measure_typescript(
460 root: &Path,
461 thresholds: TypeScriptThresholds,
462 exclude: &[String],
463) -> Result<Outcome> {
464 let report = run_vitest(root, exclude)?;
465 Ok(evaluate_typescript(&report, thresholds))
466}
467
468/// A vitest reports directory under the temp dir — unique per call (so checks
469/// running in parallel don't collide) and removed on drop (so the report never
470/// leaks into the scanned tree). vitest writes `coverage-summary.json` here.
471struct ReportDir(PathBuf);
472
473impl ReportDir {
474 fn new() -> Self {
475 static COUNTER: AtomicU64 = AtomicU64::new(0);
476 let name = format!(
477 "testing-conventions-vitest-{}-{}",
478 std::process::id(),
479 COUNTER.fetch_add(1, Ordering::Relaxed),
480 );
481 ReportDir(std::env::temp_dir().join(name))
482 }
483}
484
485impl Drop for ReportDir {
486 fn drop(&mut self) {
487 let _ = std::fs::remove_dir_all(&self.0);
488 }
489}
490
491/// Run vitest over the unit suite in `root` and return the parsed floor report.
492fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
493 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
494 parse_vitest_report(&json)
495}
496
497/// Run vitest coverage over the unit suite in `root` and return the raw contents
498/// of the `report_file` the `reporter` wrote. Shared by the floor (#31, the
499/// `json-summary` → `coverage-summary.json` pair) and patch coverage (#135, the
500/// detailed `json` → `coverage-final.json` Istanbul pair) — the two differ only in
501/// the reporter and how they parse it.
502///
503/// v8 coverage is written to an out-of-tree temp dir so the scanned tree stays
504/// pristine. `include` scopes measurement to the sources under `root`; vitest's
505/// own default excludes (test files, declaration files, build-tool config
506/// files, …, resolved live — see [`vitest_default_excludes`]) and the config
507/// `exclude` paths are excluded from the denominator. `all=true` counts source
508/// files the suite never imported, so an untested file is measured (lowering
509/// the floor / showing as uncovered) rather than vanishing. `--no-cache` keeps
510/// vitest from writing a cache into the tree.
511fn run_vitest_coverage(
512 root: &Path,
513 exclude: &[String],
514 reporter: &str,
515 report_file: &str,
516) -> Result<String> {
517 let reports = ReportDir::new();
518
519 let mut command = Command::new("npx");
520 command
521 .current_dir(root)
522 // `--no-install`, never `--yes`: run the project's own vitest (resolved via
523 // Node's parent-dir lookup) and refuse to download anything. With `--yes` a
524 // missing vitest would be silently fetched; the TS arm must fail clean like the
525 // coverage.py / cargo-llvm-cov arms, which invoke their binary directly.
526 .args(["--no-install", "vitest", "run", "--no-cache"])
527 .args(["--coverage.enabled", "--coverage.provider=v8"])
528 .arg(format!("--coverage.reporter={reporter}"))
529 .arg("--coverage.all=true")
530 .arg(format!(
531 "--coverage.reportsDirectory={}",
532 reports.0.display()
533 ))
534 .arg(format!("--coverage.include={TS_INCLUDE}"));
535 // Passing any `--coverage.exclude` replaces vitest's own default exclude
536 // list rather than extending it, so its defaults are resolved and passed
537 // back explicitly, alongside the config-driven exemption paths.
538 for path in vitest_default_excludes(root)?.iter().chain(exclude) {
539 command.arg(format!("--coverage.exclude={path}"));
540 }
541 // CI=1 keeps vitest non-interactive (no watch prompt, plain output).
542 let run = command
543 .env("CI", "1")
544 .output()
545 .context("running `npx --no-install vitest run --coverage`")?;
546 if !run.status.success() {
547 bail!(
548 "the unit suite did not run cleanly under vitest in `{}`. The rule runs the \
549 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
550 and `@vitest/coverage-v8` must be installed in the project. vitest output:\n{}{}",
551 root.display(),
552 String::from_utf8_lossy(&run.stdout),
553 String::from_utf8_lossy(&run.stderr),
554 );
555 }
556
557 let path = reports.0.join(report_file);
558 std::fs::read_to_string(&path).with_context(|| {
559 format!(
560 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
561 path.display()
562 )
563 })
564}
565
566// ---------------------------------------------------------------------------
567// TypeScript diff-scoped coverage detail — issues #135, #162.
568//
569// What the diff-scoped floor (`crate::patch_coverage::measure_typescript`) reads:
570// per-file coverage detail for the four vitest metrics. vitest's `json-summary`
571// gives only per-file totals, so this measures with the detailed `json` (Istanbul
572// `coverage-final.json`) reporter and reduces each file to the per-statement /
573// per-branch-arm / per-function `(line, covered)` counts the floor's ratio needs.
574// ---------------------------------------------------------------------------
575
576/// One file's entry in a vitest v8 `coverage-final.json` (Istanbul) report, pared
577/// to what patch coverage reads: the statement / branch / function maps and their
578/// hit counts. Unmodeled fields (`path`, per-node metadata) are ignored.
579#[derive(Debug, Clone, Deserialize)]
580struct IstanbulFile {
581 /// Statement id → source span. A statement whose hit count in `s` is `0` was
582 /// never executed, so its lines are uncovered.
583 #[serde(rename = "statementMap", default)]
584 statement_map: BTreeMap<String, IstanbulSpan>,
585 /// Statement id → execution count.
586 #[serde(default)]
587 s: BTreeMap<String, u64>,
588 /// Branch id → branch location. A branch with a `0` among its `b` counts had a
589 /// path the suite never took, so its source line is uncovered.
590 #[serde(rename = "branchMap", default)]
591 branch_map: BTreeMap<String, IstanbulBranch>,
592 /// Branch id → per-arm execution counts (one count per branch arm).
593 #[serde(default)]
594 b: BTreeMap<String, Vec<u64>>,
595 /// Function id → declaration location. A function whose hit count in `f` is `0`
596 /// was never called. The diff-scoped floor (#162) reads this via
597 /// [`istanbul_patch_detail`].
598 #[serde(rename = "fnMap", default)]
599 fn_map: BTreeMap<String, IstanbulFn>,
600 /// Function id → execution count.
601 #[serde(default)]
602 f: BTreeMap<String, u64>,
603}
604
605/// A source span — only the 1-based line numbers matter to patch coverage.
606#[derive(Debug, Clone, Deserialize)]
607struct IstanbulSpan {
608 start: IstanbulPos,
609 end: IstanbulPos,
610}
611
612/// A position in a source span; the `column` is ignored.
613#[derive(Debug, Clone, Deserialize)]
614struct IstanbulPos {
615 line: u64,
616}
617
618/// A branch entry — only its location (whose start line is the branch's source
619/// line) matters; the `type` and per-path `locations` are ignored.
620#[derive(Debug, Clone, Deserialize)]
621struct IstanbulBranch {
622 loc: IstanbulSpan,
623}
624
625/// A function entry — only its declaration's start line (the function's source
626/// line) matters; the `name`, `loc`, and top-level `line` are ignored. vitest's
627/// v8 export shapes this as `{"name":.., "decl":{"start":{"line":N,..},..}, ..}`.
628#[derive(Debug, Clone, Deserialize)]
629struct IstanbulFn {
630 decl: IstanbulSpan,
631}
632
633/// Per-file coverage detail from a vitest v8 `coverage-final.json` (Istanbul)
634/// report — the counts the diff-scoped floor (#162) needs. Each entry carries the
635/// Istanbul maps reduced to `(line, …, covered)` tuples, so the pure
636/// [`crate::patch_coverage::evaluate_patch_typescript`] can restrict each of the
637/// four metrics to the changed lines.
638#[derive(Debug, Clone, Default)]
639pub struct TsPatchCoverage {
640 /// One per `statementMap` entry: `(start_line, end_line, covered)` — `covered`
641 /// is `s[id] > 0`. A statement counts toward the diff when any line it spans is
642 /// a changed line.
643 pub statements: Vec<(u64, u64, bool)>,
644 /// One per branch **arm**: `(source_line, covered)` — `source_line` is the
645 /// branch's `loc.start.line` (shared by every arm) and `covered` is that arm's
646 /// count `> 0`. An arm counts toward the diff when its source line is changed.
647 pub branch_arms: Vec<(u64, bool)>,
648 /// One per `fnMap` entry: `(decl_line, covered)` — `decl_line` is `decl.start.line`
649 /// and `covered` is `f[id] > 0`. A function counts toward the diff when its
650 /// declaration line is changed.
651 pub functions: Vec<(u64, bool)>,
652}
653
654/// Run the TypeScript unit suite under vitest in `root` and return the per-file
655/// coverage detail for the four metrics — keyed by the absolute path vitest
656/// reports, the caller re-keying to `root`-relative to match the diff. Reads the
657/// Istanbul report for the diff-scoped floor (#162): the per-statement /
658/// per-branch-arm / per-function `(line, covered)` detail the floor's ratio needs.
659/// `exclude` is the `coverage`-rule exemptions,
660/// dropped from the run so an exempt file's changed lines are lifted. `npx`
661/// resolves the project-local `vitest`, so it and `@vitest/coverage-v8` must be
662/// installed under `root`.
663pub fn measure_patch_typescript_detail(
664 root: &Path,
665 exclude: &[String],
666) -> Result<BTreeMap<String, TsPatchCoverage>> {
667 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
668 istanbul_patch_detail(&json)
669}
670
671/// Pure: per-file [`TsPatchCoverage`] from a vitest v8 `coverage-final.json`
672/// (Istanbul) report. Keyed by the path vitest reports (absolute). A file present
673/// but with no statements/branches/functions maps to an empty `TsPatchCoverage`.
674fn istanbul_patch_detail(json: &str) -> Result<BTreeMap<String, TsPatchCoverage>> {
675 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
676 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
677 let mut out = BTreeMap::new();
678 for (path, file) in files {
679 let mut detail = TsPatchCoverage::default();
680 // Each statement → (start, end, covered): covered when its count is > 0.
681 for (id, span) in &file.statement_map {
682 let covered = file.s.get(id).is_some_and(|&count| count > 0);
683 detail
684 .statements
685 .push((span.start.line, span.end.line, covered));
686 }
687 // Each branch arm → (source_line, covered): the branch's location start line
688 // (shared by every arm) with that arm's count > 0. v8 may model a branch as
689 // a single arm (a `[count]` array) or several (`[arm0, arm1, …]`); one tuple
690 // per arm either way.
691 for (id, branch) in &file.branch_map {
692 let line = branch.loc.start.line;
693 if let Some(counts) = file.b.get(id) {
694 for &count in counts {
695 detail.branch_arms.push((line, count > 0));
696 }
697 }
698 }
699 // Each function → (decl_line, covered): the declaration's start line with
700 // its call count > 0.
701 for (id, function) in &file.fn_map {
702 let covered = file.f.get(id).is_some_and(|&count| count > 0);
703 detail.functions.push((function.decl.start.line, covered));
704 }
705 out.insert(path, detail);
706 }
707 Ok(out)
708}
709
710// ---------------------------------------------------------------------------
711// Rust (cargo llvm-cov) — issue #37.
712//
713// The Rust twin of the rules above. `cargo llvm-cov` reports LLVM source-based
714// coverage as regions + lines (branch coverage is still experimental), so the
715// Rust rule carries its own thresholds and `measure_rust` entry point; only the
716// `Outcome` type is shared. Mirroring the Python/TypeScript split, a pure
717// `evaluate_rust` over a parsed llvm-cov export and the thin subprocess layer
718// that produces one land with the implementation (#37).
719// ---------------------------------------------------------------------------
720
721/// The `cargo llvm-cov` coverage floors, from a `[rust].coverage` table (or the
722/// zero-config default). `lines` is always enforced; the rest are opt-in — `None`
723/// skips the check (the zero-config default floors lines only, #206). A `branch`
724/// floor adds `--branch` to the run, which instruments only on a nightly
725/// toolchain (#267).
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727pub struct RustThresholds {
728 pub regions: Option<u8>,
729 pub lines: u8,
730 pub functions: Option<u8>,
731 pub branch: Option<u8>,
732}
733
734/// A `cargo llvm-cov --json` export (LLVM's `llvm.coverage.json.export`), pared to
735/// the totals the check needs. A single run produces one `data` entry; unmodeled
736/// fields (per-file/per-function detail, `type`, `version`) are ignored.
737#[derive(Debug, Clone, Deserialize)]
738pub struct LlvmCovReport {
739 pub data: Vec<LlvmCovData>,
740}
741
742/// One export entry — only its `totals` are needed (`--summary-only` omits the
743/// per-file and per-function detail).
744#[derive(Debug, Clone, Copy, Deserialize)]
745pub struct LlvmCovData {
746 pub totals: LlvmCovTotals,
747}
748
749/// The `totals` block of an llvm-cov export — the metrics this rule can enforce:
750/// regions and lines always, `functions` and (under `--branch`) `branches` when
751/// their opt-in floors are set (#267). llvm-cov also reports `instantiations` and
752/// `mcdc`, which the check ignores. `branches` is optional-with-default so an
753/// export from a run without branch instrumentation still parses (it then reads
754/// `count = 0`).
755#[derive(Debug, Clone, Copy, Deserialize)]
756pub struct LlvmCovTotals {
757 pub regions: LlvmCovMetric,
758 pub lines: LlvmCovMetric,
759 pub functions: LlvmCovMetric,
760 #[serde(default)]
761 pub branches: Option<LlvmCovMetric>,
762}
763
764/// One metric's totals from an llvm-cov export, pared to what the check needs: the
765/// denominator size and the covered percent.
766#[derive(Debug, Clone, Copy, Deserialize)]
767pub struct LlvmCovMetric {
768 /// Size of the denominator (regions or lines counted).
769 pub count: u64,
770 /// How many were covered.
771 pub covered: u64,
772 /// Covered percent — llvm-cov computes `100 * covered / count`.
773 pub percent: f64,
774}
775
776/// Parse a `cargo llvm-cov --json` export.
777pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
778 serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
779}
780
781/// Decide whether `report` meets both thresholds.
782///
783/// Fails when the run measured no regions at all (an empty denominator — a wrong
784/// path, or a crate that compiled nothing — is never a silent pass), otherwise
785/// checks regions and lines and fails listing each below its floor.
786pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
787 let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
788 return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
789 };
790 // Vacuous-run guard: every compiled crate has regions, so a zero region
791 // denominator means nothing was measured — failed rather than passed on an
792 // empty measurement (mirrors the TypeScript path).
793 if totals.regions.count == 0 {
794 return Outcome::Fail(
795 "the unit suite measured no code — check the path and that the suite runs".to_string(),
796 );
797 }
798 // `regions`, `functions`, and `branch` are opt-in (#206, #267): the zero-config
799 // default floors lines only, so each check is skipped unless a config set its
800 // floor.
801 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
802 if let Some(regions) = thresholds.regions {
803 checks.push(("regions", totals.regions.percent, regions));
804 }
805 checks.push(("lines", totals.lines.percent, thresholds.lines));
806 if let Some(functions) = thresholds.functions {
807 checks.push(("functions", totals.functions.percent, functions));
808 }
809 if let Some(branch) = thresholds.branch {
810 // The floor's run added `--branch` (a failed instrumentation is a run
811 // error, surfaced before this point), so a zero branch denominator here
812 // means the crate has no branch points — vacuously satisfied, mirroring
813 // the diff-scoped floors' empty-denominator rule.
814 if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
815 checks.push(("branches", branches.percent, branch));
816 }
817 }
818 let mut shortfalls = Vec::new();
819 for (name, actual, required) in checks {
820 // A hair of tolerance so a percent that rounds to the floor isn't failed by
821 // float noise (matches the Python / TypeScript paths).
822 if actual + 1e-9 < f64::from(required) {
823 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
824 }
825 }
826 if shortfalls.is_empty() {
827 Outcome::Pass
828 } else {
829 Outcome::Fail(format!(
830 "coverage below thresholds: {}",
831 shortfalls.join(", ")
832 ))
833 }
834}
835
836/// Run the unit suite under `cargo llvm-cov` in `root` and check it against
837/// `thresholds`.
838///
839/// Shells out to `cargo llvm-cov --lib --json --summary-only`, omitting every path in
840/// `ignore` from the denominator (a single `--ignore-filename-regex`), then
841/// evaluates the export. `ignore` holds the `coverage`-rule exemptions resolved
842/// from config, as `root`-relative paths; `features` the `[rust] features` list to
843/// enable on the run (#266). `cargo-llvm-cov` must be installed.
844pub fn measure_rust(
845 root: &Path,
846 thresholds: RustThresholds,
847 ignore: &[String],
848 features: &[String],
849) -> Result<Outcome> {
850 let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
851 Ok(evaluate_rust(&report, thresholds))
852}
853
854/// A `cargo llvm-cov` target directory under the temp dir — unique per call (so
855/// checks running in parallel don't collide) and removed on drop (so the build
856/// never leaks into the scanned tree). Passed to the run as `CARGO_TARGET_DIR`.
857struct TargetDir(PathBuf);
858
859impl TargetDir {
860 fn new() -> Self {
861 static COUNTER: AtomicU64 = AtomicU64::new(0);
862 let name = format!(
863 "testing-conventions-llvm-cov-{}-{}",
864 std::process::id(),
865 COUNTER.fetch_add(1, Ordering::Relaxed),
866 );
867 TargetDir(std::env::temp_dir().join(name))
868 }
869}
870
871impl Drop for TargetDir {
872 fn drop(&mut self) {
873 let _ = std::fs::remove_dir_all(&self.0);
874 }
875}
876
877/// Run cargo llvm-cov over the unit suite in `root` and return the parsed
878/// `--summary-only` export — the totals the floor checks. `branch` adds
879/// `--branch` for a configured branch floor (#267).
880fn run_llvm_cov(
881 root: &Path,
882 ignore: &[String],
883 features: &[String],
884 branch: bool,
885) -> Result<LlvmCovReport> {
886 parse_llvm_cov_report(&run_cargo_llvm_cov(
887 root,
888 ignore,
889 &["--json", "--summary-only"],
890 features,
891 branch,
892 )?)
893}
894
895/// Run `cargo llvm-cov --lib` over the unit suite in `root` with the given coverage
896/// `format` args (`["--json", "--summary-only"]` for the whole-tree floor's totals,
897/// `["--json"]` for the diff-scoped floor's per-region detail) and return its
898/// stdout. Shared by the whole-tree floor (#37) and the diff-scoped floor (#162),
899/// so both measure the same unit-only slice (#265).
900///
901/// The build goes to an out-of-tree target dir (via `CARGO_TARGET_DIR`) so the
902/// scanned crate stays pristine; the `coverage`-rule exemptions become one
903/// `--ignore-filename-regex`; the `[rust] features` list is enabled on the run so
904/// `#[cfg(feature = ...)]` code is compiled and measured (#266); and the outer
905/// run's instrumentation env is stripped for nested-run hygiene (the loop below
906/// explains why).
907fn run_cargo_llvm_cov(
908 root: &Path,
909 ignore: &[String],
910 format: &[&str],
911 features: &[String],
912 branch: bool,
913) -> Result<String> {
914 let target = TargetDir::new();
915
916 let mut command = Command::new("cargo");
917 command
918 .current_dir(root)
919 .arg("llvm-cov")
920 // `--lib` scopes the run to the unit suite — the library target with its
921 // inline `#[cfg(test)]` modules, the tool's definition of a Rust unit.
922 // cargo-llvm-cov's default runs every test target, which lets the
923 // integration tier under `tests/` pad the number (#265).
924 .arg("--lib")
925 .args(format)
926 .env("CARGO_TARGET_DIR", &target.0);
927 if !features.is_empty() {
928 command.arg("--features").arg(features.join(","));
929 }
930 if branch {
931 // A configured branch floor measures branch outcomes (#267); the flag
932 // instruments only on a nightly toolchain — the error below names that.
933 command.arg("--branch");
934 }
935 if let Some(regex) = ignore_filename_regex(ignore) {
936 command.arg("--ignore-filename-regex").arg(regex);
937 }
938 // Nested-run hygiene: when this check itself runs under `cargo llvm-cov` (the
939 // package's own coverage job), the outer run exports its instrumentation state
940 // into our environment — the coverage flags and profile path, and (because
941 // cargo-llvm-cov drives instrumentation through a rustc wrapper) a
942 // `RUSTC_WRAPPER` pointing back at `cargo-llvm-cov`. Inherited, that wrapper
943 // makes the inner run re-enter cargo-llvm-cov on every rustc invocation and
944 // never finish — it hangs compiling the scanned crate until the runner is
945 // OOM-killed. Strip the lot so the inner run instruments from a clean slate.
946 for var in [
947 "RUSTFLAGS",
948 "CARGO_ENCODED_RUSTFLAGS",
949 "RUSTDOCFLAGS",
950 "CARGO_ENCODED_RUSTDOCFLAGS",
951 "LLVM_PROFILE_FILE",
952 "CARGO_LLVM_COV",
953 "CARGO_LLVM_COV_SHOW_ENV",
954 "CARGO_LLVM_COV_TARGET_DIR",
955 "CARGO_LLVM_COV_BUILD_DIR",
956 "RUSTC_WRAPPER",
957 "RUSTC_WORKSPACE_WRAPPER",
958 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
959 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
960 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
961 // Toolchain hygiene (#267): when this tool is itself spawned by a cargo
962 // process (a test harness, an xtask), cargo/rustup export the *spawning*
963 // toolchain into the environment, and rustup gives those variables
964 // precedence over the scanned crate's own `rust-toolchain.toml`. The
965 // scanned crate's pin must decide — a branch-floor crate pins nightly
966 // there — so the inherited selection is dropped and rustup resolves
967 // fresh from the crate's directory.
968 "RUSTUP_TOOLCHAIN",
969 "CARGO",
970 "RUSTC",
971 ] {
972 command.env_remove(var);
973 }
974 let output = command
975 .output()
976 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
977 if !output.status.success() {
978 // A branch-floor run that fails is most often a stable toolchain (the
979 // `--branch` instrumentation is nightly-only), so name the requirement
980 // alongside the run's own output.
981 let hint = if branch {
982 "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
983 nightly toolchain — pin one in the crate's rust-toolchain.toml with \
984 llvm-tools-preview, or set a rustup directory override)"
985 } else {
986 ""
987 };
988 bail!(
989 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{hint}\n{}{}",
990 root.display(),
991 String::from_utf8_lossy(&output.stdout),
992 String::from_utf8_lossy(&output.stderr),
993 );
994 }
995 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
996}
997
998/// Per-file region detail from a `cargo llvm-cov --json` export — the per-region
999/// counts the diff-scoped floor (#162) needs. Each entry carries one
1000/// `(start_line, end_line, covered)` tuple per code region, so the pure
1001/// [`crate::patch_coverage::evaluate_patch_rust`] can restrict both the regions and
1002/// lines metrics to the changed lines.
1003#[derive(Debug, Clone, Default)]
1004pub struct RustPatchCoverage {
1005 /// One per code region (a `kind == 0` region of the LLVM export):
1006 /// `(start_line, end_line, covered)` — `covered` is the region's
1007 /// `executionCount > 0`. A region counts toward the diff when any line it spans
1008 /// is a changed line.
1009 pub regions: Vec<(u64, u64, bool)>,
1010}
1011
1012/// A full `cargo llvm-cov --json` export (LLVM's `llvm.coverage.json.export`),
1013/// modeling the per-function region detail the diff-scoped floor needs — separate
1014/// from [`LlvmCovReport`], which keeps only the `totals` the whole-tree floor
1015/// reads. A single run produces one `data` entry; unmodeled fields (`totals`,
1016/// `type`, `version`) are ignored.
1017#[derive(Debug, Clone, Deserialize)]
1018struct LlvmCovExport {
1019 data: Vec<LlvmCovExportData>,
1020}
1021
1022/// One export entry — its per-function `functions` block carries the regions (the
1023/// `--summary-only` runs that feed [`LlvmCovReport`] omit it), and its `files` block
1024/// names the measured files. `--ignore-filename-regex` drops an exempt file from
1025/// `files` but *not* from `functions` (the regions array is unfiltered), so the
1026/// `files` list is the allowlist [`llvm_cov_patch_detail`] restricts the regions to.
1027#[derive(Debug, Clone, Deserialize)]
1028struct LlvmCovExportData {
1029 files: Vec<LlvmCovExportFile>,
1030 functions: Vec<LlvmCovFunction>,
1031}
1032
1033/// One measured file in the export's `files` block — only its `filename` (the
1034/// absolute path) is needed, to build the not-ignored allowlist. The per-file
1035/// `segments` / `summary` detail is ignored (the regions come from `functions`).
1036#[derive(Debug, Clone, Deserialize)]
1037struct LlvmCovExportFile {
1038 filename: String,
1039}
1040
1041/// One function's coverage in the export: the source files it spans (`filenames`,
1042/// indexed by a region's `fileID`) and its regions. Each region is a flat array
1043/// `[lineStart, colStart, lineEnd, colEnd, executionCount, fileID, expandedFileID,
1044/// kind]`; the fields are read positionally in [`llvm_cov_patch_detail`].
1045#[derive(Debug, Clone, Deserialize)]
1046struct LlvmCovFunction {
1047 filenames: Vec<String>,
1048 regions: Vec<Vec<i64>>,
1049}
1050
1051/// Run the Rust unit suite under `cargo llvm-cov` in `root` and return the per-file
1052/// region detail — keyed by the absolute path llvm-cov reports, the caller re-keying
1053/// to `root`-relative to match the diff. Reads the full `--json` export for the
1054/// diff-scoped floor (#162): the per-region `(line, covered)` detail the floor's
1055/// regions metric needs. `ignore` is the `coverage`-rule exemptions, dropped
1056/// from the run so an exempt file's changed lines are lifted. `cargo-llvm-cov` must
1057/// be installed.
1058pub fn measure_patch_rust_detail(
1059 root: &Path,
1060 ignore: &[String],
1061 features: &[String],
1062) -> Result<BTreeMap<String, RustPatchCoverage>> {
1063 // The diff-scoped floor judges regions + lines, so its run never adds
1064 // `--branch` (#267).
1065 llvm_cov_patch_detail(&run_cargo_llvm_cov(
1066 root,
1067 ignore,
1068 &["--json"],
1069 features,
1070 false,
1071 )?)
1072}
1073
1074/// Pure: per-file [`RustPatchCoverage`] from a `cargo llvm-cov --json` export.
1075/// Keyed by the path llvm-cov reports (absolute). Walks every function's regions;
1076/// for each region:
1077/// - **skips** any region whose file is not in the export's `files` allowlist —
1078/// `--ignore-filename-regex` drops an exempt file from `files` (and the totals)
1079/// but leaves it in the unfiltered `functions` regions, so honoring the
1080/// exemption means intersecting with `files`. A run with nothing exempt lists
1081/// every measured file, so this is a no-op there.
1082/// - **skips** any region whose `kind` (index 7) is not `0` — only `kind == 0`
1083/// code regions count toward coverage (gap / expansion / skipped / branch
1084/// regions carry no line-coverage signal). The kept count (with nothing
1085/// ignored) matches the `totals.regions.count` a `--summary-only` run reports.
1086/// - reads `start_line = region[0]`, `end_line = region[2]`,
1087/// `covered = region[4] > 0`, and the file `filenames[region[5]]` (the
1088/// `fileID`), pushing `(start_line, end_line, covered)` under that file.
1089///
1090/// A region array with fewer than 8 elements (malformed — never seen from
1091/// llvm-cov) is skipped rather than panicking on an index, as is one whose `fileID`
1092/// is out of range for its `filenames`.
1093fn llvm_cov_patch_detail(json: &str) -> Result<BTreeMap<String, RustPatchCoverage>> {
1094 let export: LlvmCovExport =
1095 serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")?;
1096 let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1097 for data in &export.data {
1098 // The `files` block honors `--ignore-filename-regex`; the `functions` regions
1099 // do not, so restrict to the measured (not-ignored) files.
1100 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1101 for function in &data.functions {
1102 for region in &function.regions {
1103 // A code region carries eight fields; anything shorter is malformed
1104 // (never emitted by llvm-cov) and skipped rather than indexed.
1105 if region.len() < 8 {
1106 continue;
1107 }
1108 // Only `kind == 0` (a code region) contributes to line coverage;
1109 // gap (1) / expansion (2) / skipped / branch regions are ignored.
1110 if region[7] != 0 {
1111 continue;
1112 }
1113 let file_id = region[5];
1114 let Ok(file_id) = usize::try_from(file_id) else {
1115 continue;
1116 };
1117 let Some(file) = function.filenames.get(file_id) else {
1118 continue;
1119 };
1120 // Skip a file the run ignored (absent from `files`) so a `coverage`
1121 // exemption drops its regions, lifting its changed lines.
1122 if !measured.contains(file.as_str()) {
1123 continue;
1124 }
1125 let start = region[0].max(0) as u64;
1126 let end = region[2].max(0) as u64;
1127 let covered = region[4] > 0;
1128 out.entry(file.clone())
1129 .or_default()
1130 .regions
1131 .push((start, end, covered));
1132 }
1133 }
1134 }
1135 Ok(out)
1136}
1137
1138/// The single `--ignore-filename-regex` value for the run, or `None` when nothing
1139/// is exempt. `cargo llvm-cov` takes one regex, so the `coverage`-exempt paths are
1140/// each regex-escaped (matched literally, not as a pattern) and joined with `|`. An
1141/// exempt file leaves the denominator with its reason recorded in config — an
1142/// auditable omission, not a silent ignore-glob.
1143fn ignore_filename_regex(ignore: &[String]) -> Option<String> {
1144 if ignore.is_empty() {
1145 return None;
1146 }
1147 Some(
1148 ignore
1149 .iter()
1150 .map(|path| regex_escape(path))
1151 .collect::<Vec<_>>()
1152 .join("|"),
1153 )
1154}
1155
1156/// Escape the regex metacharacters in `s` so it matches literally — an exempt path
1157/// carries `.` (and may carry other metacharacters) that must not read as regex.
1158fn regex_escape(s: &str) -> String {
1159 const META: &str = r"\.+*?()|[]{}^$";
1160 let mut out = String::with_capacity(s.len());
1161 for c in s.chars() {
1162 if META.contains(c) {
1163 out.push('\\');
1164 }
1165 out.push(c);
1166 }
1167 out
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173
1174 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1175 CoverageReport {
1176 totals: Totals {
1177 percent_covered,
1178 num_branches,
1179 },
1180 files: BTreeMap::new(),
1181 }
1182 }
1183
1184 #[test]
1185 fn passes_when_total_meets_the_floor() {
1186 assert_eq!(
1187 evaluate(
1188 &report(100.0, 12),
1189 Thresholds {
1190 fail_under: 100,
1191 branch: true
1192 }
1193 ),
1194 Outcome::Pass
1195 );
1196 }
1197
1198 #[test]
1199 fn fails_when_total_is_below_the_floor() {
1200 assert!(matches!(
1201 evaluate(
1202 &report(80.0, 12),
1203 Thresholds {
1204 fail_under: 100,
1205 branch: true
1206 }
1207 ),
1208 Outcome::Fail(_)
1209 ));
1210 }
1211
1212 #[test]
1213 fn fails_when_branch_required_but_unmeasured() {
1214 // branch=true but the report measured no branches → a misconfigured run.
1215 assert!(matches!(
1216 evaluate(
1217 &report(100.0, 0),
1218 Thresholds {
1219 fail_under: 90,
1220 branch: true
1221 }
1222 ),
1223 Outcome::Fail(_)
1224 ));
1225 }
1226
1227 #[test]
1228 fn parses_a_coverage_py_report() {
1229 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1230 let report = parse_report(json).expect("valid coverage.py json");
1231 assert_eq!(report.totals.percent_covered, 91.5);
1232 assert_eq!(report.totals.num_branches, 8);
1233 }
1234
1235 #[test]
1236 fn parses_the_per_file_block_for_patch_coverage() {
1237 // A realistic `coverage json` shape: a `files` map carrying the per-file
1238 // missing lines and `[src, dst]` branch pairs patch coverage (#132) reads.
1239 let json = r#"{
1240 "files": {
1241 "widget.py": {
1242 "executed_lines": [1, 2, 3, 4, 6],
1243 "summary": {"percent_covered": 85.0},
1244 "missing_lines": [5],
1245 "excluded_lines": [],
1246 "missing_branches": [[4, 5]]
1247 }
1248 },
1249 "totals": {"percent_covered": 85.0, "num_branches": 4}
1250 }"#;
1251 let report = parse_report(json).expect("valid coverage.py json with files");
1252 let widget = report.files.get("widget.py").expect("widget.py is present");
1253 assert_eq!(widget.missing_lines, vec![5]);
1254 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1255 // The floor still reads totals from the same report.
1256 assert_eq!(report.totals.percent_covered, 85.0);
1257 }
1258
1259 #[test]
1260 fn a_report_without_a_files_block_parses_with_an_empty_map() {
1261 // The floor path parses totals only; `files` defaults to empty.
1262 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1263 .expect("valid coverage.py json");
1264 assert!(report.files.is_empty());
1265 }
1266
1267 #[test]
1268 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1269 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1270 }
1271
1272 #[test]
1273 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1274 // The caller passes already-resolved, sorted, `root`-relative paths.
1275 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1276 assert_eq!(
1277 build_omit(&exempt),
1278 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1279 );
1280 }
1281
1282 // --- TypeScript (vitest) — issue #31 ---
1283
1284 fn metric(pct: f64) -> VitestMetric {
1285 VitestMetric {
1286 pct: Some(pct),
1287 total: 10,
1288 }
1289 }
1290
1291 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1292 VitestReport {
1293 total: VitestTotals {
1294 lines: metric(lines),
1295 branches: metric(branches),
1296 functions: metric(functions),
1297 statements: metric(statements),
1298 },
1299 }
1300 }
1301
1302 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1303 lines: 100,
1304 branches: 100,
1305 functions: 100,
1306 statements: 100,
1307 };
1308 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1309 lines: 80,
1310 branches: 75,
1311 functions: 80,
1312 statements: 80,
1313 };
1314
1315 #[test]
1316 fn typescript_passes_when_every_metric_meets_its_floor() {
1317 assert_eq!(
1318 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1319 Outcome::Pass
1320 );
1321 }
1322
1323 #[test]
1324 fn typescript_fails_on_the_one_metric_below_its_floor() {
1325 // 100% lines but only 66.66% branches (the `below` fixture's shape): the
1326 // branch floor catches what line coverage misses — and only `branches` is
1327 // named as a shortfall, not the metrics that met their floor.
1328 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1329 assert!(
1330 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1331 "got: {outcome:?}"
1332 );
1333 }
1334
1335 #[test]
1336 fn typescript_fail_message_names_every_metric_below() {
1337 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1338 assert!(
1339 matches!(&outcome, Outcome::Fail(message)
1340 if message.contains("lines")
1341 && message.contains("branches")
1342 && message.contains("functions")
1343 && message.contains("statements")),
1344 "got: {outcome:?}"
1345 );
1346 }
1347
1348 #[test]
1349 fn typescript_tolerates_float_noise_at_the_floor() {
1350 // A percent a hair under the floor from rounding still passes.
1351 assert_eq!(
1352 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1353 Outcome::Pass
1354 );
1355 }
1356
1357 #[test]
1358 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1359 // Branch-free code measured alongside real code: branches has nothing to
1360 // cover (pct "Unknown") but lines/etc. are real and pass → overall pass.
1361 let report = VitestReport {
1362 total: VitestTotals {
1363 lines: metric(100.0),
1364 branches: VitestMetric {
1365 pct: None,
1366 total: 0,
1367 },
1368 functions: metric(100.0),
1369 statements: metric(100.0),
1370 },
1371 };
1372 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1373 }
1374
1375 #[test]
1376 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1377 // No lines in the denominator (everything excluded, or a wrong path): a
1378 // vacuous run is a failure, never a silent pass.
1379 let nothing = VitestMetric {
1380 pct: None,
1381 total: 0,
1382 };
1383 let report = VitestReport {
1384 total: VitestTotals {
1385 lines: nothing,
1386 branches: nothing,
1387 functions: nothing,
1388 statements: nothing,
1389 },
1390 };
1391 let outcome = evaluate_typescript(&report, TS_MID);
1392 assert!(
1393 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1394 "got: {outcome:?}"
1395 );
1396 }
1397
1398 #[test]
1399 fn parses_a_vitest_summary_report() {
1400 // A realistic `coverage-summary.json`: the four metrics plus the
1401 // `branchesTrue` block and a per-file entry the check ignores.
1402 let json = r#"{
1403 "total": {
1404 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1405 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1406 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1407 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1408 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1409 },
1410 "/abs/widget.ts": {
1411 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1412 }
1413 }"#;
1414 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1415 // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
1416 assert_eq!(report.total.lines.pct, Some(80.0));
1417 assert_eq!(report.total.branches.pct, Some(66.66));
1418 assert_eq!(report.total.functions.total, 2);
1419 }
1420
1421 #[test]
1422 fn parses_an_unknown_pct_as_unmeasured() {
1423 let json = r#"{"total": {
1424 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1425 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1426 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1427 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1428 }}"#;
1429 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1430 assert_eq!(report.total.lines.pct, None);
1431 assert_eq!(report.total.lines.total, 0);
1432 }
1433
1434 #[test]
1435 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1436 // vitest only ever writes a number or "Unknown"; anything else (here a
1437 // bool) is a malformed report, surfaced as an error rather than guessed.
1438 let json = r#"{"total":{
1439 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1440 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1441 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1442 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1443 }}"#;
1444 assert!(parse_vitest_report(json).is_err());
1445 }
1446
1447 // --- Rust (cargo llvm-cov) — issue #37 ---
1448
1449 fn rust_metric(percent: f64) -> LlvmCovMetric {
1450 LlvmCovMetric {
1451 count: 10,
1452 covered: 10,
1453 percent,
1454 }
1455 }
1456
1457 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1458 LlvmCovReport {
1459 data: vec![LlvmCovData {
1460 totals: LlvmCovTotals {
1461 regions: rust_metric(regions),
1462 lines: rust_metric(lines),
1463 functions: rust_metric(lines),
1464 branches: None,
1465 },
1466 }],
1467 }
1468 }
1469
1470 /// Like [`rust_report`] with explicit functions/branches metrics — the opt-in
1471 /// floors' tests (#267). `branches: (count, percent)` so the vacuous
1472 /// zero-denominator case is constructible.
1473 fn rust_report_full(
1474 regions: f64,
1475 lines: f64,
1476 functions: f64,
1477 branches: (u64, f64),
1478 ) -> LlvmCovReport {
1479 let (count, percent) = branches;
1480 LlvmCovReport {
1481 data: vec![LlvmCovData {
1482 totals: LlvmCovTotals {
1483 regions: rust_metric(regions),
1484 lines: rust_metric(lines),
1485 functions: rust_metric(functions),
1486 branches: Some(LlvmCovMetric {
1487 count,
1488 covered: count,
1489 percent,
1490 }),
1491 },
1492 }],
1493 }
1494 }
1495
1496 const RUST_FULL: RustThresholds = RustThresholds {
1497 regions: Some(100),
1498 lines: 100,
1499 functions: None,
1500 branch: None,
1501 };
1502 const RUST_MID: RustThresholds = RustThresholds {
1503 regions: Some(80),
1504 lines: 85,
1505 functions: None,
1506 branch: None,
1507 };
1508
1509 #[test]
1510 fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1511 // The opt-in functions floor (#267) is judged on the export's functions
1512 // total: 66.67% fails a 100 floor naming the metric, and clears 60.
1513 let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1514 let floor = |functions| RustThresholds {
1515 regions: None,
1516 lines: 50,
1517 functions: Some(functions),
1518 branch: None,
1519 };
1520 assert!(matches!(
1521 evaluate_rust(&report, floor(100)),
1522 Outcome::Fail(message) if message.contains("functions")
1523 ));
1524 assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1525 }
1526
1527 #[test]
1528 fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1529 // The opt-in branch floor (#267) is judged on the branches total of a
1530 // `--branch` run: 50% fails a 100 floor naming the metric, and clears 50.
1531 let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1532 let floor = |branch| RustThresholds {
1533 regions: None,
1534 lines: 50,
1535 functions: None,
1536 branch: Some(branch),
1537 };
1538 assert!(matches!(
1539 evaluate_rust(&report, floor(100)),
1540 Outcome::Fail(message) if message.contains("branches")
1541 ));
1542 assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1543 }
1544
1545 #[test]
1546 fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1547 // A successful `--branch` run over a crate with no branch points reports a
1548 // zero branch denominator — vacuously satisfied, mirroring the diff-scoped
1549 // floors' empty-denominator rule (a failed instrumentation is a run error,
1550 // never a zero count here).
1551 let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1552 let floor = RustThresholds {
1553 regions: None,
1554 lines: 50,
1555 functions: None,
1556 branch: Some(100),
1557 };
1558 assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1559 }
1560
1561 #[test]
1562 fn rust_passes_when_both_metrics_meet_their_floor() {
1563 assert_eq!(
1564 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1565 Outcome::Pass
1566 );
1567 }
1568
1569 #[test]
1570 fn rust_fails_on_the_one_metric_below_its_floor() {
1571 // 100% lines but only 70% regions: the regions floor catches what line
1572 // coverage misses — and only `regions` is named, not the metric that met
1573 // its floor.
1574 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1575 assert!(
1576 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1577 "got: {outcome:?}"
1578 );
1579 }
1580
1581 #[test]
1582 fn rust_fail_message_names_every_metric_below() {
1583 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1584 assert!(
1585 matches!(&outcome, Outcome::Fail(message)
1586 if message.contains("regions") && message.contains("lines")),
1587 "got: {outcome:?}"
1588 );
1589 }
1590
1591 #[test]
1592 fn rust_skips_the_region_check_when_regions_is_opt_out() {
1593 // The zero-config default (#206) sets `regions: None`, so only lines are
1594 // enforced: a crate at 100% lines clears the floor even with low regions.
1595 let thresholds = RustThresholds {
1596 regions: None,
1597 lines: 100,
1598 functions: None,
1599 branch: None,
1600 };
1601 assert_eq!(
1602 evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1603 Outcome::Pass
1604 );
1605 }
1606
1607 #[test]
1608 fn rust_still_fails_lines_with_regions_opt_out() {
1609 // `regions: None` skips only the region check — the line floor still bites.
1610 let thresholds = RustThresholds {
1611 regions: None,
1612 lines: 100,
1613 functions: None,
1614 branch: None,
1615 };
1616 let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1617 assert!(
1618 matches!(&outcome, Outcome::Fail(message)
1619 if message.contains("lines") && !message.contains("regions")),
1620 "got: {outcome:?}"
1621 );
1622 }
1623
1624 #[test]
1625 fn rust_tolerates_float_noise_at_the_floor() {
1626 // A percent a hair under the floor from rounding still passes.
1627 assert_eq!(
1628 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1629 Outcome::Pass
1630 );
1631 }
1632
1633 #[test]
1634 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1635 // No regions in the denominator (a wrong path, or a crate that compiled
1636 // nothing): a vacuous run is a failure, never a silent pass.
1637 let nothing = LlvmCovMetric {
1638 count: 0,
1639 covered: 0,
1640 percent: 0.0,
1641 };
1642 let report = LlvmCovReport {
1643 data: vec![LlvmCovData {
1644 totals: LlvmCovTotals {
1645 regions: nothing,
1646 lines: nothing,
1647 functions: nothing,
1648 branches: None,
1649 },
1650 }],
1651 };
1652 let outcome = evaluate_rust(&report, RUST_MID);
1653 assert!(
1654 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1655 "got: {outcome:?}"
1656 );
1657 }
1658
1659 #[test]
1660 fn rust_fails_an_export_with_no_data() {
1661 // `cargo llvm-cov` always emits one `data` entry; an empty array is a
1662 // malformed run, failed rather than treated as a pass.
1663 let report = LlvmCovReport { data: vec![] };
1664 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1665 }
1666
1667 #[test]
1668 fn parses_a_cargo_llvm_cov_report() {
1669 // A realistic `--json --summary-only` export: regions/lines (enforced) plus
1670 // the functions block and the `type`/`version` the check ignores.
1671 let json = r#"{
1672 "data": [{"totals": {
1673 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1674 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1675 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1676 }}],
1677 "type": "llvm.coverage.json.export",
1678 "version": "2.0.1"
1679 }"#;
1680 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1681 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1682 assert_eq!(report.data[0].totals.lines.count, 20);
1683 }
1684
1685 // --- Rust diff-scoped region detail (`cargo llvm-cov --json`) — issue #162 ---
1686
1687 #[test]
1688 fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1689 // A realistic full `--json` export: one function spanning two regions on
1690 // `/abs/grade.rs` — line 6 covered (execCount 1), line 10 the uncovered
1691 // `else` arm (execCount 0). Both are `kind == 0` code regions, indexed back
1692 // to `filenames[0]`.
1693 let json = r#"{
1694 "data": [{
1695 "files": [{"filename": "/abs/grade.rs"}],
1696 "functions": [{
1697 "filenames": ["/abs/grade.rs"],
1698 "regions": [
1699 [6, 5, 6, 26, 1, 0, 0, 0],
1700 [10, 9, 10, 17, 0, 0, 0, 0]
1701 ]
1702 }],
1703 "totals": {}
1704 }],
1705 "type": "llvm.coverage.json.export",
1706 "version": "3.0.1"
1707 }"#;
1708 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1709 assert_eq!(
1710 out["/abs/grade.rs"].regions,
1711 vec![(6, 6, true), (10, 10, false)]
1712 );
1713 }
1714
1715 #[test]
1716 fn llvm_cov_patch_detail_skips_non_code_regions() {
1717 // Only `kind == 0` counts: a gap region (kind 1) and an expansion region
1718 // (kind 2) on the same function are ignored, leaving just the one code region.
1719 let json = r#"{
1720 "data": [{
1721 "files": [{"filename": "/abs/a.rs"}],
1722 "functions": [{
1723 "filenames": ["/abs/a.rs"],
1724 "regions": [
1725 [1, 1, 1, 10, 2, 0, 0, 0],
1726 [2, 1, 2, 10, 0, 0, 0, 1],
1727 [3, 1, 3, 10, 0, 0, 0, 2]
1728 ]
1729 }]
1730 }]
1731 }"#;
1732 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1733 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1734 }
1735
1736 #[test]
1737 fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1738 // A region's `fileID` (index 5) selects its file from the function's
1739 // `filenames`; two regions under the same function land in different files.
1740 let json = r#"{
1741 "data": [{
1742 "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1743 "functions": [{
1744 "filenames": ["/abs/a.rs", "/abs/b.rs"],
1745 "regions": [
1746 [1, 1, 1, 5, 1, 0, 0, 0],
1747 [9, 1, 9, 5, 0, 1, 1, 0]
1748 ]
1749 }]
1750 }]
1751 }"#;
1752 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1753 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1754 assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1755 }
1756
1757 #[test]
1758 fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1759 // A region array shorter than the eight fields (never seen from llvm-cov) is
1760 // skipped rather than panicking on an index; the well-formed one survives.
1761 let json = r#"{
1762 "data": [{
1763 "files": [{"filename": "/abs/a.rs"}],
1764 "functions": [{
1765 "filenames": ["/abs/a.rs"],
1766 "regions": [
1767 [4, 1, 4],
1768 [5, 1, 5, 9, 1, 0, 0, 0]
1769 ]
1770 }]
1771 }]
1772 }"#;
1773 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1774 assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1775 }
1776
1777 #[test]
1778 fn llvm_cov_patch_detail_spans_a_multiline_region() {
1779 // A region spanning lines 3–5 keeps both endpoints, so a changed line
1780 // anywhere in 3..=5 can count it.
1781 let json = r#"{
1782 "data": [{
1783 "files": [{"filename": "/abs/a.rs"}],
1784 "functions": [{
1785 "filenames": ["/abs/a.rs"],
1786 "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1787 }]
1788 }]
1789 }"#;
1790 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1791 assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1792 }
1793
1794 #[test]
1795 fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1796 // `--ignore-filename-regex` drops an exempt file from `files` but leaves its
1797 // regions in `functions`; restricting to the `files` allowlist lifts them, so
1798 // the ignored file contributes nothing while the kept file still does.
1799 let json = r#"{
1800 "data": [{
1801 "files": [{"filename": "/abs/kept.rs"}],
1802 "functions": [{
1803 "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1804 "regions": [
1805 [1, 1, 1, 9, 1, 0, 0, 0],
1806 [2, 1, 2, 9, 0, 1, 0, 0]
1807 ]
1808 }]
1809 }]
1810 }"#;
1811 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1812 assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1813 assert!(!out.contains_key("/abs/ignored.rs"));
1814 }
1815
1816 #[test]
1817 fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1818 assert!(llvm_cov_patch_detail("{ not json").is_err());
1819 }
1820
1821 #[test]
1822 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1823 assert_eq!(ignore_filename_regex(&[]), None);
1824 }
1825
1826 #[test]
1827 fn rust_ignore_regex_escapes_and_joins_exempt_paths() {
1828 // The caller passes already-resolved, `root`-relative paths; each is
1829 // regex-escaped (the `.` becomes `\.`) and joined into one alternation.
1830 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
1831 assert_eq!(
1832 ignore_filename_regex(&exempt).as_deref(),
1833 Some(r"src/shim\.rs|src/gen\.rs")
1834 );
1835 }
1836}