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