Skip to main content

testing_conventions/
patch_coverage.rs

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