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