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