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 features: &[String],
349) -> Result<Outcome> {
350 let mut changed = changed_lines(root, base)?;
351 changed.retain(|path, _| path.ends_with(".rs"));
352 lift_exempt_lines(&mut changed, exempt_lines);
353 if changed.is_empty() {
354 return Ok(Outcome::Pass);
355 }
356 let detail = relative_keys(
357 coverage::measure_patch_rust_detail(root, ignore, features)?,
358 root,
359 );
360 Ok(evaluate_patch_rust(&changed, &detail, thresholds))
361}
362
363fn evaluate_patch_rust(
380 changed: &BTreeMap<String, BTreeSet<u64>>,
381 detail: &BTreeMap<String, coverage::RustPatchCoverage>,
382 thresholds: RustThresholds,
383) -> Outcome {
384 let (mut r_cov, mut r_tot) = (0u64, 0u64);
385 let (mut l_cov, mut l_tot) = (0u64, 0u64);
386
387 for (file, lines) in changed {
388 let Some(cov) = detail.get(file) else {
389 continue;
390 };
391
392 for &(start, end, covered) in &cov.regions {
394 if (start..=end).any(|line| lines.contains(&line)) {
395 r_tot += 1;
396 if covered {
397 r_cov += 1;
398 }
399 }
400 }
401
402 for &line in lines {
405 let mut measured = false;
406 let mut covered_here = false;
407 for &(start, end, covered) in &cov.regions {
408 if start <= line && line <= end {
409 measured = true;
410 covered_here |= covered;
411 }
412 }
413 if measured {
414 l_tot += 1;
415 if covered_here {
416 l_cov += 1;
417 }
418 }
419 }
420 }
421
422 let pct = |covered: u64, total: u64| {
425 if total == 0 {
426 100.0
427 } else {
428 100.0 * covered as f64 / total as f64
429 }
430 };
431 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
434 if let Some(regions) = thresholds.regions {
435 checks.push(("regions", pct(r_cov, r_tot), regions));
436 }
437 checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
438 let mut shortfalls = Vec::new();
439 for (name, actual, required) in checks {
440 if actual + 1e-9 < f64::from(required) {
443 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
444 }
445 }
446 if shortfalls.is_empty() {
447 Outcome::Pass
448 } else {
449 Outcome::Fail(format!(
450 "coverage below thresholds: {}",
451 shortfalls.join(", ")
452 ))
453 }
454}
455
456pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
465 let range = format!("{base}...HEAD");
466 let output = Command::new("git")
467 .current_dir(repo)
468 .args([
469 "diff",
470 "--no-color",
471 "--no-renames",
472 "--unified=0",
473 "--relative",
474 &range,
475 ])
476 .output()
477 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
478 if !output.status.success() {
479 bail!(
480 "`git diff {range}` failed in `{}`: {}",
481 repo.display(),
482 String::from_utf8_lossy(&output.stderr).trim()
483 );
484 }
485 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
486}
487
488fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
494 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
495 let mut current: Option<String> = None;
496 let mut next_line: u64 = 0;
497 for line in diff.lines() {
498 if let Some(header) = line.strip_prefix("+++ ") {
499 current = new_side_path(header);
500 } else if line.starts_with("@@") {
501 if let Some(start) = hunk_new_start(line) {
502 next_line = start;
503 }
504 } else if line.starts_with('+') {
505 if let Some(file) = ¤t {
508 changed.entry(file.clone()).or_default().insert(next_line);
509 }
510 next_line += 1;
511 }
512 }
514 changed
515}
516
517fn new_side_path(header: &str) -> Option<String> {
520 let path = header
521 .split('\t')
522 .next()
523 .unwrap_or(header)
524 .trim_end_matches('\r');
525 if path == "/dev/null" {
526 return None;
527 }
528 let path = path.strip_prefix("b/").unwrap_or(path);
529 Some(path.replace('\\', "/"))
530}
531
532fn hunk_new_start(header: &str) -> Option<u64> {
535 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
536 let digits = plus.trim_start_matches('+');
537 digits.split(',').next().unwrap_or(digits).parse().ok()
538}
539
540fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
545 files
546 .into_iter()
547 .map(|(key, value)| {
548 let path = Path::new(&key);
549 let rel = path
550 .strip_prefix(root)
551 .unwrap_or(path)
552 .to_string_lossy()
553 .replace('\\', "/");
554 (rel, value)
555 })
556 .collect()
557}
558
559pub fn measure_line_exempt(
577 root: &Path,
578 thresholds: Thresholds,
579 omit: &[String],
580 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
581) -> Result<Outcome> {
582 let report = coverage::measure_report(root, omit)?;
583 let files = relative_keys(report.files, root);
584 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
585 .iter()
586 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
587 .collect();
588 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
589 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
590 Ok(floor_outcome(covered, total, thresholds.fail_under))
591}
592
593pub fn measure_line_exempt_typescript(
598 root: &Path,
599 thresholds: TypeScriptThresholds,
600 exclude: &[String],
601 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
602) -> Result<Outcome> {
603 let detail = relative_keys(
604 coverage::measure_patch_typescript_detail(root, exclude)?,
605 root,
606 );
607 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
608 .iter()
609 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
610 .collect();
611 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
612 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
613}
614
615pub fn measure_line_exempt_rust(
620 root: &Path,
621 thresholds: RustThresholds,
622 ignore: &[String],
623 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
624 features: &[String],
625) -> Result<Outcome> {
626 let detail = relative_keys(
627 coverage::measure_patch_rust_detail(root, ignore, features)?,
628 root,
629 );
630 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
631 .iter()
632 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
633 .collect();
634 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
635 Ok(evaluate_patch_rust(&line_set, &detail, thresholds))
636}
637
638fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
642 if total == 0 {
643 return Outcome::Pass;
644 }
645 let actual = 100.0 * covered as f64 / total as f64;
646 if actual + 1e-9 >= f64::from(fail_under) {
647 Outcome::Pass
648 } else {
649 Outcome::Fail(format!(
650 "coverage {actual:.2}% is below the required {fail_under}%"
651 ))
652 }
653}
654
655fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
660 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
661 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
662 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
663 let mut missed = missing;
664 if branch {
665 for arc in &cov.missing_branches {
666 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
667 if measured.contains(&src) {
668 missed.insert(src);
669 }
670 }
671 }
672 }
673 (measured, missed)
674}
675
676fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
681 let mut measured = BTreeSet::new();
682 let mut missed = BTreeSet::new();
683 let units = cov
687 .statements
688 .iter()
689 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
690 .chain(cov.branch_arms.iter().copied())
691 .chain(cov.functions.iter().copied());
692 for (line, covered) in units {
693 measured.insert(line);
694 if !covered {
695 missed.insert(line);
696 }
697 }
698 (measured, missed)
699}
700
701fn rust_measured_missed(
706 cov: &coverage::RustPatchCoverage,
707 thresholds: RustThresholds,
708) -> (BTreeSet<u64>, BTreeSet<u64>) {
709 let mut measured = BTreeSet::new();
710 for &(start, end, _covered) in &cov.regions {
711 for line in start..=end {
712 measured.insert(line);
713 }
714 }
715 let mut missed = BTreeSet::new();
716 for &line in &measured {
717 let mut covered_here = false;
718 let mut uncovered_region = false;
719 for &(start, end, covered) in &cov.regions {
720 if start <= line && line <= end {
721 if covered {
722 covered_here = true;
723 } else {
724 uncovered_region = true;
725 }
726 }
727 }
728 let is_missed = if thresholds.regions.is_some() {
729 uncovered_region
730 } else {
731 !covered_here
732 };
733 if is_missed {
734 missed.insert(line);
735 }
736 }
737 (measured, missed)
738}
739
740fn apply_line_exemptions(
745 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
746 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
747) -> Result<BTreeMap<String, BTreeSet<u64>>> {
748 let mut over: Vec<String> = Vec::new();
749 for (file, lines) in exempt_lines {
750 let missed = detail.get(file).map(|(_, missed)| missed);
751 for &line in lines {
752 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
753 if !failing {
754 over.push(format!("\n {file}:{line}"));
755 }
756 }
757 }
758 if !over.is_empty() {
759 bail!(
760 "a line-scoped coverage exemption may only list uncovered lines, but these are \
761 covered or carry no measured code:{}",
762 over.concat()
763 );
764 }
765 let mut line_set = BTreeMap::new();
766 for (file, (measured, _)) in detail {
767 let exempt = exempt_lines.get(file);
768 let kept: BTreeSet<u64> = measured
769 .iter()
770 .copied()
771 .filter(|&line| {
772 !exempt.is_some_and(|exempt| {
773 u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
774 })
775 })
776 .collect();
777 line_set.insert(file.clone(), kept);
778 }
779 Ok(line_set)
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
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]
796 fn parses_added_lines_from_a_hunk() {
797 let diff = "diff --git a/widget.py b/widget.py\n\
800 index abc..def 100644\n\
801 --- a/widget.py\n\
802 +++ b/widget.py\n\
803 @@ -3,0 +4,2 @@ def f(x):\n\
804 + if x == 99:\n\
805 + return 7\n";
806 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
807 }
808
809 #[test]
810 fn parses_a_new_file_as_added_from_line_one() {
811 let diff = "diff --git a/lonely.py b/lonely.py\n\
812 new file mode 100644\n\
813 index 0000000..bbb\n\
814 --- /dev/null\n\
815 +++ b/lonely.py\n\
816 @@ -0,0 +1,2 @@\n\
817 +def lonely():\n\
818 + return 41\n";
819 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
820 }
821
822 #[test]
823 fn a_deletion_only_hunk_records_no_added_lines() {
824 let diff = "diff --git a/widget.py b/widget.py\n\
826 index abc..def 100644\n\
827 --- a/widget.py\n\
828 +++ b/widget.py\n\
829 @@ -4,2 +3,0 @@ def f(x):\n\
830 - dead = 1\n\
831 - return dead\n";
832 assert!(parse_unified_diff(diff).is_empty());
833 }
834
835 #[test]
836 fn a_deleted_file_yields_no_entry() {
837 let diff = "diff --git a/gone.py b/gone.py\n\
838 deleted file mode 100644\n\
839 index abc..0000000\n\
840 --- a/gone.py\n\
841 +++ /dev/null\n\
842 @@ -1,2 +0,0 @@\n\
843 -def gone():\n\
844 - return 0\n";
845 assert!(parse_unified_diff(diff).is_empty());
846 }
847
848 #[test]
849 fn parses_multiple_files_and_a_single_line_hunk() {
850 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 fn cov(
870 executed: &[u64],
871 missing: &[u64],
872 executed_branches: &[[i64; 2]],
873 missing_branches: &[[i64; 2]],
874 ) -> FileCoverage {
875 FileCoverage {
876 executed_lines: executed.to_vec(),
877 missing_lines: missing.to_vec(),
878 excluded_lines: Vec::new(),
879 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
880 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
881 }
882 }
883
884 const FLOOR_85: Thresholds = Thresholds {
885 fail_under: 85,
886 branch: true,
887 };
888
889 #[test]
890 fn patch_a_fully_covered_diff_passes() {
891 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
892 assert_eq!(
893 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
894 Outcome::Pass
895 );
896 }
897
898 #[test]
899 fn patch_below_floor_fails_and_names_the_percent() {
900 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
902 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
903 assert!(
904 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
905 "got: {out:?}"
906 );
907 }
908
909 #[test]
910 fn patch_the_same_diff_clears_a_lower_floor() {
911 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
913 let floor_70 = Thresholds {
914 fail_under: 70,
915 branch: true,
916 };
917 assert_eq!(
918 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
919 Outcome::Pass
920 );
921 }
922
923 #[test]
924 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
925 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
928 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
929 assert!(
930 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
931 "got: {out:?}"
932 );
933 }
934
935 #[test]
936 fn patch_branches_off_ignores_arcs() {
937 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
939 let no_branch = Thresholds {
940 fail_under: 85,
941 branch: false,
942 };
943 assert_eq!(
944 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
945 Outcome::Pass
946 );
947 }
948
949 #[test]
950 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
951 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
954 assert_eq!(
955 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
956 Outcome::Pass
957 );
958 }
959
960 #[test]
961 fn patch_a_diff_with_no_executable_changed_lines_passes() {
962 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
964 assert_eq!(
965 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
966 Outcome::Pass
967 );
968 }
969
970 use coverage::TsPatchCoverage;
973
974 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
975 entries
976 .iter()
977 .map(|(path, cov)| (path.to_string(), cov.clone()))
978 .collect()
979 }
980
981 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
982 lines: 80,
983 branches: 80,
984 functions: 80,
985 statements: 80,
986 };
987
988 #[test]
989 fn ts_patch_a_fully_covered_diff_passes() {
990 let detail = ts_detail(&[(
993 "w.ts",
994 TsPatchCoverage {
995 statements: vec![(1, 1, true), (2, 2, true)],
996 branch_arms: vec![(2, true)],
997 functions: vec![(1, true)],
998 },
999 )]);
1000 assert_eq!(
1001 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1002 Outcome::Pass
1003 );
1004 }
1005
1006 #[test]
1007 fn ts_patch_below_floor_fails_and_names_the_metric() {
1008 let detail = ts_detail(&[(
1012 "w.ts",
1013 TsPatchCoverage {
1014 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1015 branch_arms: vec![],
1016 functions: vec![],
1017 },
1018 )]);
1019 let out =
1020 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
1021 assert!(
1022 matches!(&out, Outcome::Fail(m)
1023 if m.contains("statements 75.00% < 80%")
1024 && m.contains("lines 75.00% < 80%")
1025 && !m.contains("branches")
1026 && !m.contains("functions")),
1027 "got: {out:?}"
1028 );
1029 }
1030
1031 #[test]
1032 fn ts_patch_the_same_diff_clears_a_lower_floor() {
1033 let detail = ts_detail(&[(
1035 "w.ts",
1036 TsPatchCoverage {
1037 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1038 branch_arms: vec![],
1039 functions: vec![],
1040 },
1041 )]);
1042 let floor_70 = TypeScriptThresholds {
1043 lines: 70,
1044 branches: 70,
1045 functions: 70,
1046 statements: 70,
1047 };
1048 assert_eq!(
1049 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
1050 Outcome::Pass
1051 );
1052 }
1053
1054 #[test]
1055 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
1056 let detail = ts_detail(&[(
1059 "w.ts",
1060 TsPatchCoverage {
1061 statements: vec![(3, 3, true)],
1062 branch_arms: vec![(3, true), (3, false)],
1063 functions: vec![],
1064 },
1065 )]);
1066 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1067 assert!(
1068 matches!(&out, Outcome::Fail(m)
1069 if m.contains("branches 50.00% < 80%")
1070 && !m.contains("lines")
1071 && !m.contains("statements")),
1072 "got: {out:?}"
1073 );
1074 }
1075
1076 #[test]
1077 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1078 let detail = ts_detail(&[(
1080 "w.ts",
1081 TsPatchCoverage {
1082 statements: vec![],
1083 branch_arms: vec![],
1084 functions: vec![(9, false)],
1085 },
1086 )]);
1087 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1088 assert!(
1089 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1090 "got: {out:?}"
1091 );
1092 }
1093
1094 #[test]
1095 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1096 let detail = ts_detail(&[(
1099 "w.ts",
1100 TsPatchCoverage {
1101 statements: vec![(1, 1, true)],
1102 branch_arms: vec![],
1103 functions: vec![],
1104 },
1105 )]);
1106 assert_eq!(
1107 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1108 Outcome::Pass
1109 );
1110 }
1111
1112 #[test]
1113 fn ts_patch_a_comment_only_diff_passes() {
1114 let detail = ts_detail(&[(
1117 "w.ts",
1118 TsPatchCoverage {
1119 statements: vec![(1, 1, true), (2, 2, true)],
1120 branch_arms: vec![(2, true)],
1121 functions: vec![(1, true)],
1122 },
1123 )]);
1124 assert_eq!(
1125 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
1126 Outcome::Pass
1127 );
1128 }
1129
1130 #[test]
1131 fn ts_patch_an_empty_diff_passes() {
1132 assert_eq!(
1134 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1135 Outcome::Pass
1136 );
1137 }
1138
1139 #[test]
1140 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1141 let detail = ts_detail(&[(
1145 "w.ts",
1146 TsPatchCoverage {
1147 statements: vec![(3, 5, false)],
1148 branch_arms: vec![],
1149 functions: vec![],
1150 },
1151 )]);
1152 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1153 assert!(
1154 matches!(&out, Outcome::Fail(m)
1155 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1156 "got: {out:?}"
1157 );
1158 }
1159
1160 use coverage::RustPatchCoverage;
1163
1164 fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
1165 entries
1166 .iter()
1167 .map(|(path, cov)| (path.to_string(), cov.clone()))
1168 .collect()
1169 }
1170
1171 const RUST_FLOOR_80: RustThresholds = RustThresholds {
1172 regions: Some(80),
1173 lines: 80,
1174 functions: None,
1175 branch: None,
1176 };
1177
1178 #[test]
1179 fn rust_patch_a_fully_covered_diff_passes() {
1180 let detail = rust_detail(&[(
1183 "w.rs",
1184 RustPatchCoverage {
1185 regions: vec![(1, 1, true), (2, 2, true)],
1186 },
1187 )]);
1188 assert_eq!(
1189 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1190 Outcome::Pass
1191 );
1192 }
1193
1194 #[test]
1195 fn rust_patch_below_floor_fails_and_names_the_metrics() {
1196 let detail = rust_detail(&[(
1199 "w.rs",
1200 RustPatchCoverage {
1201 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1202 },
1203 )]);
1204 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
1205 assert!(
1206 matches!(&out, Outcome::Fail(m)
1207 if m.contains("regions 75.00% < 80%")
1208 && m.contains("lines 75.00% < 80%")),
1209 "got: {out:?}"
1210 );
1211 }
1212
1213 #[test]
1214 fn rust_patch_the_same_diff_clears_a_lower_floor() {
1215 let detail = rust_detail(&[(
1217 "w.rs",
1218 RustPatchCoverage {
1219 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1220 },
1221 )]);
1222 let floor_70 = RustThresholds {
1223 regions: Some(70),
1224 lines: 70,
1225 functions: None,
1226 branch: None,
1227 };
1228 assert_eq!(
1229 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
1230 Outcome::Pass
1231 );
1232 }
1233
1234 #[test]
1235 fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
1236 let detail = rust_detail(&[(
1241 "w.rs",
1242 RustPatchCoverage {
1243 regions: vec![(1, 4, true), (4, 4, false)],
1244 },
1245 )]);
1246 let lines_only = RustThresholds {
1247 regions: None,
1248 lines: 100,
1249 functions: None,
1250 branch: None,
1251 };
1252 assert_eq!(
1253 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
1254 Outcome::Pass
1255 );
1256 }
1257
1258 #[test]
1259 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
1260 let detail = rust_detail(&[(
1263 "w.rs",
1264 RustPatchCoverage {
1265 regions: vec![(5, 5, false)],
1266 },
1267 )]);
1268 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
1269 assert!(
1270 matches!(&out, Outcome::Fail(m)
1271 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1272 "got: {out:?}"
1273 );
1274 }
1275
1276 #[test]
1277 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1278 let detail = rust_detail(&[(
1281 "w.rs",
1282 RustPatchCoverage {
1283 regions: vec![(1, 1, true)],
1284 },
1285 )]);
1286 assert_eq!(
1287 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1288 Outcome::Pass
1289 );
1290 }
1291
1292 #[test]
1293 fn rust_patch_a_comment_only_diff_passes() {
1294 let detail = rust_detail(&[(
1297 "w.rs",
1298 RustPatchCoverage {
1299 regions: vec![(1, 1, true), (2, 2, true)],
1300 },
1301 )]);
1302 assert_eq!(
1303 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1304 Outcome::Pass
1305 );
1306 }
1307
1308 #[test]
1309 fn rust_patch_an_empty_diff_passes() {
1310 assert_eq!(
1312 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1313 Outcome::Pass
1314 );
1315 }
1316
1317 #[test]
1318 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1319 let detail = rust_detail(&[(
1323 "w.rs",
1324 RustPatchCoverage {
1325 regions: vec![(3, 5, false)],
1326 },
1327 )]);
1328 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1329 assert!(
1330 matches!(&out, Outcome::Fail(m)
1331 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1332 "got: {out:?}"
1333 );
1334 }
1335
1336 #[test]
1337 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1338 let detail = rust_detail(&[(
1343 "w.rs",
1344 RustPatchCoverage {
1345 regions: vec![(4, 4, false), (4, 6, true)],
1346 },
1347 )]);
1348 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1349 assert!(
1350 matches!(&out, Outcome::Fail(m)
1351 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1352 "got: {out:?}"
1353 );
1354 }
1355
1356 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1359 entries
1360 .iter()
1361 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1362 .collect()
1363 }
1364
1365 #[test]
1366 fn python_measured_missed_reads_lines_and_branch_sources() {
1367 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1371 let (measured, missed) = python_measured_missed(&full, true);
1372 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1373 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1374 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1376 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1377 assert!(missed_no_branch.is_empty());
1378 let (_, missed_branch) = python_measured_missed(&partial, true);
1379 assert_eq!(missed_branch, [5].into_iter().collect());
1380 }
1381
1382 #[test]
1383 fn ts_measured_missed_anchors_units_on_their_lines() {
1384 let cov = coverage::TsPatchCoverage {
1387 statements: vec![(1, 1, true), (3, 4, false)],
1388 branch_arms: vec![(1, false)],
1389 functions: vec![(6, false)],
1390 };
1391 let (measured, missed) = ts_measured_missed(&cov);
1392 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1393 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1395 }
1396
1397 #[test]
1398 fn rust_measured_missed_honors_the_enforced_metrics() {
1399 let cov = coverage::RustPatchCoverage {
1401 regions: vec![(1, 1, true), (5, 6, false)],
1402 };
1403 let with_regions = RustThresholds {
1404 regions: Some(100),
1405 lines: 100,
1406 functions: None,
1407 branch: None,
1408 };
1409 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1410 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1411 assert_eq!(missed, [5, 6].into_iter().collect());
1412 let lines_only = RustThresholds {
1415 regions: None,
1416 lines: 100,
1417 functions: None,
1418 branch: None,
1419 };
1420 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1421 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1422 }
1423
1424 #[test]
1425 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1426 let detail = BTreeMap::from([(
1428 "shim.py".to_string(),
1429 (
1430 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1431 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1432 ),
1433 )]);
1434 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1435 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1436 }
1437
1438 #[test]
1439 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1440 let detail = BTreeMap::from([(
1442 "shim.py".to_string(),
1443 (
1444 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1445 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1446 ),
1447 )]);
1448 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1449 assert!(
1450 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1451 "got: {err}"
1452 );
1453 }
1454
1455 #[test]
1456 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1457 let detail = BTreeMap::from([(
1459 "w.py".to_string(),
1460 (
1461 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1462 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1463 ),
1464 )]);
1465 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1466 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1467 }
1468
1469 #[test]
1470 fn floor_outcome_matches_the_whole_tree_message() {
1471 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1472 let out = floor_outcome(7, 8, 100);
1473 assert!(
1474 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1475 "got: {out:?}"
1476 );
1477 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1479 }
1480
1481 #[test]
1482 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1483 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1487 lift_exempt_lines(
1488 &mut changed,
1489 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1490 );
1491 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1492 assert_eq!(changed["core.py"], [5].into_iter().collect());
1493 }
1494}