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) -> Result<Outcome> {
67    let mut changed = changed_lines(root, base)?;
68    changed.retain(|path, _| path.ends_with(".py"));
69    if changed.is_empty() {
70        return Ok(Outcome::Pass);
71    }
72    let report = coverage::measure_patch_report(root, omit)?;
73    let files = relative_keys(report.files, root);
74    Ok(evaluate_patch(&changed, &files, thresholds))
75}
76
77/// Pure: the configured floor measured over the changed lines. Reproduces
78/// coverage.py's `percent_covered` — (executed lines + taken branch arcs) ÷
79/// (executable lines + all branch arcs) — restricted to the lines the diff touched,
80/// so the same number `unit coverage` enforces whole-tree is judged on the diff.
81///
82/// A changed line absent from `files` (a comment or blank, a test file, or a
83/// `coverage`-exempt file omitted from the run) has nothing to cover and is skipped;
84/// when nothing executable changed, the diff is vacuously covered (`Pass`). With
85/// `branch`, a branch arc counts toward the ratio when its source line is in the diff
86/// — taken arcs as covered, untaken as missed — exactly as the whole-tree total folds
87/// branches in. No small-diff carve-out: a tiny diff below the floor fails like any
88/// other (#162).
89fn evaluate_patch(
90    changed: &BTreeMap<String, BTreeSet<u64>>,
91    files: &BTreeMap<String, FileCoverage>,
92    thresholds: Thresholds,
93) -> Outcome {
94    let mut covered: u64 = 0;
95    let mut total: u64 = 0;
96    for (file, lines) in changed {
97        let Some(cov) = files.get(file) else {
98            continue;
99        };
100        let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
101        let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
102        for &line in lines {
103            if executed.contains(&line) {
104                covered += 1;
105                total += 1;
106            } else if missing.contains(&line) {
107                total += 1;
108            }
109        }
110        if thresholds.branch {
111            for arc in &cov.executed_branches {
112                if arc_source_in(arc, lines) {
113                    covered += 1;
114                    total += 1;
115                }
116            }
117            for arc in &cov.missing_branches {
118                if arc_source_in(arc, lines) {
119                    total += 1;
120                }
121            }
122        }
123    }
124    if total == 0 {
125        return Outcome::Pass;
126    }
127    let actual = 100.0 * covered as f64 / total as f64;
128    // A hair of tolerance so a percent that rounds to the floor isn't failed by float
129    // noise (matches the whole-tree `coverage::evaluate`).
130    if actual + 1e-9 >= f64::from(thresholds.fail_under) {
131        Outcome::Pass
132    } else {
133        Outcome::Fail(format!(
134            "changed-line coverage {actual:.2}% is below the required {}%",
135            thresholds.fail_under
136        ))
137    }
138}
139
140/// Whether a branch arc's source line (the first of its `[src, dst]` pair; `dst` may
141/// be a negative exit, which is irrelevant) falls in `lines`.
142fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
143    arc.first()
144        .and_then(|&src| u64::try_from(src).ok())
145        .is_some_and(|src| lines.contains(&src))
146}
147
148/// Diff-scoped TypeScript coverage floor (#162): the four vitest metrics measured
149/// over the `<base>...HEAD` changed `.ts`/`.tsx`/`.mts`/`.cts` lines instead of the
150/// whole tree. `exclude` is the `coverage`-rule exemptions, as in
151/// [`crate::coverage::measure_typescript`] — an excluded file is left out of the
152/// run, so its changed lines drop out of the ratios.
153///
154/// Scopes to TypeScript sources and returns early — with no coverage run — when the
155/// diff touches none, so a PR that changes only docs or other languages doesn't pay
156/// for a measurement (and is vacuously covered). Requires vitest + git; an
157/// unresolvable `base` surfaces as an error rather than a silent pass.
158pub fn measure_typescript(
159    root: &Path,
160    base: &str,
161    thresholds: TypeScriptThresholds,
162    exclude: &[String],
163) -> Result<Outcome> {
164    let mut changed = changed_lines(root, base)?;
165    changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
166    if changed.is_empty() {
167        return Ok(Outcome::Pass);
168    }
169    let detail = relative_keys(
170        coverage::measure_patch_typescript_detail(root, exclude)?,
171        root,
172    );
173    Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
174}
175
176/// Pure: the four vitest floors measured over the changed lines. Each metric's
177/// ratio is restricted to the lines the diff touched, so the same numbers
178/// `unit coverage` enforces whole-tree are judged on the diff:
179///   - **statements**: a `statementMap` entry counts when any line in its
180///     `start..=end` is a changed line; covered when its flag is set.
181///   - **lines**: a changed line counts when ≥1 statement *starts* on it; covered
182///     when ≥1 statement starting on it is covered.
183///   - **branches**: a branch arm counts when its `source_line` is a changed line;
184///     covered when its flag is set.
185///   - **functions**: a function counts when its `decl_line` is a changed line;
186///     covered when its flag is set.
187///
188/// A changed file absent from `detail` (a test file, a declaration file, or a
189/// `coverage`-exempt file left out of the run) has nothing to cover and is skipped.
190/// Each metric's percent is `100 * covered / total`, or `100` when its denominator
191/// is empty — a diff-scoped empty denominator is **vacuously satisfied**, not the
192/// "measured no code" failure the whole-tree [`coverage::evaluate_typescript`]
193/// returns (a diff may legitimately touch no branches or functions). The fail
194/// message lists every metric below its floor, matching
195/// [`coverage::evaluate_typescript`]'s. No small-diff carve-out: a tiny diff below
196/// the floor fails like any other (#162).
197fn evaluate_patch_typescript(
198    changed: &BTreeMap<String, BTreeSet<u64>>,
199    detail: &BTreeMap<String, coverage::TsPatchCoverage>,
200    thresholds: TypeScriptThresholds,
201) -> Outcome {
202    let (mut s_cov, mut s_tot) = (0u64, 0u64);
203    let (mut l_cov, mut l_tot) = (0u64, 0u64);
204    let (mut b_cov, mut b_tot) = (0u64, 0u64);
205    let (mut f_cov, mut f_tot) = (0u64, 0u64);
206
207    for (file, lines) in changed {
208        let Some(cov) = detail.get(file) else {
209            continue;
210        };
211
212        // Statements: count one whenever any line it spans was changed.
213        for &(start, end, covered) in &cov.statements {
214            if (start..=end).any(|line| lines.contains(&line)) {
215                s_tot += 1;
216                if covered {
217                    s_cov += 1;
218                }
219            }
220        }
221
222        // Lines: a changed line on which ≥1 statement *starts* counts; covered when
223        // ≥1 statement starting on it is covered.
224        for &line in lines {
225            let mut starts_here = false;
226            let mut covered_here = false;
227            for &(start, _end, covered) in &cov.statements {
228                if start == line {
229                    starts_here = true;
230                    covered_here |= covered;
231                }
232            }
233            if starts_here {
234                l_tot += 1;
235                if covered_here {
236                    l_cov += 1;
237                }
238            }
239        }
240
241        // Branch arms: count one whenever its source line was changed.
242        for &(source_line, covered) in &cov.branch_arms {
243            if lines.contains(&source_line) {
244                b_tot += 1;
245                if covered {
246                    b_cov += 1;
247                }
248            }
249        }
250
251        // Functions: count one whenever its declaration line was changed.
252        for &(decl_line, covered) in &cov.functions {
253            if lines.contains(&decl_line) {
254                f_tot += 1;
255                if covered {
256                    f_cov += 1;
257                }
258            }
259        }
260    }
261
262    // An empty denominator is vacuously full (100%) — a diff may touch no branch or
263    // function, which is satisfied, not the whole-tree "measured no code" failure.
264    let pct = |covered: u64, total: u64| {
265        if total == 0 {
266            100.0
267        } else {
268            100.0 * covered as f64 / total as f64
269        }
270    };
271    let checks = [
272        ("lines", pct(l_cov, l_tot), thresholds.lines),
273        ("branches", pct(b_cov, b_tot), thresholds.branches),
274        ("functions", pct(f_cov, f_tot), thresholds.functions),
275        ("statements", pct(s_cov, s_tot), thresholds.statements),
276    ];
277    let mut shortfalls = Vec::new();
278    for (name, actual, required) in checks {
279        // A hair of tolerance so a percent that rounds to the floor isn't failed by
280        // float noise (matches the whole-tree `coverage::evaluate_typescript`).
281        if actual + 1e-9 < f64::from(required) {
282            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
283        }
284    }
285    if shortfalls.is_empty() {
286        Outcome::Pass
287    } else {
288        Outcome::Fail(format!(
289            "coverage below thresholds: {}",
290            shortfalls.join(", ")
291        ))
292    }
293}
294
295/// Diff-scoped Rust coverage floor (#162): the `cargo llvm-cov` regions/lines
296/// metrics measured over the `<base>...HEAD` changed `.rs` lines instead of the
297/// whole tree. `ignore` is the `coverage`-rule exemptions, as in
298/// [`crate::coverage::measure_rust`] — an exempt file is dropped from the run, so
299/// its changed lines drop out of the ratios.
300///
301/// Scopes to `.rs` sources and returns early — with no coverage run — when the diff
302/// touches none, so a PR that changes only docs or other languages doesn't pay for a
303/// measurement (and is vacuously covered). Requires `cargo-llvm-cov` + git; an
304/// unresolvable `base` surfaces as an error rather than a silent pass.
305pub fn measure_rust(
306    root: &Path,
307    base: &str,
308    thresholds: RustThresholds,
309    ignore: &[String],
310) -> Result<Outcome> {
311    let mut changed = changed_lines(root, base)?;
312    changed.retain(|path, _| path.ends_with(".rs"));
313    if changed.is_empty() {
314        return Ok(Outcome::Pass);
315    }
316    let detail = relative_keys(coverage::measure_patch_rust_detail(root, ignore)?, root);
317    Ok(evaluate_patch_rust(&changed, &detail, thresholds))
318}
319
320/// Pure: the two `cargo llvm-cov` floors (regions, lines) measured over the changed
321/// lines. Each metric's ratio is restricted to the lines the diff touched, so the
322/// same numbers `unit coverage` enforces whole-tree are judged on the diff:
323///   - **regions**: a code region counts when any line in its `start..=end` is a
324///     changed line; covered when its flag is set.
325///   - **lines**: a changed line counts when ≥1 region covers it (`start <= line <=
326///     end`); covered when ≥1 covering region has its flag set.
327///
328/// A changed file absent from `detail` (a test-only file or a `coverage`-exempt file
329/// dropped from the run) has nothing to cover and is skipped. Each metric's percent
330/// is `100 * covered / total`, or `100` when its denominator is empty — a
331/// diff-scoped empty denominator is **vacuously satisfied**, not the "measured no
332/// code" failure the whole-tree [`coverage::evaluate_rust`] returns (a diff may
333/// legitimately touch no measured region). The fail message lists every metric below
334/// its floor, matching [`coverage::evaluate_rust`]'s. No small-diff carve-out: a tiny
335/// diff below the floor fails like any other (#162).
336fn evaluate_patch_rust(
337    changed: &BTreeMap<String, BTreeSet<u64>>,
338    detail: &BTreeMap<String, coverage::RustPatchCoverage>,
339    thresholds: RustThresholds,
340) -> Outcome {
341    let (mut r_cov, mut r_tot) = (0u64, 0u64);
342    let (mut l_cov, mut l_tot) = (0u64, 0u64);
343
344    for (file, lines) in changed {
345        let Some(cov) = detail.get(file) else {
346            continue;
347        };
348
349        // Regions: count one whenever any line it spans was changed.
350        for &(start, end, covered) in &cov.regions {
351            if (start..=end).any(|line| lines.contains(&line)) {
352                r_tot += 1;
353                if covered {
354                    r_cov += 1;
355                }
356            }
357        }
358
359        // Lines: a changed line covered by ≥1 region counts; covered when ≥1 region
360        // covering it has its flag set.
361        for &line in lines {
362            let mut measured = false;
363            let mut covered_here = false;
364            for &(start, end, covered) in &cov.regions {
365                if start <= line && line <= end {
366                    measured = true;
367                    covered_here |= covered;
368                }
369            }
370            if measured {
371                l_tot += 1;
372                if covered_here {
373                    l_cov += 1;
374                }
375            }
376        }
377    }
378
379    // An empty denominator is vacuously full (100%) — a diff may touch no measured
380    // region, which is satisfied, not the whole-tree "measured no code" failure.
381    let pct = |covered: u64, total: u64| {
382        if total == 0 {
383            100.0
384        } else {
385            100.0 * covered as f64 / total as f64
386        }
387    };
388    // `regions` is opt-in (#206): skip the region check unless a config set a floor,
389    // matching the whole-tree `coverage::evaluate_rust`.
390    let mut checks: Vec<(&str, f64, u8)> = Vec::new();
391    if let Some(regions) = thresholds.regions {
392        checks.push(("regions", pct(r_cov, r_tot), regions));
393    }
394    checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
395    let mut shortfalls = Vec::new();
396    for (name, actual, required) in checks {
397        // A hair of tolerance so a percent that rounds to the floor isn't failed by
398        // float noise (matches the whole-tree `coverage::evaluate_rust`).
399        if actual + 1e-9 < f64::from(required) {
400            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
401        }
402    }
403    if shortfalls.is_empty() {
404        Outcome::Pass
405    } else {
406        Outcome::Fail(format!(
407            "coverage below thresholds: {}",
408            shortfalls.join(", ")
409        ))
410    }
411}
412
413/// The new-side lines each file gained in `repo`'s `<base>...HEAD` diff, keyed by
414/// `repo`-relative path. The diff machinery shared by the TS / Rust twins.
415///
416/// `<base>...HEAD` is the merge-base diff — the changes this branch introduced
417/// (what a PR shows). `--unified=0` drops context lines so every `+` line is a
418/// real addition; `--no-renames` keeps a rename a delete + an add (the added side
419/// is held to coverage); `--relative` reports paths relative to `repo`. Returns an
420/// error if `git diff` fails (e.g. `base` names no resolvable ref).
421pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
422    let range = format!("{base}...HEAD");
423    let output = Command::new("git")
424        .current_dir(repo)
425        .args([
426            "diff",
427            "--no-color",
428            "--no-renames",
429            "--unified=0",
430            "--relative",
431            &range,
432        ])
433        .output()
434        .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
435    if !output.status.success() {
436        bail!(
437            "`git diff {range}` failed in `{}`: {}",
438            repo.display(),
439            String::from_utf8_lossy(&output.stderr).trim()
440        );
441    }
442    Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
443}
444
445/// Pure: parse `git diff --unified=0` output into the new-side lines each file
446/// gained. Tracks the current file from each `+++` header and the new-side line
447/// counter from each `@@ … +c,d @@` hunk header, then records every following `+`
448/// line (a deletion `-` consumes no new-side number). A deleted file
449/// (`+++ /dev/null`) yields no entry.
450fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
451    let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
452    let mut current: Option<String> = None;
453    let mut next_line: u64 = 0;
454    for line in diff.lines() {
455        if let Some(header) = line.strip_prefix("+++ ") {
456            current = new_side_path(header);
457        } else if line.starts_with("@@") {
458            if let Some(start) = hunk_new_start(line) {
459                next_line = start;
460            }
461        } else if line.starts_with('+') {
462            // An added new-side line — the `+++` header is handled above, so this
463            // is diff body. Record it against the current file and advance.
464            if let Some(file) = &current {
465                changed.entry(file.clone()).or_default().insert(next_line);
466            }
467            next_line += 1;
468        }
469        // `-` (deleted) and metadata lines consume no new-side line and are skipped.
470    }
471    changed
472}
473
474/// The `repo`-relative new-side path from a `+++` diff header, or `None` for a
475/// deletion (`+++ /dev/null`). Strips git's `b/` prefix and a trailing tab.
476fn new_side_path(header: &str) -> Option<String> {
477    let path = header
478        .split('\t')
479        .next()
480        .unwrap_or(header)
481        .trim_end_matches('\r');
482    if path == "/dev/null" {
483        return None;
484    }
485    let path = path.strip_prefix("b/").unwrap_or(path);
486    Some(path.replace('\\', "/"))
487}
488
489/// The new-side start line from a hunk header `@@ -a,b +c,d @@ …` — the `c`. With
490/// `--unified=0` the added lines that follow are numbered consecutively from it.
491fn hunk_new_start(header: &str) -> Option<u64> {
492    let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
493    let digits = plus.trim_start_matches('+');
494    digits.split(',').next().unwrap_or(digits).parse().ok()
495}
496
497/// Re-key a report's per-file map to `root`-relative `/`-joined paths so they match
498/// the diff's paths. coverage.py reports paths relative to where it ran (here
499/// `root`) and vitest reports absolute paths; an absolute path is stripped to
500/// `root`, a relative one left as-is.
501fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
502    files
503        .into_iter()
504        .map(|(key, value)| {
505            let path = Path::new(&key);
506            let rel = path
507                .strip_prefix(root)
508                .unwrap_or(path)
509                .to_string_lossy()
510                .replace('\\', "/");
511            (rel, value)
512        })
513        .collect()
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
521        entries
522            .iter()
523            .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
524            .collect()
525    }
526
527    // ---- parse_unified_diff --------------------------------------------------
528
529    #[test]
530    fn parses_added_lines_from_a_hunk() {
531        // `+4,2` → two added lines numbered from 4; the function context after the
532        // second `@@` is ignored.
533        let diff = "diff --git a/widget.py b/widget.py\n\
534                    index abc..def 100644\n\
535                    --- a/widget.py\n\
536                    +++ b/widget.py\n\
537                    @@ -3,0 +4,2 @@ def f(x):\n\
538                    +    if x == 99:\n\
539                    +        return 7\n";
540        assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
541    }
542
543    #[test]
544    fn parses_a_new_file_as_added_from_line_one() {
545        let diff = "diff --git a/lonely.py b/lonely.py\n\
546                    new file mode 100644\n\
547                    index 0000000..bbb\n\
548                    --- /dev/null\n\
549                    +++ b/lonely.py\n\
550                    @@ -0,0 +1,2 @@\n\
551                    +def lonely():\n\
552                    +    return 41\n";
553        assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
554    }
555
556    #[test]
557    fn a_deletion_only_hunk_records_no_added_lines() {
558        // `+3,0` adds nothing; the `-` lines consume no new-side number.
559        let diff = "diff --git a/widget.py b/widget.py\n\
560                    index abc..def 100644\n\
561                    --- a/widget.py\n\
562                    +++ b/widget.py\n\
563                    @@ -4,2 +3,0 @@ def f(x):\n\
564                    -    dead = 1\n\
565                    -    return dead\n";
566        assert!(parse_unified_diff(diff).is_empty());
567    }
568
569    #[test]
570    fn a_deleted_file_yields_no_entry() {
571        let diff = "diff --git a/gone.py b/gone.py\n\
572                    deleted file mode 100644\n\
573                    index abc..0000000\n\
574                    --- a/gone.py\n\
575                    +++ /dev/null\n\
576                    @@ -1,2 +0,0 @@\n\
577                    -def gone():\n\
578                    -    return 0\n";
579        assert!(parse_unified_diff(diff).is_empty());
580    }
581
582    #[test]
583    fn parses_multiple_files_and_a_single_line_hunk() {
584        // `+2` (no count) is one line at line 2; a nested path is kept verbatim.
585        let diff = "diff --git a/a.py b/a.py\n\
586                    --- a/a.py\n\
587                    +++ b/a.py\n\
588                    @@ -1,0 +2 @@ def a():\n\
589                    +    x = 1\n\
590                    diff --git a/pkg/b.py b/pkg/b.py\n\
591                    --- a/pkg/b.py\n\
592                    +++ b/pkg/b.py\n\
593                    @@ -10,0 +11,1 @@\n\
594                    +    y = 2\n";
595        assert_eq!(
596            parse_unified_diff(diff),
597            changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
598        );
599    }
600
601    // ---- evaluate_patch (diff-scoped floor, #162) ---------------------------
602
603    fn cov(
604        executed: &[u64],
605        missing: &[u64],
606        executed_branches: &[[i64; 2]],
607        missing_branches: &[[i64; 2]],
608    ) -> FileCoverage {
609        FileCoverage {
610            executed_lines: executed.to_vec(),
611            missing_lines: missing.to_vec(),
612            excluded_lines: Vec::new(),
613            executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
614            missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
615        }
616    }
617
618    const FLOOR_85: Thresholds = Thresholds {
619        fail_under: 85,
620        branch: true,
621    };
622
623    #[test]
624    fn patch_a_fully_covered_diff_passes() {
625        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
626        assert_eq!(
627            evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
628            Outcome::Pass
629        );
630    }
631
632    #[test]
633    fn patch_below_floor_fails_and_names_the_percent() {
634        // 3 of 4 changed executable lines covered → 75% < 85.
635        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
636        let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
637        assert!(
638            matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
639            "got: {out:?}"
640        );
641    }
642
643    #[test]
644    fn patch_the_same_diff_clears_a_lower_floor() {
645        // The #162 behavior: 75% passes a 70 floor despite the uncovered line.
646        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
647        let floor_70 = Thresholds {
648            fail_under: 70,
649            branch: true,
650        };
651        assert_eq!(
652            evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
653            Outcome::Pass
654        );
655    }
656
657    #[test]
658    fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
659        // Lines 1,2 executed (2 covered) + a taken arc out of line 2 (covered) and an
660        // untaken arc out of line 2 (missed): 3 covered of 4 → 75% < 85.
661        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
662        let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
663        assert!(
664            matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
665            "got: {out:?}"
666        );
667    }
668
669    #[test]
670    fn patch_branches_off_ignores_arcs() {
671        // Same data, branch disabled: only the two executed lines count → 100%.
672        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
673        let no_branch = Thresholds {
674            fail_under: 85,
675            branch: false,
676        };
677        assert_eq!(
678            evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
679            Outcome::Pass
680        );
681    }
682
683    #[test]
684    fn patch_a_changed_file_absent_from_coverage_is_skipped() {
685        // A test file (never measured) contributes nothing; with no other executable
686        // changed line the diff is vacuously covered.
687        let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
688        assert_eq!(
689            evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
690            Outcome::Pass
691        );
692    }
693
694    #[test]
695    fn patch_a_diff_with_no_executable_changed_lines_passes() {
696        // Changed lines are comments/blanks (in neither executed nor missing) → vacuous.
697        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
698        assert_eq!(
699            evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
700            Outcome::Pass
701        );
702    }
703
704    // ---- evaluate_patch_typescript (diff-scoped TS floor, #162) -------------
705
706    use coverage::TsPatchCoverage;
707
708    fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
709        entries
710            .iter()
711            .map(|(path, cov)| (path.to_string(), cov.clone()))
712            .collect()
713    }
714
715    const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
716        lines: 80,
717        branches: 80,
718        functions: 80,
719        statements: 80,
720    };
721
722    #[test]
723    fn ts_patch_a_fully_covered_diff_passes() {
724        // Two statements on lines 1-2, both starting on their line and both covered;
725        // a covered function on line 1; a taken branch arm off line 2 → 100% all four.
726        let detail = ts_detail(&[(
727            "w.ts",
728            TsPatchCoverage {
729                statements: vec![(1, 1, true), (2, 2, true)],
730                branch_arms: vec![(2, true)],
731                functions: vec![(1, true)],
732            },
733        )]);
734        assert_eq!(
735            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
736            Outcome::Pass
737        );
738    }
739
740    #[test]
741    fn ts_patch_below_floor_fails_and_names_the_metric() {
742        // Four changed lines each carry one statement; three covered, one not →
743        // statements (and lines) 75% < 80, named; branches/functions are empty
744        // (vacuously 100) and not named.
745        let detail = ts_detail(&[(
746            "w.ts",
747            TsPatchCoverage {
748                statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
749                branch_arms: vec![],
750                functions: vec![],
751            },
752        )]);
753        let out =
754            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
755        assert!(
756            matches!(&out, Outcome::Fail(m)
757                if m.contains("statements 75.00% < 80%")
758                    && m.contains("lines 75.00% < 80%")
759                    && !m.contains("branches")
760                    && !m.contains("functions")),
761            "got: {out:?}"
762        );
763    }
764
765    #[test]
766    fn ts_patch_the_same_diff_clears_a_lower_floor() {
767        // The #162 behavior: the 75% diff passes a 70 floor despite the uncovered line.
768        let detail = ts_detail(&[(
769            "w.ts",
770            TsPatchCoverage {
771                statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
772                branch_arms: vec![],
773                functions: vec![],
774            },
775        )]);
776        let floor_70 = TypeScriptThresholds {
777            lines: 70,
778            branches: 70,
779            functions: 70,
780            statements: 70,
781        };
782        assert_eq!(
783            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
784            Outcome::Pass
785        );
786    }
787
788    #[test]
789    fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
790        // Line 3's statement ran (covered) but one of its two branch arms never did:
791        // branches 50% < 80, named; lines/statements are 100 (the statement is covered).
792        let detail = ts_detail(&[(
793            "w.ts",
794            TsPatchCoverage {
795                statements: vec![(3, 3, true)],
796                branch_arms: vec![(3, true), (3, false)],
797                functions: vec![],
798            },
799        )]);
800        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
801        assert!(
802            matches!(&out, Outcome::Fail(m)
803                if m.contains("branches 50.00% < 80%")
804                    && !m.contains("lines")
805                    && !m.contains("statements")),
806            "got: {out:?}"
807        );
808    }
809
810    #[test]
811    fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
812        // A function declared on changed line 9 was never called → functions 0% < 80.
813        let detail = ts_detail(&[(
814            "w.ts",
815            TsPatchCoverage {
816                statements: vec![],
817                branch_arms: vec![],
818                functions: vec![(9, false)],
819            },
820        )]);
821        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
822        assert!(
823            matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
824            "got: {out:?}"
825        );
826    }
827
828    #[test]
829    fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
830        // A test file (never measured) contributes nothing; with no other changed
831        // executable line the diff is vacuously covered.
832        let detail = ts_detail(&[(
833            "w.ts",
834            TsPatchCoverage {
835                statements: vec![(1, 1, true)],
836                branch_arms: vec![],
837                functions: vec![],
838            },
839        )]);
840        assert_eq!(
841            evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
842            Outcome::Pass
843        );
844    }
845
846    #[test]
847    fn ts_patch_a_comment_only_diff_passes() {
848        // The changed lines carry no statement/branch/function (a comment or blank) →
849        // every denominator empty → vacuously covered.
850        let detail = ts_detail(&[(
851            "w.ts",
852            TsPatchCoverage {
853                statements: vec![(1, 1, true), (2, 2, true)],
854                branch_arms: vec![(2, true)],
855                functions: vec![(1, true)],
856            },
857        )]);
858        assert_eq!(
859            evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
860            Outcome::Pass
861        );
862    }
863
864    #[test]
865    fn ts_patch_an_empty_diff_passes() {
866        // No changed lines at all → vacuously covered at any floor.
867        assert_eq!(
868            evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
869            Outcome::Pass
870        );
871    }
872
873    #[test]
874    fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
875        // A statement spanning lines 3-5 that never ran; only line 4 is in the diff →
876        // it still counts (and is uncovered) → statements 0% < 80. No statement
877        // *starts* on line 4, so lines has an empty denominator (vacuously 100).
878        let detail = ts_detail(&[(
879            "w.ts",
880            TsPatchCoverage {
881                statements: vec![(3, 5, false)],
882                branch_arms: vec![],
883                functions: vec![],
884            },
885        )]);
886        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
887        assert!(
888            matches!(&out, Outcome::Fail(m)
889                if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
890            "got: {out:?}"
891        );
892    }
893
894    // ---- evaluate_patch_rust (diff-scoped Rust floor, #162) -----------------
895
896    use coverage::RustPatchCoverage;
897
898    fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
899        entries
900            .iter()
901            .map(|(path, cov)| (path.to_string(), cov.clone()))
902            .collect()
903    }
904
905    const RUST_FLOOR_80: RustThresholds = RustThresholds {
906        regions: Some(80),
907        lines: 80,
908    };
909
910    #[test]
911    fn rust_patch_a_fully_covered_diff_passes() {
912        // Two single-line code regions on lines 1-2, both covered → regions and lines
913        // both 100%.
914        let detail = rust_detail(&[(
915            "w.rs",
916            RustPatchCoverage {
917                regions: vec![(1, 1, true), (2, 2, true)],
918            },
919        )]);
920        assert_eq!(
921            evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
922            Outcome::Pass
923        );
924    }
925
926    #[test]
927    fn rust_patch_below_floor_fails_and_names_the_metrics() {
928        // Four single-line regions on lines 1-4; three covered, one not → regions (and
929        // lines) 75% < 80, both named.
930        let detail = rust_detail(&[(
931            "w.rs",
932            RustPatchCoverage {
933                regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
934            },
935        )]);
936        let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
937        assert!(
938            matches!(&out, Outcome::Fail(m)
939                if m.contains("regions 75.00% < 80%")
940                    && m.contains("lines 75.00% < 80%")),
941            "got: {out:?}"
942        );
943    }
944
945    #[test]
946    fn rust_patch_the_same_diff_clears_a_lower_floor() {
947        // The #162 behavior: the 75% diff passes a 70 floor despite the uncovered region.
948        let detail = rust_detail(&[(
949            "w.rs",
950            RustPatchCoverage {
951                regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
952            },
953        )]);
954        let floor_70 = RustThresholds {
955            regions: Some(70),
956            lines: 70,
957        };
958        assert_eq!(
959            evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
960            Outcome::Pass
961        );
962    }
963
964    #[test]
965    fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
966        // The zero-config default (#206) sets `regions: None`, so the diff-scoped floor
967        // enforces lines only: a diff whose changed lines are all covered passes even
968        // though one of its regions is uncovered (lines 1-4 are each covered by ≥1
969        // region, but region 4 is not).
970        let detail = rust_detail(&[(
971            "w.rs",
972            RustPatchCoverage {
973                regions: vec![(1, 4, true), (4, 4, false)],
974            },
975        )]);
976        let lines_only = RustThresholds {
977            regions: None,
978            lines: 100,
979        };
980        assert_eq!(
981            evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
982            Outcome::Pass
983        );
984    }
985
986    #[test]
987    fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
988        // A single uncovered region on changed line 5 → regions 0% and lines 0%, both
989        // below the floor.
990        let detail = rust_detail(&[(
991            "w.rs",
992            RustPatchCoverage {
993                regions: vec![(5, 5, false)],
994            },
995        )]);
996        let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
997        assert!(
998            matches!(&out, Outcome::Fail(m)
999                if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1000            "got: {out:?}"
1001        );
1002    }
1003
1004    #[test]
1005    fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1006        // A test-only file (never measured) contributes nothing; with no other changed
1007        // measured line the diff is vacuously covered.
1008        let detail = rust_detail(&[(
1009            "w.rs",
1010            RustPatchCoverage {
1011                regions: vec![(1, 1, true)],
1012            },
1013        )]);
1014        assert_eq!(
1015            evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1016            Outcome::Pass
1017        );
1018    }
1019
1020    #[test]
1021    fn rust_patch_a_comment_only_diff_passes() {
1022        // The changed lines (9-10) carry no region (a comment or blank) → both
1023        // denominators empty → vacuously covered.
1024        let detail = rust_detail(&[(
1025            "w.rs",
1026            RustPatchCoverage {
1027                regions: vec![(1, 1, true), (2, 2, true)],
1028            },
1029        )]);
1030        assert_eq!(
1031            evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1032            Outcome::Pass
1033        );
1034    }
1035
1036    #[test]
1037    fn rust_patch_an_empty_diff_passes() {
1038        // No changed lines at all → vacuously covered at any floor.
1039        assert_eq!(
1040            evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1041            Outcome::Pass
1042        );
1043    }
1044
1045    #[test]
1046    fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1047        // A region spanning lines 3-5 that never ran; only line 4 is in the diff → it
1048        // still counts for both metrics (the region spans line 4, so line 4 is a
1049        // measured-but-uncovered line) → regions 0% and lines 0% < 80.
1050        let detail = rust_detail(&[(
1051            "w.rs",
1052            RustPatchCoverage {
1053                regions: vec![(3, 5, false)],
1054            },
1055        )]);
1056        let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1057        assert!(
1058            matches!(&out, Outcome::Fail(m)
1059                if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1060            "got: {out:?}"
1061        );
1062    }
1063
1064    #[test]
1065    fn rust_patch_a_line_covered_by_any_region_is_covered() {
1066        // Two overlapping regions span changed line 4 — one uncovered, one covered.
1067        // For the lines metric the line is covered (≥1 covering region's flag is set);
1068        // for regions, one of the two counts as covered → regions 50% (< 80, fails) but
1069        // lines 100% (≥ 80, not named).
1070        let detail = rust_detail(&[(
1071            "w.rs",
1072            RustPatchCoverage {
1073                regions: vec![(4, 4, false), (4, 6, true)],
1074            },
1075        )]);
1076        let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1077        assert!(
1078            matches!(&out, Outcome::Fail(m)
1079                if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1080            "got: {out:?}"
1081        );
1082    }
1083}