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