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