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