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) -> Result<Outcome> {
67 let mut changed = changed_lines(root, base)?;
68 changed.retain(|path, _| path.ends_with(".py"));
69 if changed.is_empty() {
70 return Ok(Outcome::Pass);
71 }
72 let report = coverage::measure_patch_report(root, omit)?;
73 let files = relative_keys(report.files, root);
74 Ok(evaluate_patch(&changed, &files, thresholds))
75}
76
77fn evaluate_patch(
90 changed: &BTreeMap<String, BTreeSet<u64>>,
91 files: &BTreeMap<String, FileCoverage>,
92 thresholds: Thresholds,
93) -> Outcome {
94 let mut covered: u64 = 0;
95 let mut total: u64 = 0;
96 for (file, lines) in changed {
97 let Some(cov) = files.get(file) else {
98 continue;
99 };
100 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
101 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
102 for &line in lines {
103 if executed.contains(&line) {
104 covered += 1;
105 total += 1;
106 } else if missing.contains(&line) {
107 total += 1;
108 }
109 }
110 if thresholds.branch {
111 for arc in &cov.executed_branches {
112 if arc_source_in(arc, lines) {
113 covered += 1;
114 total += 1;
115 }
116 }
117 for arc in &cov.missing_branches {
118 if arc_source_in(arc, lines) {
119 total += 1;
120 }
121 }
122 }
123 }
124 if total == 0 {
125 return Outcome::Pass;
126 }
127 let actual = 100.0 * covered as f64 / total as f64;
128 if actual + 1e-9 >= f64::from(thresholds.fail_under) {
131 Outcome::Pass
132 } else {
133 Outcome::Fail(format!(
134 "changed-line coverage {actual:.2}% is below the required {}%",
135 thresholds.fail_under
136 ))
137 }
138}
139
140fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
143 arc.first()
144 .and_then(|&src| u64::try_from(src).ok())
145 .is_some_and(|src| lines.contains(&src))
146}
147
148pub fn measure_typescript(
159 root: &Path,
160 base: &str,
161 thresholds: TypeScriptThresholds,
162 exclude: &[String],
163) -> Result<Outcome> {
164 let mut changed = changed_lines(root, base)?;
165 changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
166 if changed.is_empty() {
167 return Ok(Outcome::Pass);
168 }
169 let detail = relative_keys(
170 coverage::measure_patch_typescript_detail(root, exclude)?,
171 root,
172 );
173 Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
174}
175
176fn evaluate_patch_typescript(
198 changed: &BTreeMap<String, BTreeSet<u64>>,
199 detail: &BTreeMap<String, coverage::TsPatchCoverage>,
200 thresholds: TypeScriptThresholds,
201) -> Outcome {
202 let (mut s_cov, mut s_tot) = (0u64, 0u64);
203 let (mut l_cov, mut l_tot) = (0u64, 0u64);
204 let (mut b_cov, mut b_tot) = (0u64, 0u64);
205 let (mut f_cov, mut f_tot) = (0u64, 0u64);
206
207 for (file, lines) in changed {
208 let Some(cov) = detail.get(file) else {
209 continue;
210 };
211
212 for &(start, end, covered) in &cov.statements {
214 if (start..=end).any(|line| lines.contains(&line)) {
215 s_tot += 1;
216 if covered {
217 s_cov += 1;
218 }
219 }
220 }
221
222 for &line in lines {
225 let mut starts_here = false;
226 let mut covered_here = false;
227 for &(start, _end, covered) in &cov.statements {
228 if start == line {
229 starts_here = true;
230 covered_here |= covered;
231 }
232 }
233 if starts_here {
234 l_tot += 1;
235 if covered_here {
236 l_cov += 1;
237 }
238 }
239 }
240
241 for &(source_line, covered) in &cov.branch_arms {
243 if lines.contains(&source_line) {
244 b_tot += 1;
245 if covered {
246 b_cov += 1;
247 }
248 }
249 }
250
251 for &(decl_line, covered) in &cov.functions {
253 if lines.contains(&decl_line) {
254 f_tot += 1;
255 if covered {
256 f_cov += 1;
257 }
258 }
259 }
260 }
261
262 let pct = |covered: u64, total: u64| {
265 if total == 0 {
266 100.0
267 } else {
268 100.0 * covered as f64 / total as f64
269 }
270 };
271 let checks = [
272 ("lines", pct(l_cov, l_tot), thresholds.lines),
273 ("branches", pct(b_cov, b_tot), thresholds.branches),
274 ("functions", pct(f_cov, f_tot), thresholds.functions),
275 ("statements", pct(s_cov, s_tot), thresholds.statements),
276 ];
277 let mut shortfalls = Vec::new();
278 for (name, actual, required) in checks {
279 if actual + 1e-9 < f64::from(required) {
282 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
283 }
284 }
285 if shortfalls.is_empty() {
286 Outcome::Pass
287 } else {
288 Outcome::Fail(format!(
289 "coverage below thresholds: {}",
290 shortfalls.join(", ")
291 ))
292 }
293}
294
295pub fn measure_rust(
306 root: &Path,
307 base: &str,
308 thresholds: RustThresholds,
309 ignore: &[String],
310) -> Result<Outcome> {
311 let mut changed = changed_lines(root, base)?;
312 changed.retain(|path, _| path.ends_with(".rs"));
313 if changed.is_empty() {
314 return Ok(Outcome::Pass);
315 }
316 let detail = relative_keys(coverage::measure_patch_rust_detail(root, ignore)?, root);
317 Ok(evaluate_patch_rust(&changed, &detail, thresholds))
318}
319
320fn evaluate_patch_rust(
337 changed: &BTreeMap<String, BTreeSet<u64>>,
338 detail: &BTreeMap<String, coverage::RustPatchCoverage>,
339 thresholds: RustThresholds,
340) -> Outcome {
341 let (mut r_cov, mut r_tot) = (0u64, 0u64);
342 let (mut l_cov, mut l_tot) = (0u64, 0u64);
343
344 for (file, lines) in changed {
345 let Some(cov) = detail.get(file) else {
346 continue;
347 };
348
349 for &(start, end, covered) in &cov.regions {
351 if (start..=end).any(|line| lines.contains(&line)) {
352 r_tot += 1;
353 if covered {
354 r_cov += 1;
355 }
356 }
357 }
358
359 for &line in lines {
362 let mut measured = false;
363 let mut covered_here = false;
364 for &(start, end, covered) in &cov.regions {
365 if start <= line && line <= end {
366 measured = true;
367 covered_here |= covered;
368 }
369 }
370 if measured {
371 l_tot += 1;
372 if covered_here {
373 l_cov += 1;
374 }
375 }
376 }
377 }
378
379 let pct = |covered: u64, total: u64| {
382 if total == 0 {
383 100.0
384 } else {
385 100.0 * covered as f64 / total as f64
386 }
387 };
388 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
391 if let Some(regions) = thresholds.regions {
392 checks.push(("regions", pct(r_cov, r_tot), regions));
393 }
394 checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
395 let mut shortfalls = Vec::new();
396 for (name, actual, required) in checks {
397 if actual + 1e-9 < f64::from(required) {
400 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
401 }
402 }
403 if shortfalls.is_empty() {
404 Outcome::Pass
405 } else {
406 Outcome::Fail(format!(
407 "coverage below thresholds: {}",
408 shortfalls.join(", ")
409 ))
410 }
411}
412
413pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
422 let range = format!("{base}...HEAD");
423 let output = Command::new("git")
424 .current_dir(repo)
425 .args([
426 "diff",
427 "--no-color",
428 "--no-renames",
429 "--unified=0",
430 "--relative",
431 &range,
432 ])
433 .output()
434 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
435 if !output.status.success() {
436 bail!(
437 "`git diff {range}` failed in `{}`: {}",
438 repo.display(),
439 String::from_utf8_lossy(&output.stderr).trim()
440 );
441 }
442 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
443}
444
445fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
451 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
452 let mut current: Option<String> = None;
453 let mut next_line: u64 = 0;
454 for line in diff.lines() {
455 if let Some(header) = line.strip_prefix("+++ ") {
456 current = new_side_path(header);
457 } else if line.starts_with("@@") {
458 if let Some(start) = hunk_new_start(line) {
459 next_line = start;
460 }
461 } else if line.starts_with('+') {
462 if let Some(file) = ¤t {
465 changed.entry(file.clone()).or_default().insert(next_line);
466 }
467 next_line += 1;
468 }
469 }
471 changed
472}
473
474fn new_side_path(header: &str) -> Option<String> {
477 let path = header
478 .split('\t')
479 .next()
480 .unwrap_or(header)
481 .trim_end_matches('\r');
482 if path == "/dev/null" {
483 return None;
484 }
485 let path = path.strip_prefix("b/").unwrap_or(path);
486 Some(path.replace('\\', "/"))
487}
488
489fn hunk_new_start(header: &str) -> Option<u64> {
492 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
493 let digits = plus.trim_start_matches('+');
494 digits.split(',').next().unwrap_or(digits).parse().ok()
495}
496
497fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
502 files
503 .into_iter()
504 .map(|(key, value)| {
505 let path = Path::new(&key);
506 let rel = path
507 .strip_prefix(root)
508 .unwrap_or(path)
509 .to_string_lossy()
510 .replace('\\', "/");
511 (rel, value)
512 })
513 .collect()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
521 entries
522 .iter()
523 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
524 .collect()
525 }
526
527 #[test]
530 fn parses_added_lines_from_a_hunk() {
531 let diff = "diff --git a/widget.py b/widget.py\n\
534 index abc..def 100644\n\
535 --- a/widget.py\n\
536 +++ b/widget.py\n\
537 @@ -3,0 +4,2 @@ def f(x):\n\
538 + if x == 99:\n\
539 + return 7\n";
540 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
541 }
542
543 #[test]
544 fn parses_a_new_file_as_added_from_line_one() {
545 let diff = "diff --git a/lonely.py b/lonely.py\n\
546 new file mode 100644\n\
547 index 0000000..bbb\n\
548 --- /dev/null\n\
549 +++ b/lonely.py\n\
550 @@ -0,0 +1,2 @@\n\
551 +def lonely():\n\
552 + return 41\n";
553 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
554 }
555
556 #[test]
557 fn a_deletion_only_hunk_records_no_added_lines() {
558 let diff = "diff --git a/widget.py b/widget.py\n\
560 index abc..def 100644\n\
561 --- a/widget.py\n\
562 +++ b/widget.py\n\
563 @@ -4,2 +3,0 @@ def f(x):\n\
564 - dead = 1\n\
565 - return dead\n";
566 assert!(parse_unified_diff(diff).is_empty());
567 }
568
569 #[test]
570 fn a_deleted_file_yields_no_entry() {
571 let diff = "diff --git a/gone.py b/gone.py\n\
572 deleted file mode 100644\n\
573 index abc..0000000\n\
574 --- a/gone.py\n\
575 +++ /dev/null\n\
576 @@ -1,2 +0,0 @@\n\
577 -def gone():\n\
578 - return 0\n";
579 assert!(parse_unified_diff(diff).is_empty());
580 }
581
582 #[test]
583 fn parses_multiple_files_and_a_single_line_hunk() {
584 let diff = "diff --git a/a.py b/a.py\n\
586 --- a/a.py\n\
587 +++ b/a.py\n\
588 @@ -1,0 +2 @@ def a():\n\
589 + x = 1\n\
590 diff --git a/pkg/b.py b/pkg/b.py\n\
591 --- a/pkg/b.py\n\
592 +++ b/pkg/b.py\n\
593 @@ -10,0 +11,1 @@\n\
594 + y = 2\n";
595 assert_eq!(
596 parse_unified_diff(diff),
597 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
598 );
599 }
600
601 fn cov(
604 executed: &[u64],
605 missing: &[u64],
606 executed_branches: &[[i64; 2]],
607 missing_branches: &[[i64; 2]],
608 ) -> FileCoverage {
609 FileCoverage {
610 executed_lines: executed.to_vec(),
611 missing_lines: missing.to_vec(),
612 excluded_lines: Vec::new(),
613 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
614 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
615 }
616 }
617
618 const FLOOR_85: Thresholds = Thresholds {
619 fail_under: 85,
620 branch: true,
621 };
622
623 #[test]
624 fn patch_a_fully_covered_diff_passes() {
625 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
626 assert_eq!(
627 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
628 Outcome::Pass
629 );
630 }
631
632 #[test]
633 fn patch_below_floor_fails_and_names_the_percent() {
634 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
636 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
637 assert!(
638 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
639 "got: {out:?}"
640 );
641 }
642
643 #[test]
644 fn patch_the_same_diff_clears_a_lower_floor() {
645 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
647 let floor_70 = Thresholds {
648 fail_under: 70,
649 branch: true,
650 };
651 assert_eq!(
652 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
653 Outcome::Pass
654 );
655 }
656
657 #[test]
658 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
659 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
662 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
663 assert!(
664 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
665 "got: {out:?}"
666 );
667 }
668
669 #[test]
670 fn patch_branches_off_ignores_arcs() {
671 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
673 let no_branch = Thresholds {
674 fail_under: 85,
675 branch: false,
676 };
677 assert_eq!(
678 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
679 Outcome::Pass
680 );
681 }
682
683 #[test]
684 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
685 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
688 assert_eq!(
689 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
690 Outcome::Pass
691 );
692 }
693
694 #[test]
695 fn patch_a_diff_with_no_executable_changed_lines_passes() {
696 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
698 assert_eq!(
699 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
700 Outcome::Pass
701 );
702 }
703
704 use coverage::TsPatchCoverage;
707
708 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
709 entries
710 .iter()
711 .map(|(path, cov)| (path.to_string(), cov.clone()))
712 .collect()
713 }
714
715 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
716 lines: 80,
717 branches: 80,
718 functions: 80,
719 statements: 80,
720 };
721
722 #[test]
723 fn ts_patch_a_fully_covered_diff_passes() {
724 let detail = ts_detail(&[(
727 "w.ts",
728 TsPatchCoverage {
729 statements: vec![(1, 1, true), (2, 2, true)],
730 branch_arms: vec![(2, true)],
731 functions: vec![(1, true)],
732 },
733 )]);
734 assert_eq!(
735 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
736 Outcome::Pass
737 );
738 }
739
740 #[test]
741 fn ts_patch_below_floor_fails_and_names_the_metric() {
742 let detail = ts_detail(&[(
746 "w.ts",
747 TsPatchCoverage {
748 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
749 branch_arms: vec![],
750 functions: vec![],
751 },
752 )]);
753 let out =
754 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
755 assert!(
756 matches!(&out, Outcome::Fail(m)
757 if m.contains("statements 75.00% < 80%")
758 && m.contains("lines 75.00% < 80%")
759 && !m.contains("branches")
760 && !m.contains("functions")),
761 "got: {out:?}"
762 );
763 }
764
765 #[test]
766 fn ts_patch_the_same_diff_clears_a_lower_floor() {
767 let detail = ts_detail(&[(
769 "w.ts",
770 TsPatchCoverage {
771 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
772 branch_arms: vec![],
773 functions: vec![],
774 },
775 )]);
776 let floor_70 = TypeScriptThresholds {
777 lines: 70,
778 branches: 70,
779 functions: 70,
780 statements: 70,
781 };
782 assert_eq!(
783 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
784 Outcome::Pass
785 );
786 }
787
788 #[test]
789 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
790 let detail = ts_detail(&[(
793 "w.ts",
794 TsPatchCoverage {
795 statements: vec![(3, 3, true)],
796 branch_arms: vec![(3, true), (3, false)],
797 functions: vec![],
798 },
799 )]);
800 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
801 assert!(
802 matches!(&out, Outcome::Fail(m)
803 if m.contains("branches 50.00% < 80%")
804 && !m.contains("lines")
805 && !m.contains("statements")),
806 "got: {out:?}"
807 );
808 }
809
810 #[test]
811 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
812 let detail = ts_detail(&[(
814 "w.ts",
815 TsPatchCoverage {
816 statements: vec![],
817 branch_arms: vec![],
818 functions: vec![(9, false)],
819 },
820 )]);
821 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
822 assert!(
823 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
824 "got: {out:?}"
825 );
826 }
827
828 #[test]
829 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
830 let detail = ts_detail(&[(
833 "w.ts",
834 TsPatchCoverage {
835 statements: vec![(1, 1, true)],
836 branch_arms: vec![],
837 functions: vec![],
838 },
839 )]);
840 assert_eq!(
841 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
842 Outcome::Pass
843 );
844 }
845
846 #[test]
847 fn ts_patch_a_comment_only_diff_passes() {
848 let detail = ts_detail(&[(
851 "w.ts",
852 TsPatchCoverage {
853 statements: vec![(1, 1, true), (2, 2, true)],
854 branch_arms: vec![(2, true)],
855 functions: vec![(1, true)],
856 },
857 )]);
858 assert_eq!(
859 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
860 Outcome::Pass
861 );
862 }
863
864 #[test]
865 fn ts_patch_an_empty_diff_passes() {
866 assert_eq!(
868 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
869 Outcome::Pass
870 );
871 }
872
873 #[test]
874 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
875 let detail = ts_detail(&[(
879 "w.ts",
880 TsPatchCoverage {
881 statements: vec![(3, 5, false)],
882 branch_arms: vec![],
883 functions: vec![],
884 },
885 )]);
886 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
887 assert!(
888 matches!(&out, Outcome::Fail(m)
889 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
890 "got: {out:?}"
891 );
892 }
893
894 use coverage::RustPatchCoverage;
897
898 fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
899 entries
900 .iter()
901 .map(|(path, cov)| (path.to_string(), cov.clone()))
902 .collect()
903 }
904
905 const RUST_FLOOR_80: RustThresholds = RustThresholds {
906 regions: Some(80),
907 lines: 80,
908 };
909
910 #[test]
911 fn rust_patch_a_fully_covered_diff_passes() {
912 let detail = rust_detail(&[(
915 "w.rs",
916 RustPatchCoverage {
917 regions: vec![(1, 1, true), (2, 2, true)],
918 },
919 )]);
920 assert_eq!(
921 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
922 Outcome::Pass
923 );
924 }
925
926 #[test]
927 fn rust_patch_below_floor_fails_and_names_the_metrics() {
928 let detail = rust_detail(&[(
931 "w.rs",
932 RustPatchCoverage {
933 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
934 },
935 )]);
936 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
937 assert!(
938 matches!(&out, Outcome::Fail(m)
939 if m.contains("regions 75.00% < 80%")
940 && m.contains("lines 75.00% < 80%")),
941 "got: {out:?}"
942 );
943 }
944
945 #[test]
946 fn rust_patch_the_same_diff_clears_a_lower_floor() {
947 let detail = rust_detail(&[(
949 "w.rs",
950 RustPatchCoverage {
951 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
952 },
953 )]);
954 let floor_70 = RustThresholds {
955 regions: Some(70),
956 lines: 70,
957 };
958 assert_eq!(
959 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
960 Outcome::Pass
961 );
962 }
963
964 #[test]
965 fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
966 let detail = rust_detail(&[(
971 "w.rs",
972 RustPatchCoverage {
973 regions: vec![(1, 4, true), (4, 4, false)],
974 },
975 )]);
976 let lines_only = RustThresholds {
977 regions: None,
978 lines: 100,
979 };
980 assert_eq!(
981 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
982 Outcome::Pass
983 );
984 }
985
986 #[test]
987 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
988 let detail = rust_detail(&[(
991 "w.rs",
992 RustPatchCoverage {
993 regions: vec![(5, 5, false)],
994 },
995 )]);
996 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
997 assert!(
998 matches!(&out, Outcome::Fail(m)
999 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1000 "got: {out:?}"
1001 );
1002 }
1003
1004 #[test]
1005 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1006 let detail = rust_detail(&[(
1009 "w.rs",
1010 RustPatchCoverage {
1011 regions: vec![(1, 1, true)],
1012 },
1013 )]);
1014 assert_eq!(
1015 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1016 Outcome::Pass
1017 );
1018 }
1019
1020 #[test]
1021 fn rust_patch_a_comment_only_diff_passes() {
1022 let detail = rust_detail(&[(
1025 "w.rs",
1026 RustPatchCoverage {
1027 regions: vec![(1, 1, true), (2, 2, true)],
1028 },
1029 )]);
1030 assert_eq!(
1031 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1032 Outcome::Pass
1033 );
1034 }
1035
1036 #[test]
1037 fn rust_patch_an_empty_diff_passes() {
1038 assert_eq!(
1040 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1041 Outcome::Pass
1042 );
1043 }
1044
1045 #[test]
1046 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1047 let detail = rust_detail(&[(
1051 "w.rs",
1052 RustPatchCoverage {
1053 regions: vec![(3, 5, false)],
1054 },
1055 )]);
1056 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1057 assert!(
1058 matches!(&out, Outcome::Fail(m)
1059 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1060 "got: {out:?}"
1061 );
1062 }
1063
1064 #[test]
1065 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1066 let detail = rust_detail(&[(
1071 "w.rs",
1072 RustPatchCoverage {
1073 regions: vec![(4, 4, false), (4, 6, true)],
1074 },
1075 )]);
1076 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1077 assert!(
1078 matches!(&out, Outcome::Fail(m)
1079 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1080 "got: {out:?}"
1081 );
1082 }
1083}