1use std::collections::{BTreeMap, BTreeSet};
37use std::path::Path;
38use std::process::Command;
39
40use anyhow::{bail, Context, Result};
41
42use crate::coverage::{
43 self, FileCoverage, Outcome, RustThresholds, Thresholds, TypeScriptThresholds,
44};
45
46const TS_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"];
51
52pub fn measure(
62 root: &Path,
63 base: &str,
64 thresholds: Thresholds,
65 omit: &[String],
66 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
67) -> Result<Outcome> {
68 let mut changed = changed_lines(root, base)?;
69 changed.retain(|path, _| path.ends_with(".py"));
70 lift_exempt_lines(&mut changed, exempt_lines);
71 if changed.is_empty() {
72 return Ok(Outcome::Pass);
73 }
74 let report = coverage::measure_patch_report(root, omit)?;
75 let files = relative_keys(report.files, root);
76 Ok(evaluate_patch(&changed, &files, thresholds))
77}
78
79fn lift_exempt_lines(
86 changed: &mut BTreeMap<String, BTreeSet<u64>>,
87 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
88) {
89 for (file, exempt) in exempt_lines {
90 if let Some(lines) = changed.get_mut(file) {
91 lines.retain(|&line| !u32::try_from(line).is_ok_and(|line| exempt.contains(&line)));
92 }
93 }
94}
95
96fn evaluate_patch(
109 changed: &BTreeMap<String, BTreeSet<u64>>,
110 files: &BTreeMap<String, FileCoverage>,
111 thresholds: Thresholds,
112) -> Outcome {
113 let (covered, total) = python_ratio(changed, files, thresholds.branch);
114 if total == 0 {
115 return Outcome::Pass;
116 }
117 let actual = 100.0 * covered as f64 / total as f64;
118 if actual + 1e-9 >= f64::from(thresholds.fail_under) {
121 Outcome::Pass
122 } else {
123 Outcome::Fail(format!(
124 "changed-line coverage {actual:.2}% is below the required {}%",
125 thresholds.fail_under
126 ))
127 }
128}
129
130fn python_ratio(
138 selected: &BTreeMap<String, BTreeSet<u64>>,
139 files: &BTreeMap<String, FileCoverage>,
140 branch: bool,
141) -> (u64, u64) {
142 let mut covered: u64 = 0;
143 let mut total: u64 = 0;
144 for (file, lines) in selected {
145 let Some(cov) = files.get(file) else {
146 continue;
147 };
148 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
149 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
150 for &line in lines {
151 if executed.contains(&line) {
152 covered += 1;
153 total += 1;
154 } else if missing.contains(&line) {
155 total += 1;
156 }
157 }
158 if branch {
159 for arc in &cov.executed_branches {
160 if arc_source_in(arc, lines) {
161 covered += 1;
162 total += 1;
163 }
164 }
165 for arc in &cov.missing_branches {
166 if arc_source_in(arc, lines) {
167 total += 1;
168 }
169 }
170 }
171 }
172 (covered, total)
173}
174
175fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
178 arc.first()
179 .and_then(|&src| u64::try_from(src).ok())
180 .is_some_and(|src| lines.contains(&src))
181}
182
183pub fn measure_typescript(
194 root: &Path,
195 base: &str,
196 thresholds: TypeScriptThresholds,
197 exclude: &[String],
198 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
199) -> Result<Outcome> {
200 let mut changed = changed_lines(root, base)?;
201 changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
202 lift_exempt_lines(&mut changed, exempt_lines);
203 if changed.is_empty() {
204 return Ok(Outcome::Pass);
205 }
206 let detail = relative_keys(
207 coverage::measure_patch_typescript_detail(root, exclude)?,
208 root,
209 );
210 Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
211}
212
213fn evaluate_patch_typescript(
235 changed: &BTreeMap<String, BTreeSet<u64>>,
236 detail: &BTreeMap<String, coverage::TsPatchCoverage>,
237 thresholds: TypeScriptThresholds,
238) -> Outcome {
239 let (mut s_cov, mut s_tot) = (0u64, 0u64);
240 let (mut l_cov, mut l_tot) = (0u64, 0u64);
241 let (mut b_cov, mut b_tot) = (0u64, 0u64);
242 let (mut f_cov, mut f_tot) = (0u64, 0u64);
243
244 for (file, lines) in changed {
245 let Some(cov) = detail.get(file) else {
246 continue;
247 };
248
249 for &(start, end, covered) in &cov.statements {
251 if (start..=end).any(|line| lines.contains(&line)) {
252 s_tot += 1;
253 if covered {
254 s_cov += 1;
255 }
256 }
257 }
258
259 for &line in lines {
262 let mut starts_here = false;
263 let mut covered_here = false;
264 for &(start, _end, covered) in &cov.statements {
265 if start == line {
266 starts_here = true;
267 covered_here |= covered;
268 }
269 }
270 if starts_here {
271 l_tot += 1;
272 if covered_here {
273 l_cov += 1;
274 }
275 }
276 }
277
278 for &(source_line, covered) in &cov.branch_arms {
280 if lines.contains(&source_line) {
281 b_tot += 1;
282 if covered {
283 b_cov += 1;
284 }
285 }
286 }
287
288 for &(decl_line, covered) in &cov.functions {
290 if lines.contains(&decl_line) {
291 f_tot += 1;
292 if covered {
293 f_cov += 1;
294 }
295 }
296 }
297 }
298
299 let pct = |covered: u64, total: u64| {
302 if total == 0 {
303 100.0
304 } else {
305 100.0 * covered as f64 / total as f64
306 }
307 };
308 let checks = [
309 ("lines", pct(l_cov, l_tot), thresholds.lines),
310 ("branches", pct(b_cov, b_tot), thresholds.branches),
311 ("functions", pct(f_cov, f_tot), thresholds.functions),
312 ("statements", pct(s_cov, s_tot), thresholds.statements),
313 ];
314 let mut shortfalls = Vec::new();
315 for (name, actual, required) in checks {
316 if actual + 1e-9 < f64::from(required) {
319 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
320 }
321 }
322 if shortfalls.is_empty() {
323 Outcome::Pass
324 } else {
325 Outcome::Fail(format!(
326 "coverage below thresholds: {}",
327 shortfalls.join(", ")
328 ))
329 }
330}
331
332pub fn measure_rust(
343 root: &Path,
344 base: &str,
345 thresholds: RustThresholds,
346 ignore: &[String],
347 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
348) -> Result<Outcome> {
349 let mut changed = changed_lines(root, base)?;
350 changed.retain(|path, _| path.ends_with(".rs"));
351 lift_exempt_lines(&mut changed, exempt_lines);
352 if changed.is_empty() {
353 return Ok(Outcome::Pass);
354 }
355 let detail = relative_keys(coverage::measure_patch_rust_detail(root, ignore)?, root);
356 Ok(evaluate_patch_rust(&changed, &detail, thresholds))
357}
358
359fn evaluate_patch_rust(
376 changed: &BTreeMap<String, BTreeSet<u64>>,
377 detail: &BTreeMap<String, coverage::RustPatchCoverage>,
378 thresholds: RustThresholds,
379) -> Outcome {
380 let (mut r_cov, mut r_tot) = (0u64, 0u64);
381 let (mut l_cov, mut l_tot) = (0u64, 0u64);
382
383 for (file, lines) in changed {
384 let Some(cov) = detail.get(file) else {
385 continue;
386 };
387
388 for &(start, end, covered) in &cov.regions {
390 if (start..=end).any(|line| lines.contains(&line)) {
391 r_tot += 1;
392 if covered {
393 r_cov += 1;
394 }
395 }
396 }
397
398 for &line in lines {
401 let mut measured = false;
402 let mut covered_here = false;
403 for &(start, end, covered) in &cov.regions {
404 if start <= line && line <= end {
405 measured = true;
406 covered_here |= covered;
407 }
408 }
409 if measured {
410 l_tot += 1;
411 if covered_here {
412 l_cov += 1;
413 }
414 }
415 }
416 }
417
418 let pct = |covered: u64, total: u64| {
421 if total == 0 {
422 100.0
423 } else {
424 100.0 * covered as f64 / total as f64
425 }
426 };
427 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
430 if let Some(regions) = thresholds.regions {
431 checks.push(("regions", pct(r_cov, r_tot), regions));
432 }
433 checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
434 let mut shortfalls = Vec::new();
435 for (name, actual, required) in checks {
436 if actual + 1e-9 < f64::from(required) {
439 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
440 }
441 }
442 if shortfalls.is_empty() {
443 Outcome::Pass
444 } else {
445 Outcome::Fail(format!(
446 "coverage below thresholds: {}",
447 shortfalls.join(", ")
448 ))
449 }
450}
451
452pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
461 let range = format!("{base}...HEAD");
462 let output = Command::new("git")
463 .current_dir(repo)
464 .args([
465 "diff",
466 "--no-color",
467 "--no-renames",
468 "--unified=0",
469 "--relative",
470 &range,
471 ])
472 .output()
473 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
474 if !output.status.success() {
475 bail!(
476 "`git diff {range}` failed in `{}`: {}",
477 repo.display(),
478 String::from_utf8_lossy(&output.stderr).trim()
479 );
480 }
481 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
482}
483
484fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
490 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
491 let mut current: Option<String> = None;
492 let mut next_line: u64 = 0;
493 for line in diff.lines() {
494 if let Some(header) = line.strip_prefix("+++ ") {
495 current = new_side_path(header);
496 } else if line.starts_with("@@") {
497 if let Some(start) = hunk_new_start(line) {
498 next_line = start;
499 }
500 } else if line.starts_with('+') {
501 if let Some(file) = ¤t {
504 changed.entry(file.clone()).or_default().insert(next_line);
505 }
506 next_line += 1;
507 }
508 }
510 changed
511}
512
513fn new_side_path(header: &str) -> Option<String> {
516 let path = header
517 .split('\t')
518 .next()
519 .unwrap_or(header)
520 .trim_end_matches('\r');
521 if path == "/dev/null" {
522 return None;
523 }
524 let path = path.strip_prefix("b/").unwrap_or(path);
525 Some(path.replace('\\', "/"))
526}
527
528fn hunk_new_start(header: &str) -> Option<u64> {
531 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
532 let digits = plus.trim_start_matches('+');
533 digits.split(',').next().unwrap_or(digits).parse().ok()
534}
535
536fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
541 files
542 .into_iter()
543 .map(|(key, value)| {
544 let path = Path::new(&key);
545 let rel = path
546 .strip_prefix(root)
547 .unwrap_or(path)
548 .to_string_lossy()
549 .replace('\\', "/");
550 (rel, value)
551 })
552 .collect()
553}
554
555pub fn measure_line_exempt(
573 root: &Path,
574 thresholds: Thresholds,
575 omit: &[String],
576 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
577) -> Result<Outcome> {
578 let report = coverage::measure_report(root, omit)?;
579 let files = relative_keys(report.files, root);
580 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
581 .iter()
582 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
583 .collect();
584 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
585 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
586 Ok(floor_outcome(covered, total, thresholds.fail_under))
587}
588
589pub fn measure_line_exempt_typescript(
594 root: &Path,
595 thresholds: TypeScriptThresholds,
596 exclude: &[String],
597 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
598) -> Result<Outcome> {
599 let detail = relative_keys(
600 coverage::measure_patch_typescript_detail(root, exclude)?,
601 root,
602 );
603 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
604 .iter()
605 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
606 .collect();
607 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
608 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
609}
610
611pub fn measure_line_exempt_rust(
616 root: &Path,
617 thresholds: RustThresholds,
618 ignore: &[String],
619 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
620) -> Result<Outcome> {
621 let detail = relative_keys(coverage::measure_patch_rust_detail(root, ignore)?, root);
622 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
623 .iter()
624 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
625 .collect();
626 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
627 Ok(evaluate_patch_rust(&line_set, &detail, thresholds))
628}
629
630fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
634 if total == 0 {
635 return Outcome::Pass;
636 }
637 let actual = 100.0 * covered as f64 / total as f64;
638 if actual + 1e-9 >= f64::from(fail_under) {
639 Outcome::Pass
640 } else {
641 Outcome::Fail(format!(
642 "coverage {actual:.2}% is below the required {fail_under}%"
643 ))
644 }
645}
646
647fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
652 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
653 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
654 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
655 let mut missed = missing;
656 if branch {
657 for arc in &cov.missing_branches {
658 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
659 if measured.contains(&src) {
660 missed.insert(src);
661 }
662 }
663 }
664 }
665 (measured, missed)
666}
667
668fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
673 let mut measured = BTreeSet::new();
674 let mut missed = BTreeSet::new();
675 let units = cov
679 .statements
680 .iter()
681 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
682 .chain(cov.branch_arms.iter().copied())
683 .chain(cov.functions.iter().copied());
684 for (line, covered) in units {
685 measured.insert(line);
686 if !covered {
687 missed.insert(line);
688 }
689 }
690 (measured, missed)
691}
692
693fn rust_measured_missed(
698 cov: &coverage::RustPatchCoverage,
699 thresholds: RustThresholds,
700) -> (BTreeSet<u64>, BTreeSet<u64>) {
701 let mut measured = BTreeSet::new();
702 for &(start, end, _covered) in &cov.regions {
703 for line in start..=end {
704 measured.insert(line);
705 }
706 }
707 let mut missed = BTreeSet::new();
708 for &line in &measured {
709 let mut covered_here = false;
710 let mut uncovered_region = false;
711 for &(start, end, covered) in &cov.regions {
712 if start <= line && line <= end {
713 if covered {
714 covered_here = true;
715 } else {
716 uncovered_region = true;
717 }
718 }
719 }
720 let is_missed = if thresholds.regions.is_some() {
721 uncovered_region
722 } else {
723 !covered_here
724 };
725 if is_missed {
726 missed.insert(line);
727 }
728 }
729 (measured, missed)
730}
731
732fn apply_line_exemptions(
737 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
738 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
739) -> Result<BTreeMap<String, BTreeSet<u64>>> {
740 let mut over: Vec<String> = Vec::new();
741 for (file, lines) in exempt_lines {
742 let missed = detail.get(file).map(|(_, missed)| missed);
743 for &line in lines {
744 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
745 if !failing {
746 over.push(format!("\n {file}:{line}"));
747 }
748 }
749 }
750 if !over.is_empty() {
751 bail!(
752 "a line-scoped coverage exemption may only list uncovered lines, but these are \
753 covered or carry no measured code:{}",
754 over.concat()
755 );
756 }
757 let mut line_set = BTreeMap::new();
758 for (file, (measured, _)) in detail {
759 let exempt = exempt_lines.get(file);
760 let kept: BTreeSet<u64> = measured
761 .iter()
762 .copied()
763 .filter(|&line| {
764 !exempt.is_some_and(|exempt| {
765 u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
766 })
767 })
768 .collect();
769 line_set.insert(file.clone(), kept);
770 }
771 Ok(line_set)
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
779 entries
780 .iter()
781 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
782 .collect()
783 }
784
785 #[test]
788 fn parses_added_lines_from_a_hunk() {
789 let diff = "diff --git a/widget.py b/widget.py\n\
792 index abc..def 100644\n\
793 --- a/widget.py\n\
794 +++ b/widget.py\n\
795 @@ -3,0 +4,2 @@ def f(x):\n\
796 + if x == 99:\n\
797 + return 7\n";
798 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
799 }
800
801 #[test]
802 fn parses_a_new_file_as_added_from_line_one() {
803 let diff = "diff --git a/lonely.py b/lonely.py\n\
804 new file mode 100644\n\
805 index 0000000..bbb\n\
806 --- /dev/null\n\
807 +++ b/lonely.py\n\
808 @@ -0,0 +1,2 @@\n\
809 +def lonely():\n\
810 + return 41\n";
811 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
812 }
813
814 #[test]
815 fn a_deletion_only_hunk_records_no_added_lines() {
816 let diff = "diff --git a/widget.py b/widget.py\n\
818 index abc..def 100644\n\
819 --- a/widget.py\n\
820 +++ b/widget.py\n\
821 @@ -4,2 +3,0 @@ def f(x):\n\
822 - dead = 1\n\
823 - return dead\n";
824 assert!(parse_unified_diff(diff).is_empty());
825 }
826
827 #[test]
828 fn a_deleted_file_yields_no_entry() {
829 let diff = "diff --git a/gone.py b/gone.py\n\
830 deleted file mode 100644\n\
831 index abc..0000000\n\
832 --- a/gone.py\n\
833 +++ /dev/null\n\
834 @@ -1,2 +0,0 @@\n\
835 -def gone():\n\
836 - return 0\n";
837 assert!(parse_unified_diff(diff).is_empty());
838 }
839
840 #[test]
841 fn parses_multiple_files_and_a_single_line_hunk() {
842 let diff = "diff --git a/a.py b/a.py\n\
844 --- a/a.py\n\
845 +++ b/a.py\n\
846 @@ -1,0 +2 @@ def a():\n\
847 + x = 1\n\
848 diff --git a/pkg/b.py b/pkg/b.py\n\
849 --- a/pkg/b.py\n\
850 +++ b/pkg/b.py\n\
851 @@ -10,0 +11,1 @@\n\
852 + y = 2\n";
853 assert_eq!(
854 parse_unified_diff(diff),
855 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
856 );
857 }
858
859 fn cov(
862 executed: &[u64],
863 missing: &[u64],
864 executed_branches: &[[i64; 2]],
865 missing_branches: &[[i64; 2]],
866 ) -> FileCoverage {
867 FileCoverage {
868 executed_lines: executed.to_vec(),
869 missing_lines: missing.to_vec(),
870 excluded_lines: Vec::new(),
871 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
872 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
873 }
874 }
875
876 const FLOOR_85: Thresholds = Thresholds {
877 fail_under: 85,
878 branch: true,
879 };
880
881 #[test]
882 fn patch_a_fully_covered_diff_passes() {
883 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
884 assert_eq!(
885 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
886 Outcome::Pass
887 );
888 }
889
890 #[test]
891 fn patch_below_floor_fails_and_names_the_percent() {
892 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
894 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
895 assert!(
896 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
897 "got: {out:?}"
898 );
899 }
900
901 #[test]
902 fn patch_the_same_diff_clears_a_lower_floor() {
903 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
905 let floor_70 = Thresholds {
906 fail_under: 70,
907 branch: true,
908 };
909 assert_eq!(
910 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
911 Outcome::Pass
912 );
913 }
914
915 #[test]
916 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
917 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
920 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
921 assert!(
922 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
923 "got: {out:?}"
924 );
925 }
926
927 #[test]
928 fn patch_branches_off_ignores_arcs() {
929 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
931 let no_branch = Thresholds {
932 fail_under: 85,
933 branch: false,
934 };
935 assert_eq!(
936 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
937 Outcome::Pass
938 );
939 }
940
941 #[test]
942 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
943 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
946 assert_eq!(
947 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
948 Outcome::Pass
949 );
950 }
951
952 #[test]
953 fn patch_a_diff_with_no_executable_changed_lines_passes() {
954 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
956 assert_eq!(
957 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
958 Outcome::Pass
959 );
960 }
961
962 use coverage::TsPatchCoverage;
965
966 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
967 entries
968 .iter()
969 .map(|(path, cov)| (path.to_string(), cov.clone()))
970 .collect()
971 }
972
973 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
974 lines: 80,
975 branches: 80,
976 functions: 80,
977 statements: 80,
978 };
979
980 #[test]
981 fn ts_patch_a_fully_covered_diff_passes() {
982 let detail = ts_detail(&[(
985 "w.ts",
986 TsPatchCoverage {
987 statements: vec![(1, 1, true), (2, 2, true)],
988 branch_arms: vec![(2, true)],
989 functions: vec![(1, true)],
990 },
991 )]);
992 assert_eq!(
993 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
994 Outcome::Pass
995 );
996 }
997
998 #[test]
999 fn ts_patch_below_floor_fails_and_names_the_metric() {
1000 let detail = ts_detail(&[(
1004 "w.ts",
1005 TsPatchCoverage {
1006 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1007 branch_arms: vec![],
1008 functions: vec![],
1009 },
1010 )]);
1011 let out =
1012 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
1013 assert!(
1014 matches!(&out, Outcome::Fail(m)
1015 if m.contains("statements 75.00% < 80%")
1016 && m.contains("lines 75.00% < 80%")
1017 && !m.contains("branches")
1018 && !m.contains("functions")),
1019 "got: {out:?}"
1020 );
1021 }
1022
1023 #[test]
1024 fn ts_patch_the_same_diff_clears_a_lower_floor() {
1025 let detail = ts_detail(&[(
1027 "w.ts",
1028 TsPatchCoverage {
1029 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1030 branch_arms: vec![],
1031 functions: vec![],
1032 },
1033 )]);
1034 let floor_70 = TypeScriptThresholds {
1035 lines: 70,
1036 branches: 70,
1037 functions: 70,
1038 statements: 70,
1039 };
1040 assert_eq!(
1041 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
1042 Outcome::Pass
1043 );
1044 }
1045
1046 #[test]
1047 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
1048 let detail = ts_detail(&[(
1051 "w.ts",
1052 TsPatchCoverage {
1053 statements: vec![(3, 3, true)],
1054 branch_arms: vec![(3, true), (3, false)],
1055 functions: vec![],
1056 },
1057 )]);
1058 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1059 assert!(
1060 matches!(&out, Outcome::Fail(m)
1061 if m.contains("branches 50.00% < 80%")
1062 && !m.contains("lines")
1063 && !m.contains("statements")),
1064 "got: {out:?}"
1065 );
1066 }
1067
1068 #[test]
1069 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1070 let detail = ts_detail(&[(
1072 "w.ts",
1073 TsPatchCoverage {
1074 statements: vec![],
1075 branch_arms: vec![],
1076 functions: vec![(9, false)],
1077 },
1078 )]);
1079 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1080 assert!(
1081 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1082 "got: {out:?}"
1083 );
1084 }
1085
1086 #[test]
1087 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1088 let detail = ts_detail(&[(
1091 "w.ts",
1092 TsPatchCoverage {
1093 statements: vec![(1, 1, true)],
1094 branch_arms: vec![],
1095 functions: vec![],
1096 },
1097 )]);
1098 assert_eq!(
1099 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1100 Outcome::Pass
1101 );
1102 }
1103
1104 #[test]
1105 fn ts_patch_a_comment_only_diff_passes() {
1106 let detail = ts_detail(&[(
1109 "w.ts",
1110 TsPatchCoverage {
1111 statements: vec![(1, 1, true), (2, 2, true)],
1112 branch_arms: vec![(2, true)],
1113 functions: vec![(1, true)],
1114 },
1115 )]);
1116 assert_eq!(
1117 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
1118 Outcome::Pass
1119 );
1120 }
1121
1122 #[test]
1123 fn ts_patch_an_empty_diff_passes() {
1124 assert_eq!(
1126 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1127 Outcome::Pass
1128 );
1129 }
1130
1131 #[test]
1132 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1133 let detail = ts_detail(&[(
1137 "w.ts",
1138 TsPatchCoverage {
1139 statements: vec![(3, 5, false)],
1140 branch_arms: vec![],
1141 functions: vec![],
1142 },
1143 )]);
1144 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1145 assert!(
1146 matches!(&out, Outcome::Fail(m)
1147 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1148 "got: {out:?}"
1149 );
1150 }
1151
1152 use coverage::RustPatchCoverage;
1155
1156 fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
1157 entries
1158 .iter()
1159 .map(|(path, cov)| (path.to_string(), cov.clone()))
1160 .collect()
1161 }
1162
1163 const RUST_FLOOR_80: RustThresholds = RustThresholds {
1164 regions: Some(80),
1165 lines: 80,
1166 };
1167
1168 #[test]
1169 fn rust_patch_a_fully_covered_diff_passes() {
1170 let detail = rust_detail(&[(
1173 "w.rs",
1174 RustPatchCoverage {
1175 regions: vec![(1, 1, true), (2, 2, true)],
1176 },
1177 )]);
1178 assert_eq!(
1179 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1180 Outcome::Pass
1181 );
1182 }
1183
1184 #[test]
1185 fn rust_patch_below_floor_fails_and_names_the_metrics() {
1186 let detail = rust_detail(&[(
1189 "w.rs",
1190 RustPatchCoverage {
1191 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1192 },
1193 )]);
1194 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
1195 assert!(
1196 matches!(&out, Outcome::Fail(m)
1197 if m.contains("regions 75.00% < 80%")
1198 && m.contains("lines 75.00% < 80%")),
1199 "got: {out:?}"
1200 );
1201 }
1202
1203 #[test]
1204 fn rust_patch_the_same_diff_clears_a_lower_floor() {
1205 let detail = rust_detail(&[(
1207 "w.rs",
1208 RustPatchCoverage {
1209 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1210 },
1211 )]);
1212 let floor_70 = RustThresholds {
1213 regions: Some(70),
1214 lines: 70,
1215 };
1216 assert_eq!(
1217 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
1218 Outcome::Pass
1219 );
1220 }
1221
1222 #[test]
1223 fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
1224 let detail = rust_detail(&[(
1229 "w.rs",
1230 RustPatchCoverage {
1231 regions: vec![(1, 4, true), (4, 4, false)],
1232 },
1233 )]);
1234 let lines_only = RustThresholds {
1235 regions: None,
1236 lines: 100,
1237 };
1238 assert_eq!(
1239 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
1240 Outcome::Pass
1241 );
1242 }
1243
1244 #[test]
1245 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
1246 let detail = rust_detail(&[(
1249 "w.rs",
1250 RustPatchCoverage {
1251 regions: vec![(5, 5, false)],
1252 },
1253 )]);
1254 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
1255 assert!(
1256 matches!(&out, Outcome::Fail(m)
1257 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1258 "got: {out:?}"
1259 );
1260 }
1261
1262 #[test]
1263 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1264 let detail = rust_detail(&[(
1267 "w.rs",
1268 RustPatchCoverage {
1269 regions: vec![(1, 1, true)],
1270 },
1271 )]);
1272 assert_eq!(
1273 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1274 Outcome::Pass
1275 );
1276 }
1277
1278 #[test]
1279 fn rust_patch_a_comment_only_diff_passes() {
1280 let detail = rust_detail(&[(
1283 "w.rs",
1284 RustPatchCoverage {
1285 regions: vec![(1, 1, true), (2, 2, true)],
1286 },
1287 )]);
1288 assert_eq!(
1289 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1290 Outcome::Pass
1291 );
1292 }
1293
1294 #[test]
1295 fn rust_patch_an_empty_diff_passes() {
1296 assert_eq!(
1298 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1299 Outcome::Pass
1300 );
1301 }
1302
1303 #[test]
1304 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1305 let detail = rust_detail(&[(
1309 "w.rs",
1310 RustPatchCoverage {
1311 regions: vec![(3, 5, false)],
1312 },
1313 )]);
1314 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1315 assert!(
1316 matches!(&out, Outcome::Fail(m)
1317 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1318 "got: {out:?}"
1319 );
1320 }
1321
1322 #[test]
1323 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1324 let detail = rust_detail(&[(
1329 "w.rs",
1330 RustPatchCoverage {
1331 regions: vec![(4, 4, false), (4, 6, true)],
1332 },
1333 )]);
1334 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1335 assert!(
1336 matches!(&out, Outcome::Fail(m)
1337 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1338 "got: {out:?}"
1339 );
1340 }
1341
1342 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1345 entries
1346 .iter()
1347 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1348 .collect()
1349 }
1350
1351 #[test]
1352 fn python_measured_missed_reads_lines_and_branch_sources() {
1353 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1357 let (measured, missed) = python_measured_missed(&full, true);
1358 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1359 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1360 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1362 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1363 assert!(missed_no_branch.is_empty());
1364 let (_, missed_branch) = python_measured_missed(&partial, true);
1365 assert_eq!(missed_branch, [5].into_iter().collect());
1366 }
1367
1368 #[test]
1369 fn ts_measured_missed_anchors_units_on_their_lines() {
1370 let cov = coverage::TsPatchCoverage {
1373 statements: vec![(1, 1, true), (3, 4, false)],
1374 branch_arms: vec![(1, false)],
1375 functions: vec![(6, false)],
1376 };
1377 let (measured, missed) = ts_measured_missed(&cov);
1378 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1379 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1381 }
1382
1383 #[test]
1384 fn rust_measured_missed_honors_the_enforced_metrics() {
1385 let cov = coverage::RustPatchCoverage {
1387 regions: vec![(1, 1, true), (5, 6, false)],
1388 };
1389 let with_regions = RustThresholds {
1390 regions: Some(100),
1391 lines: 100,
1392 };
1393 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1394 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1395 assert_eq!(missed, [5, 6].into_iter().collect());
1396 let lines_only = RustThresholds {
1399 regions: None,
1400 lines: 100,
1401 };
1402 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1403 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1404 }
1405
1406 #[test]
1407 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1408 let detail = BTreeMap::from([(
1410 "shim.py".to_string(),
1411 (
1412 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1413 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1414 ),
1415 )]);
1416 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1417 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1418 }
1419
1420 #[test]
1421 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1422 let detail = BTreeMap::from([(
1424 "shim.py".to_string(),
1425 (
1426 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1427 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1428 ),
1429 )]);
1430 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1431 assert!(
1432 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1433 "got: {err}"
1434 );
1435 }
1436
1437 #[test]
1438 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1439 let detail = BTreeMap::from([(
1441 "w.py".to_string(),
1442 (
1443 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1444 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1445 ),
1446 )]);
1447 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1448 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1449 }
1450
1451 #[test]
1452 fn floor_outcome_matches_the_whole_tree_message() {
1453 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1454 let out = floor_outcome(7, 8, 100);
1455 assert!(
1456 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1457 "got: {out:?}"
1458 );
1459 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1461 }
1462
1463 #[test]
1464 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1465 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1469 lift_exempt_lines(
1470 &mut changed,
1471 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1472 );
1473 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1474 assert_eq!(changed["core.py"], [5].into_iter().collect());
1475 }
1476}