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