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 };
1175
1176 #[test]
1177 fn rust_patch_a_fully_covered_diff_passes() {
1178 let detail = rust_detail(&[(
1181 "w.rs",
1182 RustPatchCoverage {
1183 regions: vec![(1, 1, true), (2, 2, true)],
1184 },
1185 )]);
1186 assert_eq!(
1187 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1188 Outcome::Pass
1189 );
1190 }
1191
1192 #[test]
1193 fn rust_patch_below_floor_fails_and_names_the_metrics() {
1194 let detail = rust_detail(&[(
1197 "w.rs",
1198 RustPatchCoverage {
1199 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1200 },
1201 )]);
1202 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
1203 assert!(
1204 matches!(&out, Outcome::Fail(m)
1205 if m.contains("regions 75.00% < 80%")
1206 && m.contains("lines 75.00% < 80%")),
1207 "got: {out:?}"
1208 );
1209 }
1210
1211 #[test]
1212 fn rust_patch_the_same_diff_clears_a_lower_floor() {
1213 let detail = rust_detail(&[(
1215 "w.rs",
1216 RustPatchCoverage {
1217 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1218 },
1219 )]);
1220 let floor_70 = RustThresholds {
1221 regions: Some(70),
1222 lines: 70,
1223 };
1224 assert_eq!(
1225 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
1226 Outcome::Pass
1227 );
1228 }
1229
1230 #[test]
1231 fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
1232 let detail = rust_detail(&[(
1237 "w.rs",
1238 RustPatchCoverage {
1239 regions: vec![(1, 4, true), (4, 4, false)],
1240 },
1241 )]);
1242 let lines_only = RustThresholds {
1243 regions: None,
1244 lines: 100,
1245 };
1246 assert_eq!(
1247 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
1248 Outcome::Pass
1249 );
1250 }
1251
1252 #[test]
1253 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
1254 let detail = rust_detail(&[(
1257 "w.rs",
1258 RustPatchCoverage {
1259 regions: vec![(5, 5, false)],
1260 },
1261 )]);
1262 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
1263 assert!(
1264 matches!(&out, Outcome::Fail(m)
1265 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1266 "got: {out:?}"
1267 );
1268 }
1269
1270 #[test]
1271 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1272 let detail = rust_detail(&[(
1275 "w.rs",
1276 RustPatchCoverage {
1277 regions: vec![(1, 1, true)],
1278 },
1279 )]);
1280 assert_eq!(
1281 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1282 Outcome::Pass
1283 );
1284 }
1285
1286 #[test]
1287 fn rust_patch_a_comment_only_diff_passes() {
1288 let detail = rust_detail(&[(
1291 "w.rs",
1292 RustPatchCoverage {
1293 regions: vec![(1, 1, true), (2, 2, true)],
1294 },
1295 )]);
1296 assert_eq!(
1297 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1298 Outcome::Pass
1299 );
1300 }
1301
1302 #[test]
1303 fn rust_patch_an_empty_diff_passes() {
1304 assert_eq!(
1306 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1307 Outcome::Pass
1308 );
1309 }
1310
1311 #[test]
1312 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1313 let detail = rust_detail(&[(
1317 "w.rs",
1318 RustPatchCoverage {
1319 regions: vec![(3, 5, false)],
1320 },
1321 )]);
1322 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1323 assert!(
1324 matches!(&out, Outcome::Fail(m)
1325 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1326 "got: {out:?}"
1327 );
1328 }
1329
1330 #[test]
1331 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1332 let detail = rust_detail(&[(
1337 "w.rs",
1338 RustPatchCoverage {
1339 regions: vec![(4, 4, false), (4, 6, true)],
1340 },
1341 )]);
1342 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1343 assert!(
1344 matches!(&out, Outcome::Fail(m)
1345 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1346 "got: {out:?}"
1347 );
1348 }
1349
1350 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1353 entries
1354 .iter()
1355 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1356 .collect()
1357 }
1358
1359 #[test]
1360 fn python_measured_missed_reads_lines_and_branch_sources() {
1361 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1365 let (measured, missed) = python_measured_missed(&full, true);
1366 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1367 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1368 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1370 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1371 assert!(missed_no_branch.is_empty());
1372 let (_, missed_branch) = python_measured_missed(&partial, true);
1373 assert_eq!(missed_branch, [5].into_iter().collect());
1374 }
1375
1376 #[test]
1377 fn ts_measured_missed_anchors_units_on_their_lines() {
1378 let cov = coverage::TsPatchCoverage {
1381 statements: vec![(1, 1, true), (3, 4, false)],
1382 branch_arms: vec![(1, false)],
1383 functions: vec![(6, false)],
1384 };
1385 let (measured, missed) = ts_measured_missed(&cov);
1386 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1387 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1389 }
1390
1391 #[test]
1392 fn rust_measured_missed_honors_the_enforced_metrics() {
1393 let cov = coverage::RustPatchCoverage {
1395 regions: vec![(1, 1, true), (5, 6, false)],
1396 };
1397 let with_regions = RustThresholds {
1398 regions: Some(100),
1399 lines: 100,
1400 };
1401 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1402 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1403 assert_eq!(missed, [5, 6].into_iter().collect());
1404 let lines_only = RustThresholds {
1407 regions: None,
1408 lines: 100,
1409 };
1410 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1411 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1412 }
1413
1414 #[test]
1415 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1416 let detail = BTreeMap::from([(
1418 "shim.py".to_string(),
1419 (
1420 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1421 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1422 ),
1423 )]);
1424 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1425 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1426 }
1427
1428 #[test]
1429 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1430 let detail = BTreeMap::from([(
1432 "shim.py".to_string(),
1433 (
1434 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1435 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1436 ),
1437 )]);
1438 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1439 assert!(
1440 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1441 "got: {err}"
1442 );
1443 }
1444
1445 #[test]
1446 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1447 let detail = BTreeMap::from([(
1449 "w.py".to_string(),
1450 (
1451 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1452 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1453 ),
1454 )]);
1455 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1456 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1457 }
1458
1459 #[test]
1460 fn floor_outcome_matches_the_whole_tree_message() {
1461 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1462 let out = floor_outcome(7, 8, 100);
1463 assert!(
1464 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1465 "got: {out:?}"
1466 );
1467 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1469 }
1470
1471 #[test]
1472 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1473 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1477 lift_exempt_lines(
1478 &mut changed,
1479 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1480 );
1481 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1482 assert_eq!(changed["core.py"], [5].into_iter().collect());
1483 }
1484}