Skip to main content

testing_conventions/
coverage.rs

1//! Coverage rule (Python — issue #26; TypeScript — issue #31; 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`.
17//!
18//! Files exempted from coverage in config (issue #32) are omitted from the
19//! denominator alongside the test files; the caller resolves them
20//! ([`crate::config::resolve_exempt`]) and passes their paths to [`measure`] /
21//! [`measure_typescript`].
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28use anyhow::{bail, Context, Result};
29use serde::Deserialize;
30
31/// Always omitted from the coverage denominator: colocated unit tests are the
32/// suite, never a subject of it.
33const TEST_OMIT: &str = "*_test.py";
34
35/// Also always omitted: `conftest.py` holds pytest fixtures (test support), never
36/// a coverage subject. `*conftest.py` matches it at any depth, mirroring the
37/// `*_test.py` glob. (#112)
38const SUPPORT_OMIT: &str = "*conftest.py";
39
40/// The coverage floor to enforce, from a `[<language>].coverage` table.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Thresholds {
43    /// Minimum total coverage percent the unit suite must meet.
44    pub fail_under: u8,
45    /// Whether branch coverage must be measured (and folded into the total).
46    pub branch: bool,
47}
48
49/// A coverage.py JSON report (`coverage json`), pared to what the checks need:
50/// the `totals` (the floor) and the per-file `files` block (patch
51/// coverage, #132). Unmodeled fields (metadata, per-function/class data) are
52/// ignored.
53#[derive(Debug, Clone, Deserialize)]
54pub struct CoverageReport {
55    pub totals: Totals,
56    /// Per-file line/branch detail, keyed by the path coverage.py reports
57    /// (relative to the measured root). Additive: `#[serde(default)]`, so a report
58    /// parsed for the floor alone (the inline tests) needs no `files`.
59    #[serde(default)]
60    pub files: BTreeMap<String, FileCoverage>,
61}
62
63/// Per-file coverage detail from a coverage.py report (one `files` entry) — what
64/// patch coverage (#132) reads to decide whether a changed line is covered.
65/// Unmodeled fields (the summary, per-function/class data) are ignored.
66#[derive(Debug, Clone, Default, Deserialize)]
67pub struct FileCoverage {
68    /// Executable lines the suite ran.
69    #[serde(default)]
70    pub executed_lines: Vec<u64>,
71    /// Executable lines the suite never ran — an uncovered changed line is one of
72    /// these.
73    #[serde(default)]
74    pub missing_lines: Vec<u64>,
75    /// Lines excluded from coverage (e.g. `# pragma: no cover`); never a miss.
76    #[serde(default)]
77    pub excluded_lines: Vec<u64>,
78    /// `[source_line, dest_line]` pairs for branches the suite never took; `dest`
79    /// may be negative (a function / loop exit). Only the source line matters to
80    /// patch coverage. Empty when branch coverage was off.
81    #[serde(default)]
82    pub missing_branches: Vec<Vec<i64>>,
83}
84
85/// The `totals` block of a coverage.py report.
86#[derive(Debug, Clone, Deserialize)]
87pub struct Totals {
88    /// Total covered percent — line coverage, plus branch when measured.
89    pub percent_covered: f64,
90    /// Branches measured; `0` when branch coverage was not enabled.
91    #[serde(default)]
92    pub num_branches: u64,
93}
94
95/// The result of checking a report against the thresholds.
96#[derive(Debug, Clone, PartialEq)]
97pub enum Outcome {
98    /// The floor is met.
99    Pass,
100    /// The floor is not met; the message explains why (actual vs. required).
101    Fail(String),
102}
103
104/// Parse a coverage.py JSON report (the output of `coverage json`).
105pub fn parse_report(json: &str) -> Result<CoverageReport> {
106    serde_json::from_str(json).context("parsing coverage.py JSON report")
107}
108
109/// Decide whether `report` meets `thresholds`.
110///
111/// Fails when total coverage is below `fail_under`, or when branch coverage was
112/// required but the report measured no branches (a misconfigured run).
113pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
114    if thresholds.branch && report.totals.num_branches == 0 {
115        return Outcome::Fail(
116            "branch coverage is required but the report measured no branches".to_string(),
117        );
118    }
119    let actual = report.totals.percent_covered;
120    let required = f64::from(thresholds.fail_under);
121    // A hair of tolerance so a report that rounds to the floor (e.g. 99.999…%
122    // for a 100% target) isn't failed by float noise.
123    if actual + 1e-9 >= required {
124        Outcome::Pass
125    } else {
126        Outcome::Fail(format!(
127            "coverage {actual:.2}% is below the required {}%",
128            thresholds.fail_under
129        ))
130    }
131}
132
133/// Run the unit suite under coverage.py in `root` and check it against
134/// `thresholds`.
135///
136/// Shells out to `coverage run --branch` (omitting `*_test.py` and every path in
137/// `omit` from the denominator) then `coverage json`, and evaluates the report.
138/// `omit` holds the `coverage`-rule exemptions resolved from config, as
139/// `root`-relative paths. The `coverage` CLI — with `pytest` importable — must be
140/// on `PATH`.
141pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
142    let report = run_coverage(root, omit, false)?;
143    Ok(evaluate(&report, thresholds))
144}
145
146/// Run the Python unit suite under coverage.py in `root` with **every** source
147/// under `root` measured (`coverage run --source=.`) and return the parsed report
148/// — so an untested source shows in the `files` block as wholly uncovered rather
149/// than vanishing. The per-file detail is what patch coverage (#132) reads; `omit`
150/// is as in [`measure`] (an exempt file stays out of the run, so its changed
151/// lines are lifted).
152pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
153    run_coverage(root, omit, true)
154}
155
156/// A coverage.py data file under the temp dir — unique per call (so checks
157/// running in parallel don't collide) and removed on drop (so nothing leaks
158/// into the scanned tree).
159struct DataFile(PathBuf);
160
161impl DataFile {
162    fn new() -> Self {
163        static COUNTER: AtomicU64 = AtomicU64::new(0);
164        let name = format!(
165            "testing-conventions-{}-{}.coverage",
166            std::process::id(),
167            COUNTER.fetch_add(1, Ordering::Relaxed),
168        );
169        DataFile(std::env::temp_dir().join(name))
170    }
171}
172
173impl Drop for DataFile {
174    fn drop(&mut self) {
175        let _ = std::fs::remove_file(&self.0);
176    }
177}
178
179/// Run coverage.py over the unit suite in `root` and return the parsed report.
180///
181/// `include_all_sources` adds `--source=.` so coverage measures every source
182/// under `root` — even one no test imports, which then appears in the `files`
183/// block as wholly uncovered. The floor passes `false` (measuring only imported
184/// files, so its total is unchanged); patch coverage passes `true`.
185fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
186    let data = DataFile::new();
187    let omit = build_omit(omit);
188
189    // Branch coverage on; measure the sources in `root` with the test files —
190    // and any `coverage`-waived files — omitted from the denominator. Byte-code
191    // and the pytest cache are suppressed so the scanned tree stays pristine.
192    let mut command = Command::new("coverage");
193    command
194        .current_dir(root)
195        .args(["run", "--branch"])
196        .arg(format!("--omit={omit}"));
197    if include_all_sources {
198        command.arg("--source=.");
199    }
200    let run = command
201        .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
202        .env("COVERAGE_FILE", &data.0)
203        .env("PYTHONDONTWRITEBYTECODE", "1")
204        .output()
205        .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
206    if !run.status.success() {
207        bail!(
208            "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
209            root.display(),
210            String::from_utf8_lossy(&run.stdout),
211            String::from_utf8_lossy(&run.stderr),
212        );
213    }
214
215    // Emit the report to stdout and parse it.
216    let json = Command::new("coverage")
217        .current_dir(root)
218        .args(["json", "-o", "-"])
219        .env("COVERAGE_FILE", &data.0)
220        .output()
221        .context("running `coverage json`")?;
222    if !json.status.success() {
223        bail!(
224            "`coverage json` failed:\n{}",
225            String::from_utf8_lossy(&json.stderr),
226        );
227    }
228
229    parse_report(&String::from_utf8_lossy(&json.stdout))
230}
231
232/// The single comma-joined `--omit` value for the coverage run: always the test
233/// glob `*_test.py` and the support glob `*conftest.py`, plus every
234/// `coverage`-exempt path from config. (coverage.py takes one `--omit` — repeated
235/// flags don't accumulate, so the patterns must be joined.) An exempt file leaves
236/// the denominator with its reason recorded in config — an auditable omission, not
237/// a silent ignore-glob.
238fn build_omit(omit: &[String]) -> String {
239    [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
240        .into_iter()
241        .chain(omit.iter().cloned())
242        .collect::<Vec<_>>()
243        .join(",")
244}
245
246// ---------------------------------------------------------------------------
247// TypeScript (vitest) — issue #31.
248//
249// The TypeScript twin of the Python rule above. vitest reports four independent
250// metrics rather than Python's single total-plus-branch, so it carries its own
251// thresholds, report shape, and evaluate/measure pair; only `Outcome` is shared.
252// The split is the same: a pure `evaluate_typescript` over a parsed json-summary
253// report, and a thin `measure_typescript` that shells out to vitest to produce
254// one — so the enforcement core is testable without a Node toolchain.
255// ---------------------------------------------------------------------------
256
257/// What vitest measures: every TypeScript source under the scanned root. The
258/// braces are a vitest (picomatch) glob, expanded by vitest, not the shell.
259const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
260/// Always excluded from the denominator: the colocated unit tests are the suite,
261/// never a subject of it (`*.test.*`), and declaration files carry no runtime
262/// code (`*.d.ts` / `*.d.mts` / `*.d.cts`).
263const TS_TEST_EXCLUDE: &str = "**/*.test.*";
264const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
265
266/// The four vitest coverage floors, from a `[typescript].coverage` table. Each
267/// is an independent percent the unit suite must meet — vitest measures all four.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct TypeScriptThresholds {
270    pub lines: u8,
271    pub branches: u8,
272    pub functions: u8,
273    pub statements: u8,
274}
275
276/// A vitest `coverage-summary.json` report, pared to the `total` block the check
277/// needs. Per-file entries and unmodeled fields are ignored.
278#[derive(Debug, Clone, Copy, Deserialize)]
279pub struct VitestReport {
280    pub total: VitestTotals,
281}
282
283/// The `total` block of a vitest json-summary report — the four metrics this
284/// rule enforces. vitest also emits `branchesTrue`, which the check ignores.
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, pared to what the check
294/// needs: the covered percent and the denominator size.
295#[derive(Debug, Clone, Copy, Deserialize)]
296pub struct VitestMetric {
297    /// Percent covered — `None` when nothing was measured, which vitest writes as
298    /// the string `"Unknown"` (and `total` is then `0`).
299    #[serde(deserialize_with = "deserialize_pct")]
300    pub pct: Option<f64>,
301    /// Size of the denominator (statements/branches/functions/lines counted).
302    pub total: u64,
303}
304
305/// Deserialize a json-summary `pct`: a number for a measured metric (vitest
306/// emits whole percents as JSON integers and fractional ones as floats), or the
307/// string `"Unknown"` (→ `None`) when the denominator is empty.
308fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
309where
310    D: serde::Deserializer<'de>,
311{
312    struct PctVisitor;
313    impl serde::de::Visitor<'_> for PctVisitor {
314        type Value = Option<f64>;
315
316        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
317            f.write_str("a coverage percent number or the string \"Unknown\"")
318        }
319
320        fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
321            Ok(Some(value))
322        }
323
324        // serde_json hands a whole-number percent (e.g. `100`) to `visit_u64`;
325        // percents are never negative, so `visit_i64` is not needed.
326        fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
327            Ok(Some(value as f64))
328        }
329
330        // Any non-numeric percent (vitest writes the literal "Unknown") means the
331        // metric had nothing to measure.
332        fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
333            Ok(None)
334        }
335    }
336    deserializer.deserialize_any(PctVisitor)
337}
338
339/// Parse a vitest json-summary report (`coverage-summary.json`).
340pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
341    serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
342}
343
344/// Decide whether `report` meets every threshold in `thresholds`.
345///
346/// Fails when the run measured no code at all (an empty line denominator — a
347/// wrong path, or a suite that touched nothing — is never a silent pass),
348/// otherwise checks each of the four metrics and fails listing every one below
349/// its floor. A metric whose denominator is empty *amid* a non-empty run (e.g.
350/// branch-free code measured alongside real code) has nothing to miss and is
351/// vacuously satisfied.
352pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
353    let total = &report.total;
354    // Vacuous-run guard: every source file has lines, so a zero line-denominator
355    // means nothing was measured — a misconfigured run (wrong path, or every file
356    // excluded), failed rather than passed on an empty measurement.
357    if total.lines.total == 0 {
358        return Outcome::Fail(
359            "the unit suite measured no code — check the path and that the suite runs".to_string(),
360        );
361    }
362    let checks = [
363        ("lines", total.lines, thresholds.lines),
364        ("branches", total.branches, thresholds.branches),
365        ("functions", total.functions, thresholds.functions),
366        ("statements", total.statements, thresholds.statements),
367    ];
368    let mut shortfalls = Vec::new();
369    for (name, metric, required) in checks {
370        // A metric with an empty denominator (e.g. branch-free code) has nothing
371        // to cover and is vacuously full; a measured one compares its percent.
372        let actual = metric.pct.unwrap_or(100.0);
373        // A hair of tolerance so a percent that rounds to the floor isn't failed
374        // by float noise (matches the Python path).
375        if actual + 1e-9 < f64::from(required) {
376            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
377        }
378    }
379    if shortfalls.is_empty() {
380        Outcome::Pass
381    } else {
382        Outcome::Fail(format!(
383            "coverage below thresholds: {}",
384            shortfalls.join(", ")
385        ))
386    }
387}
388
389/// Run the unit suite under vitest coverage in `root` and check it against
390/// `thresholds`.
391///
392/// Shells out to `npx vitest run` with v8 coverage and the json-summary reporter,
393/// excluding `*.test.*`, declaration files, and every path in `exclude` from the
394/// denominator, then evaluates the report. `exclude` holds the `coverage`-rule
395/// exemptions resolved from config, as `root`-relative paths. `npx` resolves the
396/// project-local `vitest`, so it and `@vitest/coverage-v8` must be installed
397/// under `root`.
398pub fn measure_typescript(
399    root: &Path,
400    thresholds: TypeScriptThresholds,
401    exclude: &[String],
402) -> Result<Outcome> {
403    let report = run_vitest(root, exclude)?;
404    Ok(evaluate_typescript(&report, thresholds))
405}
406
407/// A vitest reports directory under the temp dir — unique per call (so checks
408/// running in parallel don't collide) and removed on drop (so the report never
409/// leaks into the scanned tree). vitest writes `coverage-summary.json` here.
410struct ReportDir(PathBuf);
411
412impl ReportDir {
413    fn new() -> Self {
414        static COUNTER: AtomicU64 = AtomicU64::new(0);
415        let name = format!(
416            "testing-conventions-vitest-{}-{}",
417            std::process::id(),
418            COUNTER.fetch_add(1, Ordering::Relaxed),
419        );
420        ReportDir(std::env::temp_dir().join(name))
421    }
422}
423
424impl Drop for ReportDir {
425    fn drop(&mut self) {
426        let _ = std::fs::remove_dir_all(&self.0);
427    }
428}
429
430/// Run vitest over the unit suite in `root` and return the parsed floor report.
431fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
432    let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
433    parse_vitest_report(&json)
434}
435
436/// Run vitest coverage over the unit suite in `root` and return the raw contents
437/// of the `report_file` the `reporter` wrote. Shared by the floor (#31, the
438/// `json-summary` → `coverage-summary.json` pair) and patch coverage (#135, the
439/// detailed `json` → `coverage-final.json` Istanbul pair) — the two differ only in
440/// the reporter and how they parse it.
441///
442/// v8 coverage is written to an out-of-tree temp dir so the scanned tree stays
443/// pristine. `include` scopes measurement to the sources under `root`; the test
444/// glob, declaration files, and the config `exclude` paths are excluded from the
445/// denominator. `all=true` counts source files the suite never imported, so an
446/// untested file is measured (lowering the floor / showing as uncovered) rather
447/// than vanishing. `--no-cache` keeps vitest from writing a cache into the tree.
448fn run_vitest_coverage(
449    root: &Path,
450    exclude: &[String],
451    reporter: &str,
452    report_file: &str,
453) -> Result<String> {
454    let reports = ReportDir::new();
455
456    let mut command = Command::new("npx");
457    command
458        .current_dir(root)
459        .args(["--yes", "vitest", "run", "--no-cache"])
460        .args(["--coverage.enabled", "--coverage.provider=v8"])
461        .arg(format!("--coverage.reporter={reporter}"))
462        .arg("--coverage.all=true")
463        .arg(format!(
464            "--coverage.reportsDirectory={}",
465            reports.0.display()
466        ))
467        .arg(format!("--coverage.include={TS_INCLUDE}"))
468        .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
469        .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
470    for path in exclude {
471        command.arg(format!("--coverage.exclude={path}"));
472    }
473    // CI=1 keeps vitest non-interactive (no watch prompt, plain output).
474    let run = command.env("CI", "1").output().context(
475        "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
476    )?;
477    if !run.status.success() {
478        bail!(
479            "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
480            root.display(),
481            String::from_utf8_lossy(&run.stdout),
482            String::from_utf8_lossy(&run.stderr),
483        );
484    }
485
486    let path = reports.0.join(report_file);
487    std::fs::read_to_string(&path).with_context(|| {
488        format!(
489            "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
490            path.display()
491        )
492    })
493}
494
495// ---------------------------------------------------------------------------
496// TypeScript patch (changed-line) coverage — issue #135.
497//
498// What patch coverage (`crate::patch_coverage::check_typescript`) reads: the set
499// of uncovered lines per file. vitest's `json-summary` gives only per-file totals,
500// so this measures with the detailed `json` (Istanbul `coverage-final.json`)
501// reporter and reduces it to the lines a changed line must avoid — the v8 twin of
502// coverage.py's `missing_lines` / `missing_branches`.
503// ---------------------------------------------------------------------------
504
505/// Run the TypeScript unit suite under vitest in `root` and return the uncovered
506/// lines per file — keyed by the absolute path vitest reports, the caller
507/// re-keying to `root`-relative to match the diff. A line is uncovered when it
508/// carries a statement the suite never executed, or the source of a branch a path
509/// of which the suite never took (the v8 analogue of the Python arm's missing line
510/// / missing branch). `exclude` is the `coverage`-rule exemptions, dropped from the
511/// run so an exempt file's changed lines are lifted. `npx` resolves the
512/// project-local `vitest`, so it and `@vitest/coverage-v8` must be installed under
513/// `root`.
514pub fn measure_patch_typescript(
515    root: &Path,
516    exclude: &[String],
517) -> Result<BTreeMap<String, BTreeSet<u64>>> {
518    let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
519    uncovered_istanbul_lines(&json)
520}
521
522/// One file's entry in a vitest v8 `coverage-final.json` (Istanbul) report, pared
523/// to what patch coverage reads: the statement / branch maps and their hit counts.
524/// Unmodeled fields (`path`, `fnMap`/`f`, per-node metadata) are ignored.
525#[derive(Debug, Clone, Deserialize)]
526struct IstanbulFile {
527    /// Statement id → source span. A statement whose hit count in `s` is `0` was
528    /// never executed, so its lines are uncovered.
529    #[serde(rename = "statementMap", default)]
530    statement_map: BTreeMap<String, IstanbulSpan>,
531    /// Statement id → execution count.
532    #[serde(default)]
533    s: BTreeMap<String, u64>,
534    /// Branch id → branch location. A branch with a `0` among its `b` counts had a
535    /// path the suite never took, so its source line is uncovered.
536    #[serde(rename = "branchMap", default)]
537    branch_map: BTreeMap<String, IstanbulBranch>,
538    /// Branch id → per-path execution counts.
539    #[serde(default)]
540    b: BTreeMap<String, Vec<u64>>,
541}
542
543/// A source span — only the 1-based line numbers matter to patch coverage.
544#[derive(Debug, Clone, Deserialize)]
545struct IstanbulSpan {
546    start: IstanbulPos,
547    end: IstanbulPos,
548}
549
550/// A position in a source span; the `column` is ignored.
551#[derive(Debug, Clone, Deserialize)]
552struct IstanbulPos {
553    line: u64,
554}
555
556/// A branch entry — only its location (whose start line is the branch's source
557/// line) matters; the `type` and per-path `locations` are ignored.
558#[derive(Debug, Clone, Deserialize)]
559struct IstanbulBranch {
560    loc: IstanbulSpan,
561}
562
563/// Pure: every uncovered line per file from a vitest v8 `coverage-final.json`
564/// (Istanbul) report — a statement the suite never ran (every line it spans) and
565/// the source line of a branch a path of which it never took. Keyed by the path
566/// vitest reports (absolute). A file present but fully covered maps to an empty
567/// set. Mirrors [`crate::patch_coverage::uncovered_changed_lines`]'s Python rule
568/// (missing line ∪ missing-branch source) for the v8 shape.
569fn uncovered_istanbul_lines(json: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
570    let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
571        .context("parsing vitest coverage-final (Istanbul) JSON report")?;
572    let mut out = BTreeMap::new();
573    for (path, file) in files {
574        let mut lines = BTreeSet::new();
575        // A statement never executed (`s[id] == 0`) — every line it spans is
576        // uncovered (a single-line statement spans one line).
577        for (id, span) in &file.statement_map {
578            if file.s.get(id) == Some(&0) {
579                lines.extend(span.start.line..=span.end.line);
580            }
581        }
582        // A branch with an untaken path (a `0` among its counts) — its source line
583        // (the location's start) is uncovered, even when the line itself ran.
584        for (id, branch) in &file.branch_map {
585            if file.b.get(id).is_some_and(|counts| counts.contains(&0)) {
586                lines.insert(branch.loc.start.line);
587            }
588        }
589        out.insert(path, lines);
590    }
591    Ok(out)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
599        CoverageReport {
600            totals: Totals {
601                percent_covered,
602                num_branches,
603            },
604            files: BTreeMap::new(),
605        }
606    }
607
608    #[test]
609    fn passes_when_total_meets_the_floor() {
610        assert_eq!(
611            evaluate(
612                &report(100.0, 12),
613                Thresholds {
614                    fail_under: 100,
615                    branch: true
616                }
617            ),
618            Outcome::Pass
619        );
620    }
621
622    #[test]
623    fn fails_when_total_is_below_the_floor() {
624        assert!(matches!(
625            evaluate(
626                &report(80.0, 12),
627                Thresholds {
628                    fail_under: 100,
629                    branch: true
630                }
631            ),
632            Outcome::Fail(_)
633        ));
634    }
635
636    #[test]
637    fn fails_when_branch_required_but_unmeasured() {
638        // branch=true but the report measured no branches → a misconfigured run.
639        assert!(matches!(
640            evaluate(
641                &report(100.0, 0),
642                Thresholds {
643                    fail_under: 90,
644                    branch: true
645                }
646            ),
647            Outcome::Fail(_)
648        ));
649    }
650
651    #[test]
652    fn parses_a_coverage_py_report() {
653        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
654        let report = parse_report(json).expect("valid coverage.py json");
655        assert_eq!(report.totals.percent_covered, 91.5);
656        assert_eq!(report.totals.num_branches, 8);
657    }
658
659    #[test]
660    fn parses_the_per_file_block_for_patch_coverage() {
661        // A realistic `coverage json` shape: a `files` map carrying the per-file
662        // missing lines and `[src, dst]` branch pairs patch coverage (#132) reads.
663        let json = r#"{
664            "files": {
665                "widget.py": {
666                    "executed_lines": [1, 2, 3, 4, 6],
667                    "summary": {"percent_covered": 85.0},
668                    "missing_lines": [5],
669                    "excluded_lines": [],
670                    "missing_branches": [[4, 5]]
671                }
672            },
673            "totals": {"percent_covered": 85.0, "num_branches": 4}
674        }"#;
675        let report = parse_report(json).expect("valid coverage.py json with files");
676        let widget = report.files.get("widget.py").expect("widget.py is present");
677        assert_eq!(widget.missing_lines, vec![5]);
678        assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
679        // The floor still reads totals from the same report.
680        assert_eq!(report.totals.percent_covered, 85.0);
681    }
682
683    #[test]
684    fn a_report_without_a_files_block_parses_with_an_empty_map() {
685        // The floor path parses totals only; `files` defaults to empty.
686        let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
687            .expect("valid coverage.py json");
688        assert!(report.files.is_empty());
689    }
690
691    #[test]
692    fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
693        assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
694    }
695
696    #[test]
697    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
698        // The caller passes already-resolved, sorted, `root`-relative paths.
699        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
700        assert_eq!(
701            build_omit(&exempt),
702            "*_test.py,*conftest.py,pkg/gen.py,shim.py"
703        );
704    }
705
706    // --- TypeScript (vitest) — issue #31 ---
707
708    fn metric(pct: f64) -> VitestMetric {
709        VitestMetric {
710            pct: Some(pct),
711            total: 10,
712        }
713    }
714
715    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
716        VitestReport {
717            total: VitestTotals {
718                lines: metric(lines),
719                branches: metric(branches),
720                functions: metric(functions),
721                statements: metric(statements),
722            },
723        }
724    }
725
726    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
727        lines: 100,
728        branches: 100,
729        functions: 100,
730        statements: 100,
731    };
732    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
733        lines: 80,
734        branches: 75,
735        functions: 80,
736        statements: 80,
737    };
738
739    #[test]
740    fn typescript_passes_when_every_metric_meets_its_floor() {
741        assert_eq!(
742            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
743            Outcome::Pass
744        );
745    }
746
747    #[test]
748    fn typescript_fails_on_the_one_metric_below_its_floor() {
749        // 100% lines but only 66.66% branches (the `below` fixture's shape): the
750        // branch floor catches what line coverage misses — and only `branches` is
751        // named as a shortfall, not the metrics that met their floor.
752        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
753        assert!(
754            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
755            "got: {outcome:?}"
756        );
757    }
758
759    #[test]
760    fn typescript_fail_message_names_every_metric_below() {
761        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
762        assert!(
763            matches!(&outcome, Outcome::Fail(message)
764                if message.contains("lines")
765                    && message.contains("branches")
766                    && message.contains("functions")
767                    && message.contains("statements")),
768            "got: {outcome:?}"
769        );
770    }
771
772    #[test]
773    fn typescript_tolerates_float_noise_at_the_floor() {
774        // A percent a hair under the floor from rounding still passes.
775        assert_eq!(
776            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
777            Outcome::Pass
778        );
779    }
780
781    #[test]
782    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
783        // Branch-free code measured alongside real code: branches has nothing to
784        // cover (pct "Unknown") but lines/etc. are real and pass → overall pass.
785        let report = VitestReport {
786            total: VitestTotals {
787                lines: metric(100.0),
788                branches: VitestMetric {
789                    pct: None,
790                    total: 0,
791                },
792                functions: metric(100.0),
793                statements: metric(100.0),
794            },
795        };
796        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
797    }
798
799    #[test]
800    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
801        // No lines in the denominator (everything excluded, or a wrong path): a
802        // vacuous run is a failure, never a silent pass.
803        let nothing = VitestMetric {
804            pct: None,
805            total: 0,
806        };
807        let report = VitestReport {
808            total: VitestTotals {
809                lines: nothing,
810                branches: nothing,
811                functions: nothing,
812                statements: nothing,
813            },
814        };
815        let outcome = evaluate_typescript(&report, TS_MID);
816        assert!(
817            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
818            "got: {outcome:?}"
819        );
820    }
821
822    #[test]
823    fn parses_a_vitest_summary_report() {
824        // A realistic `coverage-summary.json`: the four metrics plus the
825        // `branchesTrue` block and a per-file entry the check ignores.
826        let json = r#"{
827            "total": {
828                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
829                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
830                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
831                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
832                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
833            },
834            "/abs/widget.ts": {
835                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
836            }
837        }"#;
838        let report = parse_vitest_report(json).expect("valid vitest json-summary");
839        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
840        assert_eq!(report.total.lines.pct, Some(80.0));
841        assert_eq!(report.total.branches.pct, Some(66.66));
842        assert_eq!(report.total.functions.total, 2);
843    }
844
845    #[test]
846    fn parses_an_unknown_pct_as_unmeasured() {
847        let json = r#"{"total": {
848            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
849            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
850            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
851            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
852        }}"#;
853        let report = parse_vitest_report(json).expect("valid vitest json-summary");
854        assert_eq!(report.total.lines.pct, None);
855        assert_eq!(report.total.lines.total, 0);
856    }
857
858    #[test]
859    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
860        // vitest only ever writes a number or "Unknown"; anything else (here a
861        // bool) is a malformed report, surfaced as an error rather than guessed.
862        let json = r#"{"total":{
863            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
864            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
865            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
866            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
867        }}"#;
868        assert!(parse_vitest_report(json).is_err());
869    }
870
871    // --- TypeScript patch coverage (Istanbul `coverage-final.json`) — issue #135 ---
872
873    #[test]
874    fn istanbul_flags_an_unexecuted_statement() {
875        // s1 (line 2) never ran → line 2 is uncovered; s0 (line 1) ran → not.
876        let json = r#"{"/abs/widget.ts":{
877            "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}},
878                            "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":20}}},
879            "s":{"0":1,"1":0},
880            "branchMap":{},"b":{}
881        }}"#;
882        let out = uncovered_istanbul_lines(json).unwrap();
883        assert_eq!(out["/abs/widget.ts"], BTreeSet::from([2]));
884    }
885
886    #[test]
887    fn istanbul_flags_an_untaken_branch_source() {
888        // The branch out of line 3 has an untaken path (`[4, 0]`) → line 3 is
889        // uncovered, even though its statement ran.
890        let json = r#"{"/abs/widget.ts":{
891            "statementMap":{"0":{"start":{"line":3,"column":2},"end":{"line":3,"column":20}}},
892            "s":{"0":5},
893            "branchMap":{"0":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":40}}}},
894            "b":{"0":[4,0]}
895        }}"#;
896        let out = uncovered_istanbul_lines(json).unwrap();
897        assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3]));
898    }
899
900    #[test]
901    fn istanbul_v8_single_arm_branch_counts_as_uncovered() {
902        // vitest's v8 provider models each branch arm as its own entry with a
903        // single-element count array; `[0]` is an arm the suite never took.
904        let json = r#"{"/abs/widget.ts":{
905            "statementMap":{},"s":{},
906            "branchMap":{"0":{"loc":{"start":{"line":7,"column":0},"end":{"line":7,"column":3}}}},
907            "b":{"0":[0]}
908        }}"#;
909        let out = uncovered_istanbul_lines(json).unwrap();
910        assert_eq!(out["/abs/widget.ts"], BTreeSet::from([7]));
911    }
912
913    #[test]
914    fn istanbul_spans_every_line_of_an_unexecuted_multiline_statement() {
915        // A statement that never ran and spans lines 4-6 marks all three.
916        let json = r#"{"/abs/widget.ts":{
917            "statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":6,"column":3}}},
918            "s":{"0":0},
919            "branchMap":{},"b":{}
920        }}"#;
921        let out = uncovered_istanbul_lines(json).unwrap();
922        assert_eq!(out["/abs/widget.ts"], BTreeSet::from([4, 5, 6]));
923    }
924
925    #[test]
926    fn istanbul_fully_covered_file_has_no_uncovered_lines() {
927        let json = r#"{"/abs/widget.ts":{
928            "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}},
929            "s":{"0":3},
930            "branchMap":{"0":{"loc":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}}},
931            "b":{"0":[2,1]}
932        }}"#;
933        let out = uncovered_istanbul_lines(json).unwrap();
934        assert!(out["/abs/widget.ts"].is_empty());
935    }
936
937    #[test]
938    fn istanbul_widget_report_flags_statement_and_branch_lines() {
939        // The realistic shape vitest emits for the `if (n === 42) { return 'answer';
940        // }` red fixture: lines 4-5 are unexecuted statements and line 3 is an
941        // untaken branch source → {3, 4, 5}.
942        let json = r#"{"/abs/widget.ts":{
943            "statementMap":{
944                "0":{"start":{"line":1,"column":0},"end":{"line":1,"column":43}},
945                "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}},
946                "2":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}},
947                "3":{"start":{"line":4,"column":4},"end":{"line":4,"column":20}},
948                "4":{"start":{"line":5,"column":2},"end":{"line":5,"column":3}},
949                "5":{"start":{"line":6,"column":2},"end":{"line":6,"column":15}}
950            },
951            "s":{"0":1,"1":2,"2":2,"3":0,"4":0,"5":1},
952            "branchMap":{
953                "0":{"loc":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}}},
954                "1":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}}}
955            },
956            "b":{"0":[2],"1":[0]}
957        }}"#;
958        let out = uncovered_istanbul_lines(json).unwrap();
959        assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3, 4, 5]));
960    }
961
962    #[test]
963    fn istanbul_malformed_json_is_an_error() {
964        assert!(uncovered_istanbul_lines("{ not json").is_err());
965    }
966}