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;
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    /// The json-summary file vitest writes under this directory.
494    fn summary(&self) -> PathBuf {
495        self.0.join("coverage-summary.json")
496    }
497}
498
499impl Drop for ReportDir {
500    fn drop(&mut self) {
501        let _ = std::fs::remove_dir_all(&self.0);
502    }
503}
504
505/// Run vitest over the unit suite in `root` and return the parsed report.
506fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
507    let reports = ReportDir::new();
508
509    // v8 coverage with the json-summary reporter, written to an out-of-tree temp
510    // dir so the scanned tree stays pristine. `include` scopes measurement to the
511    // sources under `root`; the test glob, declaration files, and the config
512    // exemptions are excluded from the denominator. `all=true` counts source files
513    // the suite never imported, so an untested file lowers coverage rather than
514    // vanishing. `--no-cache` keeps vitest from writing a cache into the tree.
515    let mut command = Command::new("npx");
516    command
517        .current_dir(root)
518        .args(["--yes", "vitest", "run", "--no-cache"])
519        .args([
520            "--coverage.enabled",
521            "--coverage.provider=v8",
522            "--coverage.reporter=json-summary",
523            "--coverage.all=true",
524        ])
525        .arg(format!(
526            "--coverage.reportsDirectory={}",
527            reports.0.display()
528        ))
529        .arg(format!("--coverage.include={TS_INCLUDE}"))
530        .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
531        .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
532    for path in exclude {
533        command.arg(format!("--coverage.exclude={path}"));
534    }
535    // CI=1 keeps vitest non-interactive (no watch prompt, plain output).
536    let run = command.env("CI", "1").output().context(
537        "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
538    )?;
539    if !run.status.success() {
540        bail!(
541            "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
542            root.display(),
543            String::from_utf8_lossy(&run.stdout),
544            String::from_utf8_lossy(&run.stderr),
545        );
546    }
547
548    let summary = reports.summary();
549    let json = std::fs::read_to_string(&summary).with_context(|| {
550        format!(
551            "reading vitest coverage summary `{}` (did the run produce a json-summary report?)",
552            summary.display()
553        )
554    })?;
555    parse_vitest_report(&json)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
563        CoverageReport {
564            totals: Totals {
565                percent_covered,
566                num_branches,
567            },
568            files: BTreeMap::new(),
569        }
570    }
571
572    #[test]
573    fn passes_when_total_meets_the_floor() {
574        assert_eq!(
575            evaluate(
576                &report(100.0, 12),
577                Thresholds {
578                    fail_under: 100,
579                    branch: true
580                }
581            ),
582            Outcome::Pass
583        );
584    }
585
586    #[test]
587    fn fails_when_total_is_below_the_floor() {
588        assert!(matches!(
589            evaluate(
590                &report(80.0, 12),
591                Thresholds {
592                    fail_under: 100,
593                    branch: true
594                }
595            ),
596            Outcome::Fail(_)
597        ));
598    }
599
600    #[test]
601    fn fails_when_branch_required_but_unmeasured() {
602        // branch=true but the report measured no branches → a misconfigured run.
603        assert!(matches!(
604            evaluate(
605                &report(100.0, 0),
606                Thresholds {
607                    fail_under: 90,
608                    branch: true
609                }
610            ),
611            Outcome::Fail(_)
612        ));
613    }
614
615    #[test]
616    fn parses_a_coverage_py_report() {
617        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
618        let report = parse_report(json).expect("valid coverage.py json");
619        assert_eq!(report.totals.percent_covered, 91.5);
620        assert_eq!(report.totals.num_branches, 8);
621    }
622
623    #[test]
624    fn parses_the_per_file_block_for_patch_coverage() {
625        // A realistic `coverage json` shape: a `files` map carrying the per-file
626        // missing lines and `[src, dst]` branch pairs patch coverage (#132) reads.
627        let json = r#"{
628            "files": {
629                "widget.py": {
630                    "executed_lines": [1, 2, 3, 4, 6],
631                    "summary": {"percent_covered": 85.0},
632                    "missing_lines": [5],
633                    "excluded_lines": [],
634                    "missing_branches": [[4, 5]]
635                }
636            },
637            "totals": {"percent_covered": 85.0, "num_branches": 4}
638        }"#;
639        let report = parse_report(json).expect("valid coverage.py json with files");
640        let widget = report.files.get("widget.py").expect("widget.py is present");
641        assert_eq!(widget.missing_lines, vec![5]);
642        assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
643        // The floor still reads totals from the same report.
644        assert_eq!(report.totals.percent_covered, 85.0);
645    }
646
647    #[test]
648    fn a_report_without_a_files_block_parses_with_an_empty_map() {
649        // The floor/ratchet path parses totals only; `files` defaults to empty.
650        let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
651            .expect("valid coverage.py json");
652        assert!(report.files.is_empty());
653    }
654
655    #[test]
656    fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
657        assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
658    }
659
660    #[test]
661    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
662        // The caller passes already-resolved, sorted, `root`-relative paths.
663        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
664        assert_eq!(
665            build_omit(&exempt),
666            "*_test.py,*conftest.py,pkg/gen.py,shim.py"
667        );
668    }
669
670    // --- Non-regression ratchet (#131) ---
671
672    #[test]
673    fn ratchet_passes_when_coverage_holds_at_the_baseline() {
674        assert_eq!(evaluate_ratchet(100.0, Some(100.0)), Outcome::Pass);
675    }
676
677    #[test]
678    fn ratchet_passes_when_coverage_improves_over_the_baseline() {
679        assert_eq!(evaluate_ratchet(92.0, Some(85.0)), Outcome::Pass);
680    }
681
682    #[test]
683    fn ratchet_fails_on_a_drop_below_the_baseline() {
684        assert!(matches!(
685            evaluate_ratchet(86.0, Some(90.0)),
686            Outcome::Fail(message) if message.contains("regressed") && message.contains("90")
687        ));
688    }
689
690    #[test]
691    fn ratchet_is_vacuous_without_a_recorded_baseline() {
692        assert_eq!(evaluate_ratchet(10.0, None), Outcome::Pass);
693    }
694
695    #[test]
696    fn ratchet_tolerates_float_noise_at_the_baseline() {
697        assert_eq!(evaluate_ratchet(99.999_999_999, Some(100.0)), Outcome::Pass);
698    }
699
700    static BASELINE_COUNTER: AtomicU64 = AtomicU64::new(0);
701
702    /// A throwaway directory under the temp dir, removed on drop — for the
703    /// `read_baseline` file cases.
704    struct TempDir(PathBuf);
705
706    impl TempDir {
707        fn new() -> Self {
708            let dir = std::env::temp_dir().join(format!(
709                "tc-baseline-{}-{}",
710                std::process::id(),
711                BASELINE_COUNTER.fetch_add(1, Ordering::Relaxed),
712            ));
713            std::fs::create_dir_all(&dir).unwrap();
714            TempDir(dir)
715        }
716    }
717
718    impl Drop for TempDir {
719        fn drop(&mut self) {
720            let _ = std::fs::remove_dir_all(&self.0);
721        }
722    }
723
724    #[test]
725    fn read_baseline_is_none_when_the_file_is_absent() {
726        let dir = TempDir::new();
727        assert!(read_baseline(&dir.0).unwrap().is_none());
728    }
729
730    #[test]
731    fn read_baseline_parses_the_recorded_python_total() {
732        let dir = TempDir::new();
733        std::fs::write(
734            dir.0.join(BASELINE_PATH),
735            r#"{"python":{"percent_covered":91.5}}"#,
736        )
737        .unwrap();
738        let baseline = read_baseline(&dir.0)
739            .unwrap()
740            .expect("a baseline file is present");
741        assert_eq!(baseline.python.unwrap().percent_covered, 91.5);
742    }
743
744    #[test]
745    fn read_baseline_errors_on_a_malformed_file() {
746        let dir = TempDir::new();
747        std::fs::write(dir.0.join(BASELINE_PATH), "{ not json").unwrap();
748        assert!(read_baseline(&dir.0).is_err());
749    }
750
751    // --- TypeScript (vitest) — issue #31 ---
752
753    fn metric(pct: f64) -> VitestMetric {
754        VitestMetric {
755            pct: Some(pct),
756            total: 10,
757        }
758    }
759
760    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
761        VitestReport {
762            total: VitestTotals {
763                lines: metric(lines),
764                branches: metric(branches),
765                functions: metric(functions),
766                statements: metric(statements),
767            },
768        }
769    }
770
771    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
772        lines: 100,
773        branches: 100,
774        functions: 100,
775        statements: 100,
776    };
777    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
778        lines: 80,
779        branches: 75,
780        functions: 80,
781        statements: 80,
782    };
783
784    #[test]
785    fn typescript_passes_when_every_metric_meets_its_floor() {
786        assert_eq!(
787            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
788            Outcome::Pass
789        );
790    }
791
792    #[test]
793    fn typescript_fails_on_the_one_metric_below_its_floor() {
794        // 100% lines but only 66.66% branches (the `below` fixture's shape): the
795        // branch floor catches what line coverage misses — and only `branches` is
796        // named as a shortfall, not the metrics that met their floor.
797        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
798        assert!(
799            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
800            "got: {outcome:?}"
801        );
802    }
803
804    #[test]
805    fn typescript_fail_message_names_every_metric_below() {
806        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
807        assert!(
808            matches!(&outcome, Outcome::Fail(message)
809                if message.contains("lines")
810                    && message.contains("branches")
811                    && message.contains("functions")
812                    && message.contains("statements")),
813            "got: {outcome:?}"
814        );
815    }
816
817    #[test]
818    fn typescript_tolerates_float_noise_at_the_floor() {
819        // A percent a hair under the floor from rounding still passes.
820        assert_eq!(
821            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
822            Outcome::Pass
823        );
824    }
825
826    #[test]
827    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
828        // Branch-free code measured alongside real code: branches has nothing to
829        // cover (pct "Unknown") but lines/etc. are real and pass → overall pass.
830        let report = VitestReport {
831            total: VitestTotals {
832                lines: metric(100.0),
833                branches: VitestMetric {
834                    pct: None,
835                    total: 0,
836                },
837                functions: metric(100.0),
838                statements: metric(100.0),
839            },
840        };
841        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
842    }
843
844    #[test]
845    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
846        // No lines in the denominator (everything excluded, or a wrong path): a
847        // vacuous run is a failure, never a silent pass.
848        let nothing = VitestMetric {
849            pct: None,
850            total: 0,
851        };
852        let report = VitestReport {
853            total: VitestTotals {
854                lines: nothing,
855                branches: nothing,
856                functions: nothing,
857                statements: nothing,
858            },
859        };
860        let outcome = evaluate_typescript(&report, TS_MID);
861        assert!(
862            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
863            "got: {outcome:?}"
864        );
865    }
866
867    #[test]
868    fn parses_a_vitest_summary_report() {
869        // A realistic `coverage-summary.json`: the four metrics plus the
870        // `branchesTrue` block and a per-file entry the check ignores.
871        let json = r#"{
872            "total": {
873                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
874                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
875                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
876                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
877                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
878            },
879            "/abs/widget.ts": {
880                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
881            }
882        }"#;
883        let report = parse_vitest_report(json).expect("valid vitest json-summary");
884        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
885        assert_eq!(report.total.lines.pct, Some(80.0));
886        assert_eq!(report.total.branches.pct, Some(66.66));
887        assert_eq!(report.total.functions.total, 2);
888    }
889
890    #[test]
891    fn parses_an_unknown_pct_as_unmeasured() {
892        let json = r#"{"total": {
893            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
894            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
895            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
896            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
897        }}"#;
898        let report = parse_vitest_report(json).expect("valid vitest json-summary");
899        assert_eq!(report.total.lines.pct, None);
900        assert_eq!(report.total.lines.total, 0);
901    }
902
903    #[test]
904    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
905        // vitest only ever writes a number or "Unknown"; anything else (here a
906        // bool) is a malformed report, surfaced as an error rather than guessed.
907        let json = r#"{"total":{
908            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
909            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
910            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
911            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
912        }}"#;
913        assert!(parse_vitest_report(json).is_err());
914    }
915}