Skip to main content

testing_conventions/
patch_coverage.rs

1//! Diff-scoped coverage floor: the thresholds `unit coverage` enforces whole-tree,
2//! measured over only the lines `<base>...HEAD` added or modified. Each language pairs
3//! a `measure*` that shells out to its tool with a pure `evaluate_patch*` over the diff.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::Path;
7use std::process::Command;
8
9use anyhow::{bail, Context, Result};
10
11use crate::coverage::{
12    self, FileCoverage, Outcome, RustThresholds, Thresholds, TypeScriptThresholds,
13};
14
15/// TypeScript source extensions the diff-scoped floor scopes to — the set
16/// `coverage`'s `TS_INCLUDE` measures.
17const TS_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"];
18
19/// Diff-scoped Python coverage floor: measure `thresholds` over the `<base>...HEAD`
20/// changed `.py` lines instead of the whole tree. `omit` is the `coverage`-rule
21/// exemptions; an exempt file's changed lines drop out of the ratio with it.
22pub fn measure(
23    root: &Path,
24    base: &str,
25    thresholds: Thresholds,
26    omit: &[String],
27    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
28) -> Result<Outcome> {
29    let mut changed = changed_lines(root, base)?;
30    changed.retain(|path, _| path.ends_with(".py"));
31    lift_exempt_lines(&mut changed, exempt_lines);
32    if changed.is_empty() {
33        return Ok(Outcome::Pass);
34    }
35    let report = coverage::measure_report(root, omit)?;
36    let files = relative_keys(report.files, root);
37    Ok(evaluate_patch(&changed, &files, thresholds))
38}
39
40/// Drop the line-scoped `coverage` exemptions from a `--base` diff's changed-line set.
41/// The over-exemption guard belongs to the whole-tree floor ([`measure_line_exempt`]),
42/// since the diff job can't classify a line its own diff didn't touch.
43fn lift_exempt_lines(
44    changed: &mut BTreeMap<String, BTreeSet<u64>>,
45    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
46) {
47    for (file, exempt) in exempt_lines {
48        if let Some(lines) = changed.get_mut(file) {
49            lines.retain(|&line| !u32::try_from(line).is_ok_and(|line| exempt.contains(&line)));
50        }
51    }
52}
53
54/// Pure: the configured floor over the changed lines. Reproduces coverage.py's
55/// `percent_covered` — (executed lines + taken arcs) ÷ (executable lines + all arcs) —
56/// restricted to the diff; a diff in which nothing executable changed is vacuously covered.
57fn evaluate_patch(
58    changed: &BTreeMap<String, BTreeSet<u64>>,
59    files: &BTreeMap<String, FileCoverage>,
60    thresholds: Thresholds,
61) -> Outcome {
62    let (covered, total) = python_ratio(changed, files, thresholds.branch);
63    if total == 0 {
64        return Outcome::Pass;
65    }
66    let actual = 100.0 * covered as f64 / total as f64;
67    // A hair of tolerance so a percent that rounds to the floor isn't failed by float
68    // noise (matches the whole-tree `coverage::evaluate`).
69    if actual + 1e-9 >= f64::from(thresholds.fail_under) {
70        Outcome::Pass
71    } else {
72        Outcome::Fail(format!(
73            "changed-line coverage {actual:.2}% is below the required {}%",
74            thresholds.fail_under
75        ))
76    }
77}
78
79/// Pure: coverage.py's `percent_covered` numerator/denominator restricted to `selected`.
80/// Shared by the diff-scoped floor ([`evaluate_patch`], selected = the changed lines) and
81/// the line-scoped one ([`measure_line_exempt`], selected = measured minus exempt).
82fn python_ratio(
83    selected: &BTreeMap<String, BTreeSet<u64>>,
84    files: &BTreeMap<String, FileCoverage>,
85    branch: bool,
86) -> (u64, u64) {
87    let mut covered: u64 = 0;
88    let mut total: u64 = 0;
89    for (file, lines) in selected {
90        let Some(cov) = files.get(file) else {
91            continue;
92        };
93        let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
94        let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
95        for &line in lines {
96            if executed.contains(&line) {
97                covered += 1;
98                total += 1;
99            } else if missing.contains(&line) {
100                total += 1;
101            }
102        }
103        if branch {
104            for arc in &cov.executed_branches {
105                if arc_source_in(arc, lines) {
106                    covered += 1;
107                    total += 1;
108                }
109            }
110            for arc in &cov.missing_branches {
111                if arc_source_in(arc, lines) {
112                    total += 1;
113                }
114            }
115        }
116    }
117    (covered, total)
118}
119
120/// Whether a branch arc's source line (the first of its `[src, dst]` pair) is in `lines`.
121fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
122    arc.first()
123        .and_then(|&src| u64::try_from(src).ok())
124        .is_some_and(|src| lines.contains(&src))
125}
126
127/// Diff-scoped TypeScript coverage floor: the four vitest metrics measured over the
128/// `<base>...HEAD` changed `.ts`/`.tsx`/`.mts`/`.cts` lines instead of the whole tree.
129/// `exclude` is the `coverage`-rule exemptions; an excluded file's lines drop out with it.
130pub fn measure_typescript(
131    root: &Path,
132    base: &str,
133    thresholds: TypeScriptThresholds,
134    exclude: &[String],
135    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
136) -> Result<Outcome> {
137    let mut changed = changed_lines(root, base)?;
138    changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
139    lift_exempt_lines(&mut changed, exempt_lines);
140    if changed.is_empty() {
141        return Ok(Outcome::Pass);
142    }
143    let detail = relative_keys(
144        coverage::measure_patch_typescript_detail(root, exclude)?,
145        root,
146    );
147    Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
148}
149
150/// A covered-of-total tally over the changed lines.
151#[derive(Debug, Default, Clone, Copy, PartialEq)]
152struct Tally {
153    covered: u64,
154    total: u64,
155}
156
157impl Tally {
158    /// Count one site. An uncovered site raises only the denominator.
159    fn count(&mut self, covered: bool) {
160        self.total += 1;
161        self.covered += u64::from(covered);
162    }
163
164    fn add(&mut self, other: Tally) {
165        self.covered += other.covered;
166        self.total += other.total;
167    }
168
169    /// The tally as a percent. An empty denominator is vacuously full, not a failure.
170    fn percent(self) -> f64 {
171        if self.total == 0 {
172            100.0
173        } else {
174            100.0 * self.covered as f64 / self.total as f64
175        }
176    }
177}
178
179/// Pure: the four vitest floors over the changed lines.
180fn evaluate_patch_typescript(
181    changed: &BTreeMap<String, BTreeSet<u64>>,
182    detail: &BTreeMap<String, coverage::TsPatchCoverage>,
183    thresholds: TypeScriptThresholds,
184) -> Outcome {
185    let tallies = typescript_tallies(changed, detail);
186    let checks = [
187        ("lines", tallies.lines.percent(), thresholds.lines),
188        ("branches", tallies.branches.percent(), thresholds.branches),
189        (
190            "functions",
191            tallies.functions.percent(),
192            thresholds.functions,
193        ),
194        (
195            "statements",
196            tallies.statements.percent(),
197            thresholds.statements,
198        ),
199    ];
200    coverage::verdict(
201        checks
202            .into_iter()
203            .filter_map(|(name, actual, required)| coverage::shortfall(name, actual, required))
204            .collect(),
205    )
206}
207
208/// The four vitest tallies the diff-scoped floor enforces, accumulated together.
209#[derive(Default)]
210struct TsTallies {
211    statements: Tally,
212    lines: Tally,
213    branches: Tally,
214    functions: Tally,
215}
216
217/// Accumulate the four tallies over only the changed lines of each file vitest measured. A
218/// changed file absent from `detail` contributes nothing — vitest never measured it, so it
219/// has no coverage to judge rather than zero coverage.
220fn typescript_tallies(
221    changed: &BTreeMap<String, BTreeSet<u64>>,
222    detail: &BTreeMap<String, coverage::TsPatchCoverage>,
223) -> TsTallies {
224    let mut tallies = TsTallies::default();
225    for (file, touched) in changed {
226        let Some(cov) = detail.get(file) else {
227            continue;
228        };
229        tallies
230            .statements
231            .add(statements_tally(&cov.statements, touched));
232        tallies.lines.add(lines_tally(&cov.statements, touched));
233        tallies
234            .branches
235            .add(on_line_tally(&cov.branch_arms, touched));
236        tallies
237            .functions
238            .add(on_line_tally(&cov.functions, touched));
239    }
240    tallies
241}
242
243/// A statement counts when the diff touches any line it spans.
244fn statements_tally(statements: &[(u64, u64, bool)], touched: &BTreeSet<u64>) -> Tally {
245    let mut tally = Tally::default();
246    for &(start, end, covered) in statements {
247        if (start..=end).any(|line| touched.contains(&line)) {
248            tally.count(covered);
249        }
250    }
251    tally
252}
253
254/// A changed line counts when a statement *starts* on it, and is covered when any statement
255/// starting there ran — one line can hold several.
256fn lines_tally(statements: &[(u64, u64, bool)], touched: &BTreeSet<u64>) -> Tally {
257    let mut tally = Tally::default();
258    for &line in touched {
259        let mut starts_here = false;
260        let mut covered_here = false;
261        for &(start, _end, covered) in statements {
262            if start == line {
263                starts_here = true;
264                covered_here |= covered;
265            }
266        }
267        if starts_here {
268            tally.count(covered_here);
269        }
270    }
271    tally
272}
273
274/// A branch arm or a function declaration counts when the diff touches its own line.
275fn on_line_tally(sites: &[(u64, bool)], touched: &BTreeSet<u64>) -> Tally {
276    let mut tally = Tally::default();
277    for &(line, covered) in sites {
278        if touched.contains(&line) {
279            tally.count(covered);
280        }
281    }
282    tally
283}
284
285/// Diff-scoped Rust coverage floor: the `cargo llvm-cov` regions/lines metrics measured
286/// over the `<base>...HEAD` changed `.rs` lines instead of the whole tree. `ignore` is the
287/// `coverage`-rule exemptions; an exempt file's lines drop out of the ratios with it.
288pub fn measure_rust(
289    root: &Path,
290    base: &str,
291    thresholds: RustThresholds,
292    ignore: &[String],
293    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
294    features: &[String],
295) -> Result<Outcome> {
296    let mut changed = changed_lines(root, base)?;
297    changed.retain(|path, _| path.ends_with(".rs"));
298    lift_exempt_lines(&mut changed, exempt_lines);
299    if changed.is_empty() {
300        return Ok(Outcome::Pass);
301    }
302    let detail = relative_keys(
303        coverage::measure_patch_rust_detail(root, ignore, features)?,
304        root,
305    );
306    Ok(crate::rust_patch_coverage::evaluate_patch_rust(
307        &changed, &detail, thresholds,
308    ))
309}
310
311/// The new-side lines each file gained in `repo`'s `<base>...HEAD` merge-base diff, keyed
312/// by `repo`-relative path. Pinned against the caller's git config — `core.quotepath=off`,
313/// `--no-ext-diff`, forced `a/`/`b/` prefixes — so [`new_side_path`]'s `b/` strip holds.
314pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
315    let range = format!("{base}...HEAD");
316    let output = Command::new("git")
317        .current_dir(repo)
318        .args(unified_diff_argv(&range))
319        .output()
320        .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
321    diff_exit(&output, repo, &range)?;
322    Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
323}
324
325/// The `git diff` argv `changed_lines` runs. Every flag is load-bearing: the config
326/// overrides and prefix flags pin the output against the caller's git config so
327/// [`new_side_path`]'s `b/` strip holds, and `--unified=0` keeps context lines out of the
328/// changed set.
329fn unified_diff_argv(range: &str) -> [&str; 11] {
330    [
331        "-c",
332        "core.quotepath=off",
333        "diff",
334        "--no-color",
335        "--no-ext-diff",
336        "--no-renames",
337        "--unified=0",
338        "--relative",
339        "--src-prefix=a/",
340        "--dst-prefix=b/",
341        range,
342    ]
343}
344
345/// The error a failed `git diff` becomes, naming the range and the repo. Kept out of
346/// [`changed_lines`] so that seam stays branch-free.
347fn diff_exit(output: &std::process::Output, repo: &Path, range: &str) -> Result<()> {
348    if !output.status.success() {
349        bail!(
350            "`git diff {range}` failed in `{}`: {}",
351            repo.display(),
352            String::from_utf8_lossy(&output.stderr).trim()
353        );
354    }
355    Ok(())
356}
357
358/// Pure: parse `git diff --unified=0` output into the new-side lines each file gained.
359/// Hunk state guards header detection: a `+++ ` line is a file header only before the
360/// first `@@`; inside a hunk it is body — an added line whose content began `++ `.
361fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
362    let mut scan = DiffScan::default();
363    for line in diff.lines() {
364        scan.feed(line);
365    }
366    scan.changed
367}
368
369/// The walk's position in a `git diff --unified=0` stream: which file's header was last
370/// seen, which new-side line the next `+` lands on, and whether a hunk is open. The four
371/// move together, so they are one value rather than four locals.
372#[derive(Default)]
373struct DiffScan {
374    changed: BTreeMap<String, BTreeSet<u64>>,
375    current: Option<String>,
376    next_line: u64,
377    in_hunk: bool,
378}
379
380impl DiffScan {
381    /// Advance the scan by one line of diff output.
382    fn feed(&mut self, line: &str) {
383        if line.starts_with("diff --git ") {
384            self.in_hunk = false;
385            self.current = None;
386        } else if line.starts_with("@@") {
387            self.in_hunk = true;
388            if let Some(start) = hunk_new_start(line) {
389                self.next_line = start;
390            }
391        } else if !self.in_hunk {
392            if let Some(header) = line.strip_prefix("+++ ") {
393                self.current = new_side_path(header);
394            }
395        } else if line.starts_with('+') {
396            if let Some(file) = &self.current {
397                self.changed
398                    .entry(file.clone())
399                    .or_default()
400                    .insert(self.next_line);
401            }
402            self.next_line += 1;
403        }
404    }
405}
406
407/// The `repo`-relative new-side path from a `+++` diff header, or `None` for a deletion
408/// (`+++ /dev/null`). Decodes a C-quoted path before stripping git's `b/` prefix.
409fn new_side_path(header: &str) -> Option<String> {
410    let raw = header
411        .split('\t')
412        .next()
413        .unwrap_or(header)
414        .trim_end_matches('\r');
415    if raw == "/dev/null" {
416        return None;
417    }
418    // Git quotes the whole thing including the prefix (`"b/föö.py"`), so decode first.
419    let unquoted = crate::git_path::unquote_c_path(raw);
420    let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
421    Some(path.replace('\\', "/"))
422}
423
424/// The new-side start line from a hunk header `@@ -a,b +c,d @@ …` — the `c`. With
425/// `--unified=0` the added lines that follow are numbered consecutively from it.
426fn hunk_new_start(header: &str) -> Option<u64> {
427    let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
428    let digits = plus.trim_start_matches('+');
429    digits.split(',').next().unwrap_or(digits).parse().ok()
430}
431
432/// Re-key a report's per-file map to `root`-relative `/`-joined paths so they match the
433/// diff's. coverage.py reports relative to where it ran (here `root`) and vitest reports
434/// absolute; an absolute path is stripped to `root`, a relative one left as-is.
435fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
436    files
437        .into_iter()
438        .map(|(key, value)| {
439            let path = Path::new(&key);
440            let rel = path
441                .strip_prefix(root)
442                .unwrap_or(path)
443                .to_string_lossy()
444                .replace('\\', "/");
445            (rel, value)
446        })
447        .collect()
448}
449
450/// Diff-free Python coverage floor with line-scoped exemptions: measure `thresholds` over
451/// every measured line except the `exempt_lines`. `omit` is the whole-file `coverage`
452/// exemptions. Runs only when `exempt_lines` is non-empty.
453pub fn measure_line_exempt(
454    root: &Path,
455    thresholds: Thresholds,
456    omit: &[String],
457    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
458) -> Result<Outcome> {
459    let report = coverage::measure_report(root, omit)?;
460    let files = relative_keys(report.files, root);
461    let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
462        .iter()
463        .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
464        .collect();
465    let line_set = apply_line_exemptions(&detail, exempt_lines)?;
466    let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
467    Ok(floor_outcome(covered, total, thresholds.fail_under))
468}
469
470/// TypeScript twin of [`measure_line_exempt`]: the four vitest metrics over every measured
471/// line except the `exempt_lines`. `exclude` is the whole-file `coverage` exemptions.
472pub fn measure_line_exempt_typescript(
473    root: &Path,
474    thresholds: TypeScriptThresholds,
475    exclude: &[String],
476    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
477) -> Result<Outcome> {
478    let detail = relative_keys(
479        coverage::measure_patch_typescript_detail(root, exclude)?,
480        root,
481    );
482    let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
483        .iter()
484        .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
485        .collect();
486    let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
487    Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
488}
489
490/// Rust twin of [`measure_line_exempt`]: the `cargo llvm-cov` regions/lines metrics over
491/// every measured line except the `exempt_lines`. `ignore` is the whole-file exemptions.
492pub fn measure_line_exempt_rust(
493    root: &Path,
494    thresholds: RustThresholds,
495    ignore: &[String],
496    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
497    features: &[String],
498) -> Result<Outcome> {
499    let detail = relative_keys(
500        coverage::measure_patch_rust_detail(root, ignore, features)?,
501        root,
502    );
503    let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
504        .iter()
505        .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
506        .collect();
507    let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
508    Ok(crate::rust_patch_coverage::evaluate_patch_rust(
509        &line_set, &detail, thresholds,
510    ))
511}
512
513/// The whole-tree floor verdict for a recomputed `covered`/`total`, with the same message
514/// and float tolerance as the tool-total [`crate::coverage::evaluate`].
515fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
516    if total == 0 {
517        return Outcome::Pass;
518    }
519    let actual = 100.0 * covered as f64 / total as f64;
520    if actual + 1e-9 >= f64::from(fail_under) {
521        Outcome::Pass
522    } else {
523        Outcome::Fail(format!(
524            "coverage {actual:.2}% is below the required {fail_under}%"
525        ))
526    }
527}
528
529/// The `(measured, missed)` lines for one Python file. `missed` is the uncovered lines,
530/// plus (under branch coverage) any line that is the source of an untaken branch arc.
531fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
532    let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
533    let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
534    let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
535    let mut missed = missing;
536    if branch {
537        for arc in &cov.missing_branches {
538            if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
539                if measured.contains(&src) {
540                    missed.insert(src);
541                }
542            }
543        }
544    }
545    (measured, missed)
546}
547
548/// The `(measured, missed)` lines for one TypeScript file. A unit is anchored on the lines
549/// it spans, so a line carrying any uncovered unit is exemptable.
550fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
551    let mut measured = BTreeSet::new();
552    let mut missed = BTreeSet::new();
553    let units = cov
554        .statements
555        .iter()
556        .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
557        .chain(cov.branch_arms.iter().copied())
558        .chain(cov.functions.iter().copied());
559    for (line, covered) in units {
560        measured.insert(line);
561        if !covered {
562            missed.insert(line);
563        }
564    }
565    (measured, missed)
566}
567
568/// The `(measured, missed)` lines for one Rust file. `missed` honors the enforced metrics
569/// — with `regions` on, any line in an uncovered region; with lines-only, a line covered
570/// by regions but by no *covered* one.
571fn rust_measured_missed(
572    cov: &coverage::RustPatchCoverage,
573    thresholds: RustThresholds,
574) -> (BTreeSet<u64>, BTreeSet<u64>) {
575    let measured = measured_lines(cov);
576    let missed = measured
577        .iter()
578        .copied()
579        .filter(|&line| is_missed(cov, line, thresholds))
580        .collect();
581    (measured, missed)
582}
583
584/// Every line any region of `cov` spans — the lines llvm-cov actually measured for the file.
585fn measured_lines(cov: &coverage::RustPatchCoverage) -> BTreeSet<u64> {
586    let mut measured = BTreeSet::new();
587    for &(start, end, _covered) in &cov.regions {
588        for line in start..=end {
589            measured.insert(line);
590        }
591    }
592    measured
593}
594
595/// Whether `line` counts as missed, which the enforced metrics decide. With a region floor
596/// on, a line is missed when *any* region over it is uncovered — region coverage is the
597/// stricter reading. With lines only, a line is missed when *no* region over it is covered,
598/// so one covered region redeems it.
599fn is_missed(cov: &coverage::RustPatchCoverage, line: u64, thresholds: RustThresholds) -> bool {
600    let mut covered_here = false;
601    let mut uncovered_region = false;
602    for &(start, end, covered) in &cov.regions {
603        if start <= line && line <= end {
604            if covered {
605                covered_here = true;
606            } else {
607                uncovered_region = true;
608            }
609        }
610    }
611    if thresholds.regions.is_some() {
612        uncovered_region
613    } else {
614        !covered_here
615    }
616}
617
618/// The per-file line set the floor is measured over — every measured line minus the exempt
619/// ones, after the determinism guard.
620fn apply_line_exemptions(
621    detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
622    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
623) -> Result<BTreeMap<String, BTreeSet<u64>>> {
624    guard_exempt_lines_are_failing(detail, exempt_lines)?;
625    Ok(detail
626        .iter()
627        .map(|(file, (measured, _))| (file.clone(), kept_lines(measured, exempt_lines.get(file))))
628        .collect())
629}
630
631/// The determinism guard: each exempt line must be genuinely failing, so an exemption can't
632/// excuse working code.
633fn guard_exempt_lines_are_failing(
634    detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
635    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
636) -> Result<()> {
637    let mut over: Vec<String> = Vec::new();
638    for (file, lines) in exempt_lines {
639        let missed = detail.get(file).map(|(_, missed)| missed);
640        for &line in lines {
641            let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
642            if !failing {
643                over.push(format!("\n  {file}:{line}"));
644            }
645        }
646    }
647    if !over.is_empty() {
648        bail!(
649            "a line-scoped coverage exemption may only list uncovered lines, but these are \
650             covered or carry no measured code:{}",
651            over.concat()
652        );
653    }
654    Ok(())
655}
656
657/// `measured` less the `exempt` lines. A measured line too large for a `u32` can carry no
658/// exemption, since an exemption is written as one.
659fn kept_lines(measured: &BTreeSet<u64>, exempt: Option<&BTreeSet<u32>>) -> BTreeSet<u64> {
660    measured
661        .iter()
662        .copied()
663        .filter(|&line| {
664            !exempt
665                .is_some_and(|exempt| u32::try_from(line).is_ok_and(|line| exempt.contains(&line)))
666        })
667        .collect()
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn the_diff_argv_pins_every_flag_its_doc_comment_calls_load_bearing() {
676        let argv = unified_diff_argv("main...HEAD");
677        // The config override and prefix flags pin the output against the caller's git
678        // config, which is what lets `new_side_path` strip a fixed `b/`.
679        assert!(argv.starts_with(&["-c", "core.quotepath=off", "diff"]));
680        assert!(argv.contains(&"--src-prefix=a/"));
681        assert!(argv.contains(&"--dst-prefix=b/"));
682        assert!(argv.contains(&"--no-ext-diff"));
683        assert!(argv.contains(&"--no-color"));
684        assert!(argv.contains(&"--no-renames"));
685        // Context lines are not changed lines.
686        assert!(argv.contains(&"--unified=0"));
687        assert_eq!(argv.last(), Some(&"main...HEAD"));
688    }
689
690    #[test]
691    fn a_failed_diff_names_the_range_and_the_stderr() {
692        let output = std::process::Output {
693            status: failed_status(),
694            stdout: Vec::new(),
695            stderr: b"fatal: bad revision".to_vec(),
696        };
697        let err = diff_exit(&output, Path::new("/repo"), "nope...HEAD").unwrap_err();
698        let text = err.to_string();
699        assert!(text.contains("nope...HEAD"), "got: {text}");
700        assert!(text.contains("fatal: bad revision"), "got: {text}");
701    }
702
703    #[test]
704    fn a_successful_diff_is_not_an_error() {
705        let output = std::process::Output {
706            status: ok_status(),
707            stdout: Vec::new(),
708            stderr: Vec::new(),
709        };
710        assert!(diff_exit(&output, Path::new("/repo"), "main...HEAD").is_ok());
711    }
712
713    #[test]
714    fn measured_lines_spans_every_region_inclusive_of_both_ends() {
715        let cov = coverage::RustPatchCoverage {
716            regions: vec![(3, 5, true), (9, 9, false)],
717        };
718        assert_eq!(
719            measured_lines(&cov),
720            [3, 4, 5, 9].into_iter().collect::<BTreeSet<u64>>()
721        );
722    }
723
724    #[test]
725    fn with_a_region_floor_any_uncovered_region_misses_the_line() {
726        // Line 4 is spanned by one covered and one uncovered region.
727        let cov = coverage::RustPatchCoverage {
728            regions: vec![(4, 4, true), (4, 4, false)],
729        };
730        assert!(is_missed(&cov, 4, region_thresholds()));
731    }
732
733    #[test]
734    fn with_lines_only_one_covered_region_redeems_the_line() {
735        let cov = coverage::RustPatchCoverage {
736            regions: vec![(4, 4, true), (4, 4, false)],
737        };
738        assert!(!is_missed(&cov, 4, lines_only_thresholds()));
739    }
740
741    #[test]
742    fn a_line_no_region_covers_is_missed_under_either_reading() {
743        let cov = coverage::RustPatchCoverage {
744            regions: vec![(4, 4, false)],
745        };
746        assert!(is_missed(&cov, 4, region_thresholds()));
747        assert!(is_missed(&cov, 4, lines_only_thresholds()));
748    }
749
750    fn region_thresholds() -> RustThresholds {
751        RustThresholds {
752            regions: Some(100),
753            lines: 100,
754            functions: None,
755            branch: None,
756        }
757    }
758
759    fn lines_only_thresholds() -> RustThresholds {
760        RustThresholds {
761            regions: None,
762            lines: 100,
763            functions: None,
764            branch: None,
765        }
766    }
767
768    /// An exit status built directly rather than spawned: `/bin/true` and `/bin/false` would
769    /// be two real processes to manufacture a value `ExitStatusExt` hands over for free.
770    fn status(code: i32) -> std::process::ExitStatus {
771        use std::os::unix::process::ExitStatusExt;
772        std::process::ExitStatus::from_raw(code << 8)
773    }
774
775    fn ok_status() -> std::process::ExitStatus {
776        status(0)
777    }
778
779    fn failed_status() -> std::process::ExitStatus {
780        status(1)
781    }
782
783    fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
784        entries
785            .iter()
786            .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
787            .collect()
788    }
789
790    #[test]
791    fn changed_lines_reports_a_spawn_failure() {
792        let err = changed_lines(Path::new("/nonexistent-tc-patch-coverage"), "main").unwrap_err();
793        assert!(err.to_string().contains("running `git diff`"), "got: {err}");
794    }
795
796    #[test]
797    fn parses_added_lines_from_a_hunk() {
798        let diff = "diff --git a/widget.py b/widget.py\n\
799                    index abc..def 100644\n\
800                    --- a/widget.py\n\
801                    +++ b/widget.py\n\
802                    @@ -3,0 +4,2 @@ def f(x):\n\
803                    +    if x == 99:\n\
804                    +        return 7\n";
805        assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
806    }
807
808    #[test]
809    fn parses_a_new_file_as_added_from_line_one() {
810        let diff = "diff --git a/lonely.py b/lonely.py\n\
811                    new file mode 100644\n\
812                    index 0000000..bbb\n\
813                    --- /dev/null\n\
814                    +++ b/lonely.py\n\
815                    @@ -0,0 +1,2 @@\n\
816                    +def lonely():\n\
817                    +    return 41\n";
818        assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
819    }
820
821    #[test]
822    fn a_deletion_only_hunk_records_no_added_lines() {
823        let diff = "diff --git a/widget.py b/widget.py\n\
824                    index abc..def 100644\n\
825                    --- a/widget.py\n\
826                    +++ b/widget.py\n\
827                    @@ -4,2 +3,0 @@ def f(x):\n\
828                    -    dead = 1\n\
829                    -    return dead\n";
830        assert!(parse_unified_diff(diff).is_empty());
831    }
832
833    #[test]
834    fn a_deleted_file_yields_no_entry() {
835        let diff = "diff --git a/gone.py b/gone.py\n\
836                    deleted file mode 100644\n\
837                    index abc..0000000\n\
838                    --- a/gone.py\n\
839                    +++ /dev/null\n\
840                    @@ -1,2 +0,0 @@\n\
841                    -def gone():\n\
842                    -    return 0\n";
843        assert!(parse_unified_diff(diff).is_empty());
844    }
845
846    #[test]
847    fn parses_multiple_files_and_a_single_line_hunk() {
848        let diff = "diff --git a/a.py b/a.py\n\
849                    --- a/a.py\n\
850                    +++ b/a.py\n\
851                    @@ -1,0 +2 @@ def a():\n\
852                    +    x = 1\n\
853                    diff --git a/pkg/b.py b/pkg/b.py\n\
854                    --- a/pkg/b.py\n\
855                    +++ b/pkg/b.py\n\
856                    @@ -10,0 +11,1 @@\n\
857                    +    y = 2\n";
858        assert_eq!(
859            parse_unified_diff(diff),
860            changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
861        );
862    }
863
864    #[test]
865    fn a_plus_plus_body_line_is_not_a_file_header() {
866        // An added line whose content begins `++ ` renders as `+++ …` — hunk *body*, not a
867        // `+++` file header. Read as a header it would divert the file's later added lines
868        // to a bogus key, dropping them from scoping: a false green.
869        let diff = "diff --git a/w.py b/w.py\n\
870                    index abc..def 100644\n\
871                    --- a/w.py\n\
872                    +++ b/w.py\n\
873                    @@ -1,0 +1,3 @@\n\
874                    +++ 1\n\
875                    +y = 1\n\
876                    +z = 2\n";
877        assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
878    }
879
880    #[test]
881    fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
882        // With `core.quotepath` on (git's default) a non-ASCII header path is C-quoted:
883        // `src/föö.py` → `"b/src/f\303\266\303\266.py"`. Left quoted it matches no
884        // coverage-report key, so the changed lines are silently skipped — a vacuous pass.
885        assert_eq!(
886            new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
887            Some("src/föö.py")
888        );
889        assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
890    }
891
892    fn cov(
893        executed: &[u64],
894        missing: &[u64],
895        executed_branches: &[[i64; 2]],
896        missing_branches: &[[i64; 2]],
897    ) -> FileCoverage {
898        FileCoverage {
899            executed_lines: executed.to_vec(),
900            missing_lines: missing.to_vec(),
901            excluded_lines: Vec::new(),
902            executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
903            missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
904        }
905    }
906
907    const FLOOR_85: Thresholds = Thresholds {
908        fail_under: 85,
909        branch: true,
910    };
911
912    #[test]
913    fn patch_a_fully_covered_diff_passes() {
914        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
915        assert_eq!(
916            evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
917            Outcome::Pass
918        );
919    }
920
921    #[test]
922    fn patch_below_floor_fails_and_names_the_percent() {
923        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
924        let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
925        assert!(
926            matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
927            "got: {out:?}"
928        );
929    }
930
931    #[test]
932    fn patch_the_same_diff_clears_a_lower_floor() {
933        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
934        let floor_70 = Thresholds {
935            fail_under: 70,
936            branch: true,
937        };
938        assert_eq!(
939            evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
940            Outcome::Pass
941        );
942    }
943
944    #[test]
945    fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
946        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
947        let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
948        assert!(
949            matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
950            "got: {out:?}"
951        );
952    }
953
954    #[test]
955    fn patch_branches_off_ignores_arcs() {
956        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
957        let no_branch = Thresholds {
958            fail_under: 85,
959            branch: false,
960        };
961        assert_eq!(
962            evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
963            Outcome::Pass
964        );
965    }
966
967    #[test]
968    fn patch_a_changed_file_absent_from_coverage_is_skipped() {
969        let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
970        assert_eq!(
971            evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
972            Outcome::Pass
973        );
974    }
975
976    #[test]
977    fn patch_a_diff_with_no_executable_changed_lines_passes() {
978        let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
979        assert_eq!(
980            evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
981            Outcome::Pass
982        );
983    }
984
985    use coverage::TsPatchCoverage;
986
987    fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
988        entries
989            .iter()
990            .map(|(path, cov)| (path.to_string(), cov.clone()))
991            .collect()
992    }
993
994    const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
995        lines: 80,
996        branches: 80,
997        functions: 80,
998        statements: 80,
999    };
1000
1001    #[test]
1002    fn ts_patch_a_fully_covered_diff_passes() {
1003        let detail = ts_detail(&[(
1004            "w.ts",
1005            TsPatchCoverage {
1006                statements: vec![(1, 1, true), (2, 2, true)],
1007                branch_arms: vec![(2, true)],
1008                functions: vec![(1, true)],
1009            },
1010        )]);
1011        assert_eq!(
1012            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1013            Outcome::Pass
1014        );
1015    }
1016
1017    #[test]
1018    fn ts_patch_below_floor_fails_and_names_the_metric() {
1019        let detail = ts_detail(&[(
1020            "w.ts",
1021            TsPatchCoverage {
1022                statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1023                branch_arms: vec![],
1024                functions: vec![],
1025            },
1026        )]);
1027        let out =
1028            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
1029        assert!(
1030            matches!(&out, Outcome::Fail(m)
1031                if m.contains("statements 75.00% < 80%")
1032                    && m.contains("lines 75.00% < 80%")
1033                    && !m.contains("branches")
1034                    && !m.contains("functions")),
1035            "got: {out:?}"
1036        );
1037    }
1038
1039    #[test]
1040    fn ts_patch_the_same_diff_clears_a_lower_floor() {
1041        let detail = ts_detail(&[(
1042            "w.ts",
1043            TsPatchCoverage {
1044                statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1045                branch_arms: vec![],
1046                functions: vec![],
1047            },
1048        )]);
1049        let floor_70 = TypeScriptThresholds {
1050            lines: 70,
1051            branches: 70,
1052            functions: 70,
1053            statements: 70,
1054        };
1055        assert_eq!(
1056            evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
1057            Outcome::Pass
1058        );
1059    }
1060
1061    #[test]
1062    fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
1063        let detail = ts_detail(&[(
1064            "w.ts",
1065            TsPatchCoverage {
1066                statements: vec![(3, 3, true)],
1067                branch_arms: vec![(3, true), (3, false)],
1068                functions: vec![],
1069            },
1070        )]);
1071        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1072        assert!(
1073            matches!(&out, Outcome::Fail(m)
1074                if m.contains("branches 50.00% < 80%")
1075                    && !m.contains("lines")
1076                    && !m.contains("statements")),
1077            "got: {out:?}"
1078        );
1079    }
1080
1081    #[test]
1082    fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1083        let detail = ts_detail(&[(
1084            "w.ts",
1085            TsPatchCoverage {
1086                statements: vec![],
1087                branch_arms: vec![],
1088                functions: vec![(9, false)],
1089            },
1090        )]);
1091        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1092        assert!(
1093            matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1094            "got: {out:?}"
1095        );
1096    }
1097
1098    #[test]
1099    fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1100        let detail = ts_detail(&[(
1101            "w.ts",
1102            TsPatchCoverage {
1103                statements: vec![(1, 1, true)],
1104                branch_arms: vec![],
1105                functions: vec![],
1106            },
1107        )]);
1108        assert_eq!(
1109            evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1110            Outcome::Pass
1111        );
1112    }
1113
1114    #[test]
1115    fn ts_patch_a_comment_only_diff_passes() {
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        assert_eq!(
1133            evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1134            Outcome::Pass
1135        );
1136    }
1137
1138    #[test]
1139    fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1140        let detail = ts_detail(&[(
1141            "w.ts",
1142            TsPatchCoverage {
1143                statements: vec![(3, 5, false)],
1144                branch_arms: vec![],
1145                functions: vec![],
1146            },
1147        )]);
1148        let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1149        assert!(
1150            matches!(&out, Outcome::Fail(m)
1151                if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1152            "got: {out:?}"
1153        );
1154    }
1155
1156    fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1157        entries
1158            .iter()
1159            .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1160            .collect()
1161    }
1162
1163    #[test]
1164    fn python_measured_missed_reads_lines_and_branch_sources() {
1165        let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1166        let (measured, missed) = python_measured_missed(&full, true);
1167        assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1168        assert_eq!(missed, [2, 3, 4].into_iter().collect());
1169        let partial = cov(&[5], &[], &[], &[[5, 6]]);
1170        let (_, missed_no_branch) = python_measured_missed(&partial, false);
1171        assert!(missed_no_branch.is_empty());
1172        let (_, missed_branch) = python_measured_missed(&partial, true);
1173        assert_eq!(missed_branch, [5].into_iter().collect());
1174    }
1175
1176    #[test]
1177    fn python_measured_missed_skips_an_arc_from_an_unmeasured_line() {
1178        let partial = cov(&[1], &[], &[], &[[99, 1]]);
1179        let (measured, missed) = python_measured_missed(&partial, true);
1180        assert_eq!(measured, [1].into_iter().collect());
1181        assert!(missed.is_empty(), "got: {missed:?}");
1182    }
1183
1184    #[test]
1185    fn python_measured_missed_skips_an_arc_with_a_negative_source() {
1186        let partial = cov(&[1], &[], &[], &[[-1, 1]]);
1187        let (measured, missed) = python_measured_missed(&partial, true);
1188        assert_eq!(measured, [1].into_iter().collect());
1189        assert!(missed.is_empty(), "got: {missed:?}");
1190    }
1191
1192    #[test]
1193    fn ts_measured_missed_anchors_units_on_their_lines() {
1194        let cov = coverage::TsPatchCoverage {
1195            statements: vec![(1, 1, true), (3, 4, false)],
1196            branch_arms: vec![(1, false)],
1197            functions: vec![(6, false)],
1198        };
1199        let (measured, missed) = ts_measured_missed(&cov);
1200        assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1201        assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1202    }
1203
1204    #[test]
1205    fn rust_measured_missed_honors_the_enforced_metrics() {
1206        let cov = coverage::RustPatchCoverage {
1207            regions: vec![(1, 1, true), (5, 6, false)],
1208        };
1209        let with_regions = RustThresholds {
1210            regions: Some(100),
1211            lines: 100,
1212            functions: None,
1213            branch: None,
1214        };
1215        let (measured, missed) = rust_measured_missed(&cov, with_regions);
1216        assert_eq!(measured, [1, 5, 6].into_iter().collect());
1217        assert_eq!(missed, [5, 6].into_iter().collect());
1218        let lines_only = RustThresholds {
1219            regions: None,
1220            lines: 100,
1221            functions: None,
1222            branch: None,
1223        };
1224        let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1225        assert_eq!(missed_lines, [5, 6].into_iter().collect());
1226    }
1227
1228    #[test]
1229    fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1230        let detail = BTreeMap::from([(
1231            "shim.py".to_string(),
1232            (
1233                [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1234                [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1235            ),
1236        )]);
1237        let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1238        assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1239    }
1240
1241    #[test]
1242    fn apply_line_exemptions_rejects_a_covered_listed_line() {
1243        let detail = BTreeMap::from([(
1244            "shim.py".to_string(),
1245            (
1246                [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1247                [2u64].into_iter().collect::<BTreeSet<u64>>(),
1248            ),
1249        )]);
1250        let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1251        assert!(
1252            err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1253            "got: {err}"
1254        );
1255    }
1256
1257    #[test]
1258    fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1259        let detail = BTreeMap::from([(
1260            "w.py".to_string(),
1261            (
1262                [2u64].into_iter().collect::<BTreeSet<u64>>(),
1263                [2u64].into_iter().collect::<BTreeSet<u64>>(),
1264            ),
1265        )]);
1266        let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1267        assert!(err.to_string().contains("w.py:9"), "got: {err}");
1268    }
1269
1270    #[test]
1271    fn floor_outcome_matches_the_whole_tree_message() {
1272        assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1273        let out = floor_outcome(7, 8, 100);
1274        assert!(
1275            matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1276            "got: {out:?}"
1277        );
1278        assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1279    }
1280
1281    #[test]
1282    fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1283        let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1284        lift_exempt_lines(
1285            &mut changed,
1286            &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1287        );
1288        assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1289        assert_eq!(changed["core.py"], [5].into_iter().collect());
1290    }
1291
1292    #[test]
1293    fn kept_lines_drops_only_the_exempt_lines() {
1294        let measured = BTreeSet::from([3u64, 4, 5]);
1295        assert_eq!(
1296            kept_lines(&measured, Some(&BTreeSet::from([4u32]))),
1297            BTreeSet::from([3, 5])
1298        );
1299        assert_eq!(kept_lines(&measured, None), measured);
1300    }
1301
1302    #[test]
1303    fn a_measured_line_too_large_for_a_u32_can_carry_no_exemption() {
1304        // An exemption is written as a u32, so a line number that cannot be one is kept
1305        // however the exemption set reads.
1306        let measured = BTreeSet::from([u64::from(u32::MAX) + 1]);
1307        assert_eq!(
1308            kept_lines(&measured, Some(&BTreeSet::from([u32::MAX]))),
1309            measured
1310        );
1311    }
1312}