Skip to main content

testing_conventions/
patch_coverage.rs

1//! Diff-scoped coverage floor.
2//!
3//! Enforces the README Coverage rule over the lines a diff touches: where
4//! [`crate::coverage`] measures the *whole* suite against the configured floor,
5//! the `measure*` functions here measure that same floor over only the
6//! lines `<base>...HEAD` added or modified — `covered ÷ total-changed-executable`,
7//! against the thresholds `unit coverage` enforces whole-tree. `unit coverage
8//! --base` routes here, so a diff that clears the configured floor passes even with
9//! an uncovered changed line, and one below it fails no matter how small.
10//!
11//! Two inputs are combined:
12//!   - the **diff** — [`changed_lines`] runs `git diff --unified=0 <base>...HEAD`
13//!     and returns the new-side line numbers each file gained. This diff machinery
14//!     is language-agnostic, shared by all three arms.
15//!   - the **coverage** — per the language. Python ([`measure`]) reads coverage.py's
16//!     per-file lines and branch arcs ([`crate::coverage::measure_patch_report`]),
17//!     restricting the `percent_covered` ratio to the changed lines
18//!     ([`evaluate_patch`]). TypeScript ([`measure_typescript`]) reduces vitest's v8
19//!     export to the four per-metric counts
20//!     ([`crate::coverage::measure_patch_typescript_detail`]) and Rust
21//!     ([`measure_rust`]) reduces `cargo llvm-cov`'s export to the per-region counts
22//!     ([`crate::coverage::measure_patch_rust_detail`]); each metric's ratio is then
23//!     restricted to the changed lines ([`evaluate_patch_typescript`] /
24//!     [`evaluate_patch_rust`]). Either way, non-executable changed lines (comments,
25//!     blanks) and `coverage`-exempt files have nothing to cover and drop out of the
26//!     ratio.
27//!
28//! Relationship to the commit-scoped co-change rule ([`crate::co_change`]):
29//! co-change enforces that a changed source and its colocated *test* move
30//! together; the diff-scoped floor enforces that the changed *lines* are actually
31//! exercised. They are complementary, not overlapping — co-change can pass (the
32//! test file changed) while the floor fails (the change isn't covered), and
33//! vice versa.
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::path::Path;
37use std::process::Command;
38
39use anyhow::{bail, Context, Result};
40
41use crate::coverage::{
42    self, FileCoverage, Outcome, RustThresholds, Thresholds, TypeScriptThresholds,
43};
44
45/// TypeScript source extensions the diff-scoped floor scopes to — the set
46/// `coverage`'s `TS_INCLUDE` measures. A `.d.ts` declaration ends in `.ts` but
47/// carries no runtime code; vitest excludes it from the report, so its changed
48/// lines find nothing to cover and are skipped without a special case here.
49const TS_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"];
50
51/// Diff-scoped Python coverage floor: measure `thresholds` over the
52/// `<base>...HEAD` changed `.py` lines instead of the whole tree. `omit` is the
53/// `coverage`-rule exemptions, as in [`crate::coverage::measure`] — an exempt file
54/// is omitted from the run, so its changed lines drop out of the ratio.
55///
56/// Scopes to `.py` sources and returns early — with no coverage run — when the diff
57/// touches none, so a PR that changes only docs or other languages doesn't pay for a
58/// measurement (and is vacuously covered). Requires coverage.py + pytest + git; an
59/// unresolvable `base` surfaces as an error rather than a silent pass.
60pub fn measure(
61    root: &Path,
62    base: &str,
63    thresholds: Thresholds,
64    omit: &[String],
65    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
66) -> Result<Outcome> {
67    let mut changed = changed_lines(root, base)?;
68    changed.retain(|path, _| path.ends_with(".py"));
69    lift_exempt_lines(&mut changed, exempt_lines);
70    if changed.is_empty() {
71        return Ok(Outcome::Pass);
72    }
73    let report = coverage::measure_patch_report(root, omit)?;
74    let files = relative_keys(report.files, root);
75    Ok(evaluate_patch(&changed, &files, thresholds))
76}
77
78/// Drop the line-scoped `coverage` exemptions from a `--base` diff's changed-line
79/// set, so a changed line that is line-exempt is lifted from the diff floor — the
80/// counterpart to a whole-file exemption dropping the file. The over-exemption guard is
81/// the whole-tree floor's job ([`measure_line_exempt`], the `unit coverage` job the
82/// reusable workflow runs alongside `--base`); the diff job can't classify a line its
83/// diff didn't touch, so here the exempt lines are simply removed.
84fn lift_exempt_lines(
85    changed: &mut BTreeMap<String, BTreeSet<u64>>,
86    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
87) {
88    for (file, exempt) in exempt_lines {
89        if let Some(lines) = changed.get_mut(file) {
90            lines.retain(|&line| !u32::try_from(line).is_ok_and(|line| exempt.contains(&line)));
91        }
92    }
93}
94
95/// Pure: the configured floor measured over the changed lines. Reproduces
96/// coverage.py's `percent_covered` — (executed lines + taken branch arcs) ÷
97/// (executable lines + all branch arcs) — restricted to the lines the diff touched,
98/// so the same number `unit coverage` enforces whole-tree is judged on the diff.
99///
100/// A changed line absent from `files` (a comment or blank, a test file, or a
101/// `coverage`-exempt file omitted from the run) has nothing to cover and is skipped;
102/// when nothing executable changed, the diff is vacuously covered (`Pass`). With
103/// `branch`, a branch arc counts toward the ratio when its source line is in the diff
104/// — taken arcs as covered, untaken as missed — exactly as the whole-tree total folds
105/// branches in. No small-diff carve-out: a tiny diff below the floor fails like any
106/// other.
107fn evaluate_patch(
108    changed: &BTreeMap<String, BTreeSet<u64>>,
109    files: &BTreeMap<String, FileCoverage>,
110    thresholds: Thresholds,
111) -> Outcome {
112    let (covered, total) = python_ratio(changed, files, thresholds.branch);
113    if total == 0 {
114        return Outcome::Pass;
115    }
116    let actual = 100.0 * covered as f64 / total as f64;
117    // A hair of tolerance so a percent that rounds to the floor isn't failed by float
118    // noise (matches the whole-tree `coverage::evaluate`).
119    if actual + 1e-9 >= f64::from(thresholds.fail_under) {
120        Outcome::Pass
121    } else {
122        Outcome::Fail(format!(
123            "changed-line coverage {actual:.2}% is below the required {}%",
124            thresholds.fail_under
125        ))
126    }
127}
128
129/// Pure: coverage.py's `percent_covered` numerator/denominator restricted to the lines
130/// in `selected` — (executed lines + taken branch arcs) over (executable lines + all
131/// branch arcs), counting only the selected lines and the arcs whose source is selected.
132/// Shared by the diff-scoped floor ([`evaluate_patch`], where `selected` is the changed
133/// lines) and the line-scoped exemption floor ([`measure_line_exempt`], where it's the
134/// measured lines minus the exempt ones). Over *every* measured line it reproduces the
135/// whole-tree total exactly.
136fn python_ratio(
137    selected: &BTreeMap<String, BTreeSet<u64>>,
138    files: &BTreeMap<String, FileCoverage>,
139    branch: bool,
140) -> (u64, u64) {
141    let mut covered: u64 = 0;
142    let mut total: u64 = 0;
143    for (file, lines) in selected {
144        let Some(cov) = files.get(file) else {
145            continue;
146        };
147        let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
148        let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
149        for &line in lines {
150            if executed.contains(&line) {
151                covered += 1;
152                total += 1;
153            } else if missing.contains(&line) {
154                total += 1;
155            }
156        }
157        if branch {
158            for arc in &cov.executed_branches {
159                if arc_source_in(arc, lines) {
160                    covered += 1;
161                    total += 1;
162                }
163            }
164            for arc in &cov.missing_branches {
165                if arc_source_in(arc, lines) {
166                    total += 1;
167                }
168            }
169        }
170    }
171    (covered, total)
172}
173
174/// Whether a branch arc's source line (the first of its `[src, dst]` pair; `dst` may
175/// be a negative exit, which is irrelevant) falls in `lines`.
176fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
177    arc.first()
178        .and_then(|&src| u64::try_from(src).ok())
179        .is_some_and(|src| lines.contains(&src))
180}
181
182/// Diff-scoped TypeScript coverage floor: the four vitest metrics measured
183/// over the `<base>...HEAD` changed `.ts`/`.tsx`/`.mts`/`.cts` lines instead of the
184/// whole tree. `exclude` is the `coverage`-rule exemptions, as in
185/// [`crate::coverage::measure_typescript`] — an excluded file is left out of the
186/// run, so its changed lines drop out of the ratios.
187///
188/// Scopes to TypeScript sources and returns early — with no coverage run — when the
189/// diff touches none, so a PR that changes only docs or other languages doesn't pay
190/// for a measurement (and is vacuously covered). Requires vitest + git; an
191/// unresolvable `base` surfaces as an error rather than a silent pass.
192pub fn measure_typescript(
193    root: &Path,
194    base: &str,
195    thresholds: TypeScriptThresholds,
196    exclude: &[String],
197    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
198) -> Result<Outcome> {
199    let mut changed = changed_lines(root, base)?;
200    changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
201    lift_exempt_lines(&mut changed, exempt_lines);
202    if changed.is_empty() {
203        return Ok(Outcome::Pass);
204    }
205    let detail = relative_keys(
206        coverage::measure_patch_typescript_detail(root, exclude)?,
207        root,
208    );
209    Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
210}
211
212/// Pure: the four vitest floors measured over the changed lines. Each metric's
213/// ratio is restricted to the lines the diff touched, so the same numbers
214/// `unit coverage` enforces whole-tree are judged on the diff:
215///   - **statements**: a `statementMap` entry counts when any line in its
216///     `start..=end` is a changed line; covered when its flag is set.
217///   - **lines**: a changed line counts when ≥1 statement *starts* on it; covered
218///     when ≥1 statement starting on it is covered.
219///   - **branches**: a branch arm counts when its `source_line` is a changed line;
220///     covered when its flag is set.
221///   - **functions**: a function counts when its `decl_line` is a changed line;
222///     covered when its flag is set.
223///
224/// A changed file absent from `detail` (a test file, a declaration file, or a
225/// `coverage`-exempt file left out of the run) has nothing to cover and is skipped.
226/// Each metric's percent is `100 * covered / total`, or `100` when its denominator
227/// is empty — a diff-scoped empty denominator is **vacuously satisfied**, not the
228/// "measured no code" failure the whole-tree [`coverage::evaluate_typescript`]
229/// returns (a diff may legitimately touch no branches or functions). The fail
230/// message lists every metric below its floor, matching
231/// [`coverage::evaluate_typescript`]'s. No small-diff carve-out: a tiny diff below
232/// the floor fails like any other.
233fn evaluate_patch_typescript(
234    changed: &BTreeMap<String, BTreeSet<u64>>,
235    detail: &BTreeMap<String, coverage::TsPatchCoverage>,
236    thresholds: TypeScriptThresholds,
237) -> Outcome {
238    let (mut s_cov, mut s_tot) = (0u64, 0u64);
239    let (mut l_cov, mut l_tot) = (0u64, 0u64);
240    let (mut b_cov, mut b_tot) = (0u64, 0u64);
241    let (mut f_cov, mut f_tot) = (0u64, 0u64);
242
243    for (file, lines) in changed {
244        let Some(cov) = detail.get(file) else {
245            continue;
246        };
247
248        // Statements: count one whenever any line it spans was changed.
249        for &(start, end, covered) in &cov.statements {
250            if (start..=end).any(|line| lines.contains(&line)) {
251                s_tot += 1;
252                if covered {
253                    s_cov += 1;
254                }
255            }
256        }
257
258        // Lines: a changed line on which ≥1 statement *starts* counts; covered when
259        // ≥1 statement starting on it is covered.
260        for &line in lines {
261            let mut starts_here = false;
262            let mut covered_here = false;
263            for &(start, _end, covered) in &cov.statements {
264                if start == line {
265                    starts_here = true;
266                    covered_here |= covered;
267                }
268            }
269            if starts_here {
270                l_tot += 1;
271                if covered_here {
272                    l_cov += 1;
273                }
274            }
275        }
276
277        // Branch arms: count one whenever its source line was changed.
278        for &(source_line, covered) in &cov.branch_arms {
279            if lines.contains(&source_line) {
280                b_tot += 1;
281                if covered {
282                    b_cov += 1;
283                }
284            }
285        }
286
287        // Functions: count one whenever its declaration line was changed.
288        for &(decl_line, covered) in &cov.functions {
289            if lines.contains(&decl_line) {
290                f_tot += 1;
291                if covered {
292                    f_cov += 1;
293                }
294            }
295        }
296    }
297
298    // An empty denominator is vacuously full (100%) — a diff may touch no branch or
299    // function, which is satisfied, not the whole-tree "measured no code" failure.
300    let pct = |covered: u64, total: u64| {
301        if total == 0 {
302            100.0
303        } else {
304            100.0 * covered as f64 / total as f64
305        }
306    };
307    let checks = [
308        ("lines", pct(l_cov, l_tot), thresholds.lines),
309        ("branches", pct(b_cov, b_tot), thresholds.branches),
310        ("functions", pct(f_cov, f_tot), thresholds.functions),
311        ("statements", pct(s_cov, s_tot), thresholds.statements),
312    ];
313    let mut shortfalls = Vec::new();
314    for (name, actual, required) in checks {
315        // A hair of tolerance so a percent that rounds to the floor isn't failed by
316        // float noise (matches the whole-tree `coverage::evaluate_typescript`).
317        if actual + 1e-9 < f64::from(required) {
318            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
319        }
320    }
321    if shortfalls.is_empty() {
322        Outcome::Pass
323    } else {
324        Outcome::Fail(format!(
325            "coverage below thresholds: {}",
326            shortfalls.join(", ")
327        ))
328    }
329}
330
331/// Diff-scoped Rust coverage floor: the `cargo llvm-cov` regions/lines
332/// metrics measured over the `<base>...HEAD` changed `.rs` lines instead of the
333/// whole tree. `ignore` is the `coverage`-rule exemptions, as in
334/// [`crate::coverage::measure_rust`] — an exempt file is dropped from the run, so
335/// its changed lines drop out of the ratios.
336///
337/// Scopes to `.rs` sources and returns early — with no coverage run — when the diff
338/// touches none, so a PR that changes only docs or other languages doesn't pay for a
339/// measurement (and is vacuously covered). Requires `cargo-llvm-cov` + git; an
340/// unresolvable `base` surfaces as an error rather than a silent pass.
341pub fn measure_rust(
342    root: &Path,
343    base: &str,
344    thresholds: RustThresholds,
345    ignore: &[String],
346    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
347    features: &[String],
348) -> Result<Outcome> {
349    let mut changed = changed_lines(root, base)?;
350    changed.retain(|path, _| path.ends_with(".rs"));
351    lift_exempt_lines(&mut changed, exempt_lines);
352    if changed.is_empty() {
353        return Ok(Outcome::Pass);
354    }
355    let detail = relative_keys(
356        coverage::measure_patch_rust_detail(root, ignore, features)?,
357        root,
358    );
359    Ok(evaluate_patch_rust(&changed, &detail, thresholds))
360}
361
362/// Pure: the two `cargo llvm-cov` floors (regions, lines) measured over the changed
363/// lines. Each metric's ratio is restricted to the lines the diff touched, so the
364/// same numbers `unit coverage` enforces whole-tree are judged on the diff:
365///   - **regions**: a code region counts when any line in its `start..=end` is a
366///     changed line; covered when its flag is set.
367///   - **lines**: a changed line counts when ≥1 region covers it (`start <= line <=
368///     end`); covered when ≥1 covering region has its flag set.
369///
370/// A changed file absent from `detail` (a test-only file or a `coverage`-exempt file
371/// dropped from the run) has nothing to cover and is skipped. Each metric's percent
372/// is `100 * covered / total`, or `100` when its denominator is empty — a
373/// diff-scoped empty denominator is **vacuously satisfied**, not the "measured no
374/// code" failure the whole-tree [`coverage::evaluate_rust`] returns (a diff may
375/// legitimately touch no measured region). The fail message lists every metric below
376/// its floor, matching [`coverage::evaluate_rust`]'s. No small-diff carve-out: a tiny
377/// diff below the floor fails like any other.
378fn evaluate_patch_rust(
379    changed: &BTreeMap<String, BTreeSet<u64>>,
380    detail: &BTreeMap<String, coverage::RustPatchCoverage>,
381    thresholds: RustThresholds,
382) -> Outcome {
383    let (mut r_cov, mut r_tot) = (0u64, 0u64);
384    let (mut l_cov, mut l_tot) = (0u64, 0u64);
385
386    for (file, lines) in changed {
387        let Some(cov) = detail.get(file) else {
388            continue;
389        };
390
391        // Regions: count one whenever any line it spans was changed.
392        for &(start, end, covered) in &cov.regions {
393            if (start..=end).any(|line| lines.contains(&line)) {
394                r_tot += 1;
395                if covered {
396                    r_cov += 1;
397                }
398            }
399        }
400
401        // Lines: a changed line covered by ≥1 region counts; covered when ≥1 region
402        // covering it has its flag set.
403        for &line in lines {
404            let mut measured = false;
405            let mut covered_here = false;
406            for &(start, end, covered) in &cov.regions {
407                if start <= line && line <= end {
408                    measured = true;
409                    covered_here |= covered;
410                }
411            }
412            if measured {
413                l_tot += 1;
414                if covered_here {
415                    l_cov += 1;
416                }
417            }
418        }
419    }
420
421    // An empty denominator is vacuously full (100%) — a diff may touch no measured
422    // region, which is satisfied, not the whole-tree "measured no code" failure.
423    let pct = |covered: u64, total: u64| {
424        if total == 0 {
425            100.0
426        } else {
427            100.0 * covered as f64 / total as f64
428        }
429    };
430    // `regions` is opt-in: skip the region check unless a config set a floor,
431    // matching the whole-tree `coverage::evaluate_rust`.
432    let mut checks: Vec<(&str, f64, u8)> = Vec::new();
433    if let Some(regions) = thresholds.regions {
434        checks.push(("regions", pct(r_cov, r_tot), regions));
435    }
436    checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
437    let mut shortfalls = Vec::new();
438    for (name, actual, required) in checks {
439        // A hair of tolerance so a percent that rounds to the floor isn't failed by
440        // float noise (matches the whole-tree `coverage::evaluate_rust`).
441        if actual + 1e-9 < f64::from(required) {
442            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
443        }
444    }
445    if shortfalls.is_empty() {
446        Outcome::Pass
447    } else {
448        Outcome::Fail(format!(
449            "coverage below thresholds: {}",
450            shortfalls.join(", ")
451        ))
452    }
453}
454
455/// The new-side lines each file gained in `repo`'s `<base>...HEAD` diff, keyed by
456/// `repo`-relative path. The diff machinery shared by the TS / Rust twins.
457///
458/// `<base>...HEAD` is the merge-base diff — the changes this branch introduced
459/// (what a PR shows). `--unified=0` drops context lines so every `+` line is a
460/// real addition; `--no-renames` keeps a rename a delete + an add (the added side
461/// is held to coverage); `--relative` reports paths relative to `repo`. Returns an
462/// error if `git diff` fails (e.g. `base` names no resolvable ref).
463///
464/// The invocation is pinned against the caller's git config (#392):
465/// `-c core.quotepath=off` emits non-ASCII bytes raw instead of octal-escaping them,
466/// `--no-ext-diff` blocks a configured external differ, and forcing
467/// `--src-prefix=a/ --dst-prefix=b/` keeps the `b/` strip in [`new_side_path`] working
468/// under a custom `diff.mnemonicPrefix` / `diff.noprefix`. Any residual C-quoted path
469/// (a name with a `"`, a backslash, or a control byte) is decoded in [`new_side_path`].
470pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
471    let range = format!("{base}...HEAD");
472    let output = Command::new("git")
473        .current_dir(repo)
474        .args([
475            "-c",
476            "core.quotepath=off",
477            "diff",
478            "--no-color",
479            "--no-ext-diff",
480            "--no-renames",
481            "--unified=0",
482            "--relative",
483            "--src-prefix=a/",
484            "--dst-prefix=b/",
485            &range,
486        ])
487        .output()
488        .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
489    if !output.status.success() {
490        bail!(
491            "`git diff {range}` failed in `{}`: {}",
492            repo.display(),
493            String::from_utf8_lossy(&output.stderr).trim()
494        );
495    }
496    Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
497}
498
499/// Pure: parse `git diff --unified=0` output into the new-side lines each file
500/// gained. Tracks the current file from each `+++` header and the new-side line
501/// counter from each `@@ … +c,d @@` hunk header, then records every following `+`
502/// line (a deletion `-` consumes no new-side number). A deleted file
503/// (`+++ /dev/null`) yields no entry.
504///
505/// Hunk state guards the header detection (#392): a `+++ ` / `--- ` line is a file
506/// header only *before* the first `@@` of a file's block; once inside a hunk, a `+`
507/// line — even one that renders as `+++ …` because its added content began `++ ` —
508/// is body, recorded against the current file. Each `diff --git ` line opens a new
509/// block and resets the state, so the next `+++ ` header is read as one again.
510fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
511    let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
512    let mut current: Option<String> = None;
513    let mut next_line: u64 = 0;
514    let mut in_hunk = false;
515    for line in diff.lines() {
516        if line.starts_with("diff --git ") {
517            // A new file's header block begins — leave hunk state so the `+++`
518            // header that follows is read as a header, not as added body.
519            in_hunk = false;
520            current = None;
521        } else if line.starts_with("@@") {
522            in_hunk = true;
523            if let Some(start) = hunk_new_start(line) {
524                next_line = start;
525            }
526        } else if !in_hunk {
527            // Before the first `@@`: the only header we read is `+++`. `--- `, `index`,
528            // and mode lines carry no new-side path and are ignored.
529            if let Some(header) = line.strip_prefix("+++ ") {
530                current = new_side_path(header);
531            }
532        } else if line.starts_with('+') {
533            // Inside a hunk: an added new-side body line — recorded against the
534            // current file and the counter advanced (a `+++ …` body line included).
535            if let Some(file) = &current {
536                changed.entry(file.clone()).or_default().insert(next_line);
537            }
538            next_line += 1;
539        }
540        // `-` (deleted), context, and metadata lines consume no new-side line.
541    }
542    changed
543}
544
545/// The `repo`-relative new-side path from a `+++` diff header, or `None` for a
546/// deletion (`+++ /dev/null`). Decodes a C-quoted path (#392) before stripping git's
547/// forced `b/` prefix and a trailing tab.
548fn new_side_path(header: &str) -> Option<String> {
549    let raw = header
550        .split('\t')
551        .next()
552        .unwrap_or(header)
553        .trim_end_matches('\r');
554    if raw == "/dev/null" {
555        return None;
556    }
557    // Git quotes the whole thing including the prefix (`"b/föö.py"`), so decode first.
558    let unquoted = unquote_c_path(raw);
559    let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
560    Some(path.replace('\\', "/"))
561}
562
563/// Decode a git C-quoted path to its real bytes (#392). Git wraps a path containing a
564/// `"`, a backslash, or a control byte — and, with `core.quotepath` on, a high-bit
565/// byte — in double quotes and C-escapes it (`\a \b \t \n \v \f \r \" \\`, and octal
566/// `\NNN` for any other byte). An unquoted path (no surrounding quotes) is returned
567/// unchanged, so this is a no-op on the common `core.quotepath=off` output.
568pub(crate) fn unquote_c_path(path: &str) -> String {
569    let bytes = path.as_bytes();
570    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
571        return path.to_string();
572    }
573    let inner = &bytes[1..bytes.len() - 1];
574    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
575    let mut i = 0;
576    while i < inner.len() {
577        if inner[i] != b'\\' || i + 1 >= inner.len() {
578            out.push(inner[i]);
579            i += 1;
580            continue;
581        }
582        let next = inner[i + 1];
583        if (b'0'..=b'7').contains(&next) {
584            // An octal escape: up to three octal digits.
585            let mut value: u32 = 0;
586            let mut k = i + 1;
587            while k < inner.len() && k < i + 4 && (b'0'..=b'7').contains(&inner[k]) {
588                value = value * 8 + u32::from(inner[k] - b'0');
589                k += 1;
590            }
591            out.push(value as u8);
592            i = k;
593        } else {
594            let decoded = match next {
595                b'a' => 0x07,
596                b'b' => 0x08,
597                b't' => b'\t',
598                b'n' => b'\n',
599                b'v' => 0x0b,
600                b'f' => 0x0c,
601                b'r' => b'\r',
602                other => other, // `\"`, `\\`, and any other escaped byte are literal.
603            };
604            out.push(decoded);
605            i += 2;
606        }
607    }
608    String::from_utf8_lossy(&out).into_owned()
609}
610
611/// The new-side start line from a hunk header `@@ -a,b +c,d @@ …` — the `c`. With
612/// `--unified=0` the added lines that follow are numbered consecutively from it.
613fn hunk_new_start(header: &str) -> Option<u64> {
614    let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
615    let digits = plus.trim_start_matches('+');
616    digits.split(',').next().unwrap_or(digits).parse().ok()
617}
618
619/// Re-key a report's per-file map to `root`-relative `/`-joined paths so they match
620/// the diff's paths. coverage.py reports paths relative to where it ran (here
621/// `root`) and vitest reports absolute paths; an absolute path is stripped to
622/// `root`, a relative one left as-is.
623fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
624    files
625        .into_iter()
626        .map(|(key, value)| {
627            let path = Path::new(&key);
628            let rel = path
629                .strip_prefix(root)
630                .unwrap_or(path)
631                .to_string_lossy()
632                .replace('\\', "/");
633            (rel, value)
634        })
635        .collect()
636}
637
638// Line-scoped coverage exemptions.
639//
640// A `coverage` exemption with a `lines` list excuses only those lines from the floor,
641// not the whole file. No coverage tool excludes individual line *numbers* from the
642// outside (coverage.py / v8 / llvm-cov all do it through source pragmas, which this
643// tool can't write), so the floor is recomputed from the same per-file detail the
644// diff-scoped floor reads — the measured lines minus the exempt ones. A determinism
645// guard (the counterpart to the stale-path rule) keeps the list honest: every listed
646// line must be genuinely uncovered, and an unlisted uncovered line still fails.
647
648/// Diff-free Python coverage floor with line-scoped exemptions: measure
649/// `thresholds` over every measured line *except* the `exempt_lines`. `omit` is the
650/// whole-file `coverage` exemptions, as in [`crate::coverage::measure`]. Requires
651/// coverage.py + pytest. The line-exempt path runs only when `exempt_lines` is
652/// non-empty; otherwise the caller takes the unchanged tool-total path.
653pub fn measure_line_exempt(
654    root: &Path,
655    thresholds: Thresholds,
656    omit: &[String],
657    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
658) -> Result<Outcome> {
659    let report = coverage::measure_report(root, omit)?;
660    let files = relative_keys(report.files, root);
661    let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
662        .iter()
663        .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
664        .collect();
665    let line_set = apply_line_exemptions(&detail, exempt_lines)?;
666    let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
667    Ok(floor_outcome(covered, total, thresholds.fail_under))
668}
669
670/// TypeScript twin of [`measure_line_exempt`]: the four vitest metrics measured
671/// over every measured line except the `exempt_lines`. `exclude` is the whole-file
672/// `coverage` exemptions, as in [`crate::coverage::measure_typescript`]. Requires
673/// vitest + `@vitest/coverage-v8`.
674pub fn measure_line_exempt_typescript(
675    root: &Path,
676    thresholds: TypeScriptThresholds,
677    exclude: &[String],
678    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
679) -> Result<Outcome> {
680    let detail = relative_keys(
681        coverage::measure_patch_typescript_detail(root, exclude)?,
682        root,
683    );
684    let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
685        .iter()
686        .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
687        .collect();
688    let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
689    Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
690}
691
692/// Rust twin of [`measure_line_exempt`]: the `cargo llvm-cov` regions/lines
693/// metrics measured over every measured line except the `exempt_lines`. `ignore` is the
694/// whole-file `coverage` exemptions, as in [`crate::coverage::measure_rust`]. Requires
695/// `cargo-llvm-cov`.
696pub fn measure_line_exempt_rust(
697    root: &Path,
698    thresholds: RustThresholds,
699    ignore: &[String],
700    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
701    features: &[String],
702) -> Result<Outcome> {
703    let detail = relative_keys(
704        coverage::measure_patch_rust_detail(root, ignore, features)?,
705        root,
706    );
707    let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
708        .iter()
709        .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
710        .collect();
711    let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
712    Ok(evaluate_patch_rust(&line_set, &detail, thresholds))
713}
714
715/// The whole-tree floor verdict for a recomputed `covered`/`total`, with the same
716/// message and float tolerance as the tool-total [`crate::coverage::evaluate`] — a
717/// from-detail total with the exempt lines removed is judged exactly as the tool's own.
718fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
719    if total == 0 {
720        return Outcome::Pass;
721    }
722    let actual = 100.0 * covered as f64 / total as f64;
723    if actual + 1e-9 >= f64::from(fail_under) {
724        Outcome::Pass
725    } else {
726        Outcome::Fail(format!(
727            "coverage {actual:.2}% is below the required {fail_under}%"
728        ))
729    }
730}
731
732/// The `(measured, missed)` lines for one Python file. `measured` is every executable
733/// line (executed or missing); `missed` is what the floor counts against you — the
734/// uncovered lines, plus (under branch coverage) any line that is the source of an
735/// untaken branch arc.
736fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
737    let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
738    let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
739    let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
740    let mut missed = missing;
741    if branch {
742        for arc in &cov.missing_branches {
743            if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
744                if measured.contains(&src) {
745                    missed.insert(src);
746                }
747            }
748        }
749    }
750    (measured, missed)
751}
752
753/// The `(measured, missed)` lines for one TypeScript file. A unit is anchored on the
754/// lines it spans — statements over `start..=end`, branch arms and function decls on
755/// their line — and `missed` is that set restricted to the uncovered units, so a line
756/// carrying any uncovered unit is exemptable.
757fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
758    let mut measured = BTreeSet::new();
759    let mut missed = BTreeSet::new();
760    // Each unit contributes its line(s): a statement spans `start..=end`, a branch arm
761    // and a function declaration sit on a single line. A line is `missed` when any unit
762    // on it is uncovered.
763    let units = cov
764        .statements
765        .iter()
766        .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
767        .chain(cov.branch_arms.iter().copied())
768        .chain(cov.functions.iter().copied());
769    for (line, covered) in units {
770        measured.insert(line);
771        if !covered {
772            missed.insert(line);
773        }
774    }
775    (measured, missed)
776}
777
778/// The `(measured, missed)` lines for one Rust file. `measured` is every line a code
779/// region spans; `missed` honors the enforced metrics — with `regions` on, any line in
780/// an uncovered region; with lines-only (`regions = None`), a line covered by regions
781/// but by no *covered* one.
782fn rust_measured_missed(
783    cov: &coverage::RustPatchCoverage,
784    thresholds: RustThresholds,
785) -> (BTreeSet<u64>, BTreeSet<u64>) {
786    let mut measured = BTreeSet::new();
787    for &(start, end, _covered) in &cov.regions {
788        for line in start..=end {
789            measured.insert(line);
790        }
791    }
792    let mut missed = BTreeSet::new();
793    for &line in &measured {
794        let mut covered_here = false;
795        let mut uncovered_region = false;
796        for &(start, end, covered) in &cov.regions {
797            if start <= line && line <= end {
798                if covered {
799                    covered_here = true;
800                } else {
801                    uncovered_region = true;
802                }
803            }
804        }
805        let is_missed = if thresholds.regions.is_some() {
806            uncovered_region
807        } else {
808            !covered_here
809        };
810        if is_missed {
811            missed.insert(line);
812        }
813    }
814    (measured, missed)
815}
816
817/// The per-file line set the floor is measured over — every measured line minus the
818/// exempt ones — after the determinism guard. Each exempt line must be in its
819/// file's `missed` set (genuinely failing); a listed line that is covered, or carries
820/// no measured code, is a hard error so the exemption can't excuse working code.
821fn apply_line_exemptions(
822    detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
823    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
824) -> Result<BTreeMap<String, BTreeSet<u64>>> {
825    let mut over: Vec<String> = Vec::new();
826    for (file, lines) in exempt_lines {
827        let missed = detail.get(file).map(|(_, missed)| missed);
828        for &line in lines {
829            let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
830            if !failing {
831                over.push(format!("\n  {file}:{line}"));
832            }
833        }
834    }
835    if !over.is_empty() {
836        bail!(
837            "a line-scoped coverage exemption may only list uncovered lines, but these are \
838             covered or carry no measured code:{}",
839            over.concat()
840        );
841    }
842    let mut line_set = BTreeMap::new();
843    for (file, (measured, _)) in detail {
844        let exempt = exempt_lines.get(file);
845        let kept: BTreeSet<u64> = measured
846            .iter()
847            .copied()
848            .filter(|&line| {
849                !exempt.is_some_and(|exempt| {
850                    u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
851                })
852            })
853            .collect();
854        line_set.insert(file.clone(), kept);
855    }
856    Ok(line_set)
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
864        entries
865            .iter()
866            .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
867            .collect()
868    }
869
870    #[test]
871    fn parses_added_lines_from_a_hunk() {
872        // `+4,2` → two added lines numbered from 4; the function context after the
873        // second `@@` is ignored.
874        let diff = "diff --git a/widget.py b/widget.py\n\
875                    index abc..def 100644\n\
876                    --- a/widget.py\n\
877                    +++ b/widget.py\n\
878                    @@ -3,0 +4,2 @@ def f(x):\n\
879                    +    if x == 99:\n\
880                    +        return 7\n";
881        assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
882    }
883
884    #[test]
885    fn parses_a_new_file_as_added_from_line_one() {
886        let diff = "diff --git a/lonely.py b/lonely.py\n\
887                    new file mode 100644\n\
888                    index 0000000..bbb\n\
889                    --- /dev/null\n\
890                    +++ b/lonely.py\n\
891                    @@ -0,0 +1,2 @@\n\
892                    +def lonely():\n\
893                    +    return 41\n";
894        assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
895    }
896
897    #[test]
898    fn a_deletion_only_hunk_records_no_added_lines() {
899        // `+3,0` adds nothing; the `-` lines consume no new-side number.
900        let diff = "diff --git a/widget.py b/widget.py\n\
901                    index abc..def 100644\n\
902                    --- a/widget.py\n\
903                    +++ b/widget.py\n\
904                    @@ -4,2 +3,0 @@ def f(x):\n\
905                    -    dead = 1\n\
906                    -    return dead\n";
907        assert!(parse_unified_diff(diff).is_empty());
908    }
909
910    #[test]
911    fn a_deleted_file_yields_no_entry() {
912        let diff = "diff --git a/gone.py b/gone.py\n\
913                    deleted file mode 100644\n\
914                    index abc..0000000\n\
915                    --- a/gone.py\n\
916                    +++ /dev/null\n\
917                    @@ -1,2 +0,0 @@\n\
918                    -def gone():\n\
919                    -    return 0\n";
920        assert!(parse_unified_diff(diff).is_empty());
921    }
922
923    #[test]
924    fn parses_multiple_files_and_a_single_line_hunk() {
925        // `+2` (no count) is one line at line 2; a nested path is kept verbatim.
926        let diff = "diff --git a/a.py b/a.py\n\
927                    --- a/a.py\n\
928                    +++ b/a.py\n\
929                    @@ -1,0 +2 @@ def a():\n\
930                    +    x = 1\n\
931                    diff --git a/pkg/b.py b/pkg/b.py\n\
932                    --- a/pkg/b.py\n\
933                    +++ b/pkg/b.py\n\
934                    @@ -10,0 +11,1 @@\n\
935                    +    y = 2\n";
936        assert_eq!(
937            parse_unified_diff(diff),
938            changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
939        );
940    }
941
942    #[test]
943    fn a_plus_plus_body_line_is_not_a_file_header() {
944        // An added source line whose content begins `++ ` renders as `+++ …` in the
945        // unified diff (the `+` add-marker plus the `++ `). It is hunk *body*, not a
946        // `+++` file header — so it must stay attributed to `w.py` and must not divert
947        // the file's later added lines to a bogus key (which drops them from scoping —
948        // a false green). Header detection only fires before the first `@@`.
949        let diff = "diff --git a/w.py b/w.py\n\
950                    index abc..def 100644\n\
951                    --- a/w.py\n\
952                    +++ b/w.py\n\
953                    @@ -1,0 +1,3 @@\n\
954                    +++ 1\n\
955                    +y = 1\n\
956                    +z = 2\n";
957        assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
958    }
959
960    #[test]
961    fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
962        // With `core.quotepath` on (git's default), a non-ASCII header path is
963        // C-quoted — wrapped in double quotes with octal `\NNN` byte escapes — e.g.
964        // `src/föö.py` → `"b/src/f\303\266\303\266.py"`. `new_side_path` must decode it
965        // to the real UTF-8 path so it matches the coverage report's key; left quoted,
966        // the changed lines are silently skipped (a vacuous pass).
967        assert_eq!(
968            new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
969            Some("src/föö.py")
970        );
971        // An unquoted path (quotepath off) keeps working, bar the `b/` strip.
972        assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
973    }
974
975    #[test]
976    fn unquote_c_path_decodes_octal_and_named_escapes() {
977        // Octal byte escapes reassemble UTF-8 (ö = C3 B6).
978        assert_eq!(
979            unquote_c_path("\"src/f\\303\\266\\303\\266.py\""),
980            "src/föö.py"
981        );
982        // The named escapes and the literal `\"` / `\\` each decode to their byte.
983        assert_eq!(unquote_c_path("\"a\\tb\\\"c\\\\d\""), "a\tb\"c\\d");
984        assert_eq!(
985            unquote_c_path("\"\\a\\b\\n\\v\\f\\r\""),
986            "\u{7}\u{8}\n\u{b}\u{c}\r"
987        );
988        // A short octal run stops at three digits: `\1015` → byte 0o101 ('A') then '5'.
989        assert_eq!(unquote_c_path("\"\\1015\""), "A5");
990    }
991
992    #[test]
993    fn unquote_c_path_leaves_an_unquoted_path_unchanged() {
994        // The common `core.quotepath=off` case: no surrounding quotes → returned as-is.
995        assert_eq!(unquote_c_path("src/föö.py"), "src/föö.py");
996        // A lone `"` (not a wrapping pair) and the empty string are left alone.
997        assert_eq!(unquote_c_path("\""), "\"");
998        assert_eq!(unquote_c_path(""), "");
999        // A trailing lone backslash inside the quotes is emitted literally.
1000        assert_eq!(unquote_c_path("\"a\\\""), "a\\");
1001    }
1002
1003    fn cov(
1004        executed: &[u64],
1005        missing: &[u64],
1006        executed_branches: &[[i64; 2]],
1007        missing_branches: &[[i64; 2]],
1008    ) -> FileCoverage {
1009        FileCoverage {
1010            executed_lines: executed.to_vec(),
1011            missing_lines: missing.to_vec(),
1012            excluded_lines: Vec::new(),
1013            executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
1014            missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
1015        }
1016    }
1017
1018    const FLOOR_85: Thresholds = Thresholds {
1019        fail_under: 85,
1020        branch: true,
1021    };
1022
1023    #[test]
1024    fn patch_a_fully_covered_diff_passes() {
1025        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
1026        assert_eq!(
1027            evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
1028            Outcome::Pass
1029        );
1030    }
1031
1032    #[test]
1033    fn patch_below_floor_fails_and_names_the_percent() {
1034        // 3 of 4 changed executable lines covered → 75% < 85.
1035        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
1036        let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
1037        assert!(
1038            matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
1039            "got: {out:?}"
1040        );
1041    }
1042
1043    #[test]
1044    fn patch_the_same_diff_clears_a_lower_floor() {
1045        // 75% passes a 70 floor despite the uncovered line.
1046        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
1047        let floor_70 = Thresholds {
1048            fail_under: 70,
1049            branch: true,
1050        };
1051        assert_eq!(
1052            evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
1053            Outcome::Pass
1054        );
1055    }
1056
1057    #[test]
1058    fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
1059        // Lines 1,2 executed (2 covered) + a taken arc out of line 2 (covered) and an
1060        // untaken arc out of line 2 (missed): 3 covered of 4 → 75% < 85.
1061        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
1062        let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
1063        assert!(
1064            matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
1065            "got: {out:?}"
1066        );
1067    }
1068
1069    #[test]
1070    fn patch_branches_off_ignores_arcs() {
1071        // Same data, branch disabled: only the two executed lines count → 100%.
1072        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
1073        let no_branch = Thresholds {
1074            fail_under: 85,
1075            branch: false,
1076        };
1077        assert_eq!(
1078            evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
1079            Outcome::Pass
1080        );
1081    }
1082
1083    #[test]
1084    fn patch_a_changed_file_absent_from_coverage_is_skipped() {
1085        // A test file (never measured) contributes nothing; with no other executable
1086        // changed line the diff is vacuously covered.
1087        let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
1088        assert_eq!(
1089            evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
1090            Outcome::Pass
1091        );
1092    }
1093
1094    #[test]
1095    fn patch_a_diff_with_no_executable_changed_lines_passes() {
1096        // Changed lines are comments/blanks (in neither executed nor missing) → vacuous.
1097        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
1098        assert_eq!(
1099            evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
1100            Outcome::Pass
1101        );
1102    }
1103
1104    use coverage::TsPatchCoverage;
1105
1106    fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
1107        entries
1108            .iter()
1109            .map(|(path, cov)| (path.to_string(), cov.clone()))
1110            .collect()
1111    }
1112
1113    const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
1114        lines: 80,
1115        branches: 80,
1116        functions: 80,
1117        statements: 80,
1118    };
1119
1120    #[test]
1121    fn ts_patch_a_fully_covered_diff_passes() {
1122        // Two statements on lines 1-2, both starting on their line and both covered;
1123        // a covered function on line 1; a taken branch arm off line 2 → 100% all four.
1124        let detail = ts_detail(&[(
1125            "w.ts",
1126            TsPatchCoverage {
1127                statements: vec![(1, 1, true), (2, 2, true)],
1128                branch_arms: vec![(2, true)],
1129                functions: vec![(1, true)],
1130            },
1131        )]);
1132        assert_eq!(
1133            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1134            Outcome::Pass
1135        );
1136    }
1137
1138    #[test]
1139    fn ts_patch_below_floor_fails_and_names_the_metric() {
1140        // Four changed lines each carry one statement; three covered, one not →
1141        // statements (and lines) 75% < 80, named; branches/functions are empty
1142        // (vacuously 100) and not named.
1143        let detail = ts_detail(&[(
1144            "w.ts",
1145            TsPatchCoverage {
1146                statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1147                branch_arms: vec![],
1148                functions: vec![],
1149            },
1150        )]);
1151        let out =
1152            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
1153        assert!(
1154            matches!(&out, Outcome::Fail(m)
1155                if m.contains("statements 75.00% < 80%")
1156                    && m.contains("lines 75.00% < 80%")
1157                    && !m.contains("branches")
1158                    && !m.contains("functions")),
1159            "got: {out:?}"
1160        );
1161    }
1162
1163    #[test]
1164    fn ts_patch_the_same_diff_clears_a_lower_floor() {
1165        // The 75% diff passes a 70 floor despite the uncovered line.
1166        let detail = ts_detail(&[(
1167            "w.ts",
1168            TsPatchCoverage {
1169                statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1170                branch_arms: vec![],
1171                functions: vec![],
1172            },
1173        )]);
1174        let floor_70 = TypeScriptThresholds {
1175            lines: 70,
1176            branches: 70,
1177            functions: 70,
1178            statements: 70,
1179        };
1180        assert_eq!(
1181            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
1182            Outcome::Pass
1183        );
1184    }
1185
1186    #[test]
1187    fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
1188        // Line 3's statement ran (covered) but one of its two branch arms never did:
1189        // branches 50% < 80, named; lines/statements are 100 (the statement is covered).
1190        let detail = ts_detail(&[(
1191            "w.ts",
1192            TsPatchCoverage {
1193                statements: vec![(3, 3, true)],
1194                branch_arms: vec![(3, true), (3, false)],
1195                functions: vec![],
1196            },
1197        )]);
1198        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1199        assert!(
1200            matches!(&out, Outcome::Fail(m)
1201                if m.contains("branches 50.00% < 80%")
1202                    && !m.contains("lines")
1203                    && !m.contains("statements")),
1204            "got: {out:?}"
1205        );
1206    }
1207
1208    #[test]
1209    fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1210        // A function declared on changed line 9 was never called → functions 0% < 80.
1211        let detail = ts_detail(&[(
1212            "w.ts",
1213            TsPatchCoverage {
1214                statements: vec![],
1215                branch_arms: vec![],
1216                functions: vec![(9, false)],
1217            },
1218        )]);
1219        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1220        assert!(
1221            matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1222            "got: {out:?}"
1223        );
1224    }
1225
1226    #[test]
1227    fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1228        // A test file (never measured) contributes nothing; with no other changed
1229        // executable line the diff is vacuously covered.
1230        let detail = ts_detail(&[(
1231            "w.ts",
1232            TsPatchCoverage {
1233                statements: vec![(1, 1, true)],
1234                branch_arms: vec![],
1235                functions: vec![],
1236            },
1237        )]);
1238        assert_eq!(
1239            evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1240            Outcome::Pass
1241        );
1242    }
1243
1244    #[test]
1245    fn ts_patch_a_comment_only_diff_passes() {
1246        // The changed lines carry no statement/branch/function (a comment or blank) →
1247        // every denominator empty → vacuously covered.
1248        let detail = ts_detail(&[(
1249            "w.ts",
1250            TsPatchCoverage {
1251                statements: vec![(1, 1, true), (2, 2, true)],
1252                branch_arms: vec![(2, true)],
1253                functions: vec![(1, true)],
1254            },
1255        )]);
1256        assert_eq!(
1257            evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
1258            Outcome::Pass
1259        );
1260    }
1261
1262    #[test]
1263    fn ts_patch_an_empty_diff_passes() {
1264        // No changed lines at all → vacuously covered at any floor.
1265        assert_eq!(
1266            evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1267            Outcome::Pass
1268        );
1269    }
1270
1271    #[test]
1272    fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1273        // A statement spanning lines 3-5 that never ran; only line 4 is in the diff →
1274        // it still counts (and is uncovered) → statements 0% < 80. No statement
1275        // *starts* on line 4, so lines has an empty denominator (vacuously 100).
1276        let detail = ts_detail(&[(
1277            "w.ts",
1278            TsPatchCoverage {
1279                statements: vec![(3, 5, false)],
1280                branch_arms: vec![],
1281                functions: vec![],
1282            },
1283        )]);
1284        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1285        assert!(
1286            matches!(&out, Outcome::Fail(m)
1287                if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1288            "got: {out:?}"
1289        );
1290    }
1291
1292    use coverage::RustPatchCoverage;
1293
1294    fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
1295        entries
1296            .iter()
1297            .map(|(path, cov)| (path.to_string(), cov.clone()))
1298            .collect()
1299    }
1300
1301    const RUST_FLOOR_80: RustThresholds = RustThresholds {
1302        regions: Some(80),
1303        lines: 80,
1304        functions: None,
1305        branch: None,
1306    };
1307
1308    #[test]
1309    fn rust_patch_a_fully_covered_diff_passes() {
1310        // Two single-line code regions on lines 1-2, both covered → regions and lines
1311        // both 100%.
1312        let detail = rust_detail(&[(
1313            "w.rs",
1314            RustPatchCoverage {
1315                regions: vec![(1, 1, true), (2, 2, true)],
1316            },
1317        )]);
1318        assert_eq!(
1319            evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1320            Outcome::Pass
1321        );
1322    }
1323
1324    #[test]
1325    fn rust_patch_below_floor_fails_and_names_the_metrics() {
1326        // Four single-line regions on lines 1-4; three covered, one not → regions (and
1327        // lines) 75% < 80, both named.
1328        let detail = rust_detail(&[(
1329            "w.rs",
1330            RustPatchCoverage {
1331                regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1332            },
1333        )]);
1334        let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
1335        assert!(
1336            matches!(&out, Outcome::Fail(m)
1337                if m.contains("regions 75.00% < 80%")
1338                    && m.contains("lines 75.00% < 80%")),
1339            "got: {out:?}"
1340        );
1341    }
1342
1343    #[test]
1344    fn rust_patch_the_same_diff_clears_a_lower_floor() {
1345        // The 75% diff passes a 70 floor despite the uncovered region.
1346        let detail = rust_detail(&[(
1347            "w.rs",
1348            RustPatchCoverage {
1349                regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1350            },
1351        )]);
1352        let floor_70 = RustThresholds {
1353            regions: Some(70),
1354            lines: 70,
1355            functions: None,
1356            branch: None,
1357        };
1358        assert_eq!(
1359            evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
1360            Outcome::Pass
1361        );
1362    }
1363
1364    #[test]
1365    fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
1366        // The zero-config default sets `regions: None`, so the diff-scoped floor
1367        // enforces lines only: a diff whose changed lines are all covered passes even
1368        // though one of its regions is uncovered (lines 1-4 are each covered by ≥1
1369        // region, but region 4 is not).
1370        let detail = rust_detail(&[(
1371            "w.rs",
1372            RustPatchCoverage {
1373                regions: vec![(1, 4, true), (4, 4, false)],
1374            },
1375        )]);
1376        let lines_only = RustThresholds {
1377            regions: None,
1378            lines: 100,
1379            functions: None,
1380            branch: None,
1381        };
1382        assert_eq!(
1383            evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
1384            Outcome::Pass
1385        );
1386    }
1387
1388    #[test]
1389    fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
1390        // A single uncovered region on changed line 5 → regions 0% and lines 0%, both
1391        // below the floor.
1392        let detail = rust_detail(&[(
1393            "w.rs",
1394            RustPatchCoverage {
1395                regions: vec![(5, 5, false)],
1396            },
1397        )]);
1398        let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
1399        assert!(
1400            matches!(&out, Outcome::Fail(m)
1401                if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1402            "got: {out:?}"
1403        );
1404    }
1405
1406    #[test]
1407    fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1408        // A test-only file (never measured) contributes nothing; with no other changed
1409        // measured line the diff is vacuously covered.
1410        let detail = rust_detail(&[(
1411            "w.rs",
1412            RustPatchCoverage {
1413                regions: vec![(1, 1, true)],
1414            },
1415        )]);
1416        assert_eq!(
1417            evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1418            Outcome::Pass
1419        );
1420    }
1421
1422    #[test]
1423    fn rust_patch_a_comment_only_diff_passes() {
1424        // The changed lines (9-10) carry no region (a comment or blank) → both
1425        // denominators empty → vacuously covered.
1426        let detail = rust_detail(&[(
1427            "w.rs",
1428            RustPatchCoverage {
1429                regions: vec![(1, 1, true), (2, 2, true)],
1430            },
1431        )]);
1432        assert_eq!(
1433            evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1434            Outcome::Pass
1435        );
1436    }
1437
1438    #[test]
1439    fn rust_patch_an_empty_diff_passes() {
1440        // No changed lines at all → vacuously covered at any floor.
1441        assert_eq!(
1442            evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1443            Outcome::Pass
1444        );
1445    }
1446
1447    #[test]
1448    fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1449        // A region spanning lines 3-5 that never ran; only line 4 is in the diff → it
1450        // still counts for both metrics (the region spans line 4, so line 4 is a
1451        // measured-but-uncovered line) → regions 0% and lines 0% < 80.
1452        let detail = rust_detail(&[(
1453            "w.rs",
1454            RustPatchCoverage {
1455                regions: vec![(3, 5, false)],
1456            },
1457        )]);
1458        let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1459        assert!(
1460            matches!(&out, Outcome::Fail(m)
1461                if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1462            "got: {out:?}"
1463        );
1464    }
1465
1466    #[test]
1467    fn rust_patch_a_line_covered_by_any_region_is_covered() {
1468        // Two overlapping regions span changed line 4 — one uncovered, one covered.
1469        // For the lines metric the line is covered (≥1 covering region's flag is set);
1470        // for regions, one of the two counts as covered → regions 50% (< 80, fails) but
1471        // lines 100% (≥ 80, not named).
1472        let detail = rust_detail(&[(
1473            "w.rs",
1474            RustPatchCoverage {
1475                regions: vec![(4, 4, false), (4, 6, true)],
1476            },
1477        )]);
1478        let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1479        assert!(
1480            matches!(&out, Outcome::Fail(m)
1481                if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1482            "got: {out:?}"
1483        );
1484    }
1485
1486    fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1487        entries
1488            .iter()
1489            .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1490            .collect()
1491    }
1492
1493    #[test]
1494    fn python_measured_missed_reads_lines_and_branch_sources() {
1495        // shim.py's shape: line 1 executed (the `def`), lines 2-4 the never-run body,
1496        // a missing branch out of line 2. measured = every executable line; missed =
1497        // the uncovered lines plus the branch source.
1498        let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1499        let (measured, missed) = python_measured_missed(&full, true);
1500        assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1501        assert_eq!(missed, [2, 3, 4].into_iter().collect());
1502        // With branch coverage off, a partial-branch source isn't a miss on its own.
1503        let partial = cov(&[5], &[], &[], &[[5, 6]]);
1504        let (_, missed_no_branch) = python_measured_missed(&partial, false);
1505        assert!(missed_no_branch.is_empty());
1506        let (_, missed_branch) = python_measured_missed(&partial, true);
1507        assert_eq!(missed_branch, [5].into_iter().collect());
1508    }
1509
1510    #[test]
1511    fn ts_measured_missed_anchors_units_on_their_lines() {
1512        // A covered statement on line 1, an uncovered one spanning 3-4, an uncovered
1513        // branch arm on 1, an uncovered function decl on 6.
1514        let cov = coverage::TsPatchCoverage {
1515            statements: vec![(1, 1, true), (3, 4, false)],
1516            branch_arms: vec![(1, false)],
1517            functions: vec![(6, false)],
1518        };
1519        let (measured, missed) = ts_measured_missed(&cov);
1520        assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1521        // Line 1 is missed via its uncovered branch arm even though its statement ran.
1522        assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1523    }
1524
1525    #[test]
1526    fn rust_measured_missed_honors_the_enforced_metrics() {
1527        // An uncovered region on lines 5-6 and a covered one on line 1.
1528        let cov = coverage::RustPatchCoverage {
1529            regions: vec![(1, 1, true), (5, 6, false)],
1530        };
1531        let with_regions = RustThresholds {
1532            regions: Some(100),
1533            lines: 100,
1534            functions: None,
1535            branch: None,
1536        };
1537        let (measured, missed) = rust_measured_missed(&cov, with_regions);
1538        assert_eq!(measured, [1, 5, 6].into_iter().collect());
1539        assert_eq!(missed, [5, 6].into_iter().collect());
1540        // Lines-only: a line covered by ≥1 covered region isn't a miss; an uncovered
1541        // region with no covering one still is.
1542        let lines_only = RustThresholds {
1543            regions: None,
1544            lines: 100,
1545            functions: None,
1546            branch: None,
1547        };
1548        let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1549        assert_eq!(missed_lines, [5, 6].into_iter().collect());
1550    }
1551
1552    #[test]
1553    fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1554        // shim measured {1,2,3,4}, missed {2,3,4}; exempting 2-4 leaves {1}.
1555        let detail = BTreeMap::from([(
1556            "shim.py".to_string(),
1557            (
1558                [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1559                [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1560            ),
1561        )]);
1562        let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1563        assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1564    }
1565
1566    #[test]
1567    fn apply_line_exemptions_rejects_a_covered_listed_line() {
1568        // Line 1 is measured but not missed (it's covered) — over-exemption is an error.
1569        let detail = BTreeMap::from([(
1570            "shim.py".to_string(),
1571            (
1572                [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1573                [2u64].into_iter().collect::<BTreeSet<u64>>(),
1574            ),
1575        )]);
1576        let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1577        assert!(
1578            err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1579            "got: {err}"
1580        );
1581    }
1582
1583    #[test]
1584    fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1585        // A line not measured at all (a comment) can't be exempted either.
1586        let detail = BTreeMap::from([(
1587            "w.py".to_string(),
1588            (
1589                [2u64].into_iter().collect::<BTreeSet<u64>>(),
1590                [2u64].into_iter().collect::<BTreeSet<u64>>(),
1591            ),
1592        )]);
1593        let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1594        assert!(err.to_string().contains("w.py:9"), "got: {err}");
1595    }
1596
1597    #[test]
1598    fn floor_outcome_matches_the_whole_tree_message() {
1599        assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1600        let out = floor_outcome(7, 8, 100);
1601        assert!(
1602            matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1603            "got: {out:?}"
1604        );
1605        // An all-exempt file leaves nothing to measure — vacuously a pass.
1606        assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1607    }
1608
1609    #[test]
1610    fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1611        // The `--base` path drops a changed line that is line-exempt (lines 2-3 here),
1612        // leaving the rest of the diff to be judged; an exemption for an untouched file
1613        // is a no-op.
1614        let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1615        lift_exempt_lines(
1616            &mut changed,
1617            &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1618        );
1619        assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1620        assert_eq!(changed["core.py"], [5].into_iter().collect());
1621    }
1622}