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 checks = [
389 ("regions", pct(r_cov, r_tot), thresholds.regions),
390 ("lines", pct(l_cov, l_tot), thresholds.lines),
391 ];
392 let mut shortfalls = Vec::new();
393 for (name, actual, required) in checks {
394 if actual + 1e-9 < f64::from(required) {
397 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
398 }
399 }
400 if shortfalls.is_empty() {
401 Outcome::Pass
402 } else {
403 Outcome::Fail(format!(
404 "coverage below thresholds: {}",
405 shortfalls.join(", ")
406 ))
407 }
408}
409
410pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
419 let range = format!("{base}...HEAD");
420 let output = Command::new("git")
421 .current_dir(repo)
422 .args([
423 "diff",
424 "--no-color",
425 "--no-renames",
426 "--unified=0",
427 "--relative",
428 &range,
429 ])
430 .output()
431 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
432 if !output.status.success() {
433 bail!(
434 "`git diff {range}` failed in `{}`: {}",
435 repo.display(),
436 String::from_utf8_lossy(&output.stderr).trim()
437 );
438 }
439 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
440}
441
442fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
448 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
449 let mut current: Option<String> = None;
450 let mut next_line: u64 = 0;
451 for line in diff.lines() {
452 if let Some(header) = line.strip_prefix("+++ ") {
453 current = new_side_path(header);
454 } else if line.starts_with("@@") {
455 if let Some(start) = hunk_new_start(line) {
456 next_line = start;
457 }
458 } else if line.starts_with('+') {
459 if let Some(file) = ¤t {
462 changed.entry(file.clone()).or_default().insert(next_line);
463 }
464 next_line += 1;
465 }
466 }
468 changed
469}
470
471fn new_side_path(header: &str) -> Option<String> {
474 let path = header
475 .split('\t')
476 .next()
477 .unwrap_or(header)
478 .trim_end_matches('\r');
479 if path == "/dev/null" {
480 return None;
481 }
482 let path = path.strip_prefix("b/").unwrap_or(path);
483 Some(path.replace('\\', "/"))
484}
485
486fn hunk_new_start(header: &str) -> Option<u64> {
489 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
490 let digits = plus.trim_start_matches('+');
491 digits.split(',').next().unwrap_or(digits).parse().ok()
492}
493
494fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
499 files
500 .into_iter()
501 .map(|(key, value)| {
502 let path = Path::new(&key);
503 let rel = path
504 .strip_prefix(root)
505 .unwrap_or(path)
506 .to_string_lossy()
507 .replace('\\', "/");
508 (rel, value)
509 })
510 .collect()
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
518 entries
519 .iter()
520 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
521 .collect()
522 }
523
524 #[test]
527 fn parses_added_lines_from_a_hunk() {
528 let diff = "diff --git a/widget.py b/widget.py\n\
531 index abc..def 100644\n\
532 --- a/widget.py\n\
533 +++ b/widget.py\n\
534 @@ -3,0 +4,2 @@ def f(x):\n\
535 + if x == 99:\n\
536 + return 7\n";
537 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
538 }
539
540 #[test]
541 fn parses_a_new_file_as_added_from_line_one() {
542 let diff = "diff --git a/lonely.py b/lonely.py\n\
543 new file mode 100644\n\
544 index 0000000..bbb\n\
545 --- /dev/null\n\
546 +++ b/lonely.py\n\
547 @@ -0,0 +1,2 @@\n\
548 +def lonely():\n\
549 + return 41\n";
550 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
551 }
552
553 #[test]
554 fn a_deletion_only_hunk_records_no_added_lines() {
555 let diff = "diff --git a/widget.py b/widget.py\n\
557 index abc..def 100644\n\
558 --- a/widget.py\n\
559 +++ b/widget.py\n\
560 @@ -4,2 +3,0 @@ def f(x):\n\
561 - dead = 1\n\
562 - return dead\n";
563 assert!(parse_unified_diff(diff).is_empty());
564 }
565
566 #[test]
567 fn a_deleted_file_yields_no_entry() {
568 let diff = "diff --git a/gone.py b/gone.py\n\
569 deleted file mode 100644\n\
570 index abc..0000000\n\
571 --- a/gone.py\n\
572 +++ /dev/null\n\
573 @@ -1,2 +0,0 @@\n\
574 -def gone():\n\
575 - return 0\n";
576 assert!(parse_unified_diff(diff).is_empty());
577 }
578
579 #[test]
580 fn parses_multiple_files_and_a_single_line_hunk() {
581 let diff = "diff --git a/a.py b/a.py\n\
583 --- a/a.py\n\
584 +++ b/a.py\n\
585 @@ -1,0 +2 @@ def a():\n\
586 + x = 1\n\
587 diff --git a/pkg/b.py b/pkg/b.py\n\
588 --- a/pkg/b.py\n\
589 +++ b/pkg/b.py\n\
590 @@ -10,0 +11,1 @@\n\
591 + y = 2\n";
592 assert_eq!(
593 parse_unified_diff(diff),
594 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
595 );
596 }
597
598 fn cov(
601 executed: &[u64],
602 missing: &[u64],
603 executed_branches: &[[i64; 2]],
604 missing_branches: &[[i64; 2]],
605 ) -> FileCoverage {
606 FileCoverage {
607 executed_lines: executed.to_vec(),
608 missing_lines: missing.to_vec(),
609 excluded_lines: Vec::new(),
610 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
611 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
612 }
613 }
614
615 const FLOOR_85: Thresholds = Thresholds {
616 fail_under: 85,
617 branch: true,
618 };
619
620 #[test]
621 fn patch_a_fully_covered_diff_passes() {
622 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
623 assert_eq!(
624 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
625 Outcome::Pass
626 );
627 }
628
629 #[test]
630 fn patch_below_floor_fails_and_names_the_percent() {
631 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
633 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
634 assert!(
635 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
636 "got: {out:?}"
637 );
638 }
639
640 #[test]
641 fn patch_the_same_diff_clears_a_lower_floor() {
642 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
644 let floor_70 = Thresholds {
645 fail_under: 70,
646 branch: true,
647 };
648 assert_eq!(
649 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
650 Outcome::Pass
651 );
652 }
653
654 #[test]
655 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
656 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
659 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
660 assert!(
661 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
662 "got: {out:?}"
663 );
664 }
665
666 #[test]
667 fn patch_branches_off_ignores_arcs() {
668 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
670 let no_branch = Thresholds {
671 fail_under: 85,
672 branch: false,
673 };
674 assert_eq!(
675 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
676 Outcome::Pass
677 );
678 }
679
680 #[test]
681 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
682 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
685 assert_eq!(
686 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
687 Outcome::Pass
688 );
689 }
690
691 #[test]
692 fn patch_a_diff_with_no_executable_changed_lines_passes() {
693 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
695 assert_eq!(
696 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
697 Outcome::Pass
698 );
699 }
700
701 use coverage::TsPatchCoverage;
704
705 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
706 entries
707 .iter()
708 .map(|(path, cov)| (path.to_string(), cov.clone()))
709 .collect()
710 }
711
712 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
713 lines: 80,
714 branches: 80,
715 functions: 80,
716 statements: 80,
717 };
718
719 #[test]
720 fn ts_patch_a_fully_covered_diff_passes() {
721 let detail = ts_detail(&[(
724 "w.ts",
725 TsPatchCoverage {
726 statements: vec![(1, 1, true), (2, 2, true)],
727 branch_arms: vec![(2, true)],
728 functions: vec![(1, true)],
729 },
730 )]);
731 assert_eq!(
732 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
733 Outcome::Pass
734 );
735 }
736
737 #[test]
738 fn ts_patch_below_floor_fails_and_names_the_metric() {
739 let detail = ts_detail(&[(
743 "w.ts",
744 TsPatchCoverage {
745 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
746 branch_arms: vec![],
747 functions: vec![],
748 },
749 )]);
750 let out =
751 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
752 assert!(
753 matches!(&out, Outcome::Fail(m)
754 if m.contains("statements 75.00% < 80%")
755 && m.contains("lines 75.00% < 80%")
756 && !m.contains("branches")
757 && !m.contains("functions")),
758 "got: {out:?}"
759 );
760 }
761
762 #[test]
763 fn ts_patch_the_same_diff_clears_a_lower_floor() {
764 let detail = ts_detail(&[(
766 "w.ts",
767 TsPatchCoverage {
768 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
769 branch_arms: vec![],
770 functions: vec![],
771 },
772 )]);
773 let floor_70 = TypeScriptThresholds {
774 lines: 70,
775 branches: 70,
776 functions: 70,
777 statements: 70,
778 };
779 assert_eq!(
780 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
781 Outcome::Pass
782 );
783 }
784
785 #[test]
786 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
787 let detail = ts_detail(&[(
790 "w.ts",
791 TsPatchCoverage {
792 statements: vec![(3, 3, true)],
793 branch_arms: vec![(3, true), (3, false)],
794 functions: vec![],
795 },
796 )]);
797 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
798 assert!(
799 matches!(&out, Outcome::Fail(m)
800 if m.contains("branches 50.00% < 80%")
801 && !m.contains("lines")
802 && !m.contains("statements")),
803 "got: {out:?}"
804 );
805 }
806
807 #[test]
808 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
809 let detail = ts_detail(&[(
811 "w.ts",
812 TsPatchCoverage {
813 statements: vec![],
814 branch_arms: vec![],
815 functions: vec![(9, false)],
816 },
817 )]);
818 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
819 assert!(
820 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
821 "got: {out:?}"
822 );
823 }
824
825 #[test]
826 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
827 let detail = ts_detail(&[(
830 "w.ts",
831 TsPatchCoverage {
832 statements: vec![(1, 1, true)],
833 branch_arms: vec![],
834 functions: vec![],
835 },
836 )]);
837 assert_eq!(
838 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
839 Outcome::Pass
840 );
841 }
842
843 #[test]
844 fn ts_patch_a_comment_only_diff_passes() {
845 let detail = ts_detail(&[(
848 "w.ts",
849 TsPatchCoverage {
850 statements: vec![(1, 1, true), (2, 2, true)],
851 branch_arms: vec![(2, true)],
852 functions: vec![(1, true)],
853 },
854 )]);
855 assert_eq!(
856 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
857 Outcome::Pass
858 );
859 }
860
861 #[test]
862 fn ts_patch_an_empty_diff_passes() {
863 assert_eq!(
865 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
866 Outcome::Pass
867 );
868 }
869
870 #[test]
871 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
872 let detail = ts_detail(&[(
876 "w.ts",
877 TsPatchCoverage {
878 statements: vec![(3, 5, false)],
879 branch_arms: vec![],
880 functions: vec![],
881 },
882 )]);
883 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
884 assert!(
885 matches!(&out, Outcome::Fail(m)
886 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
887 "got: {out:?}"
888 );
889 }
890
891 use coverage::RustPatchCoverage;
894
895 fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
896 entries
897 .iter()
898 .map(|(path, cov)| (path.to_string(), cov.clone()))
899 .collect()
900 }
901
902 const RUST_FLOOR_80: RustThresholds = RustThresholds {
903 regions: 80,
904 lines: 80,
905 };
906
907 #[test]
908 fn rust_patch_a_fully_covered_diff_passes() {
909 let detail = rust_detail(&[(
912 "w.rs",
913 RustPatchCoverage {
914 regions: vec![(1, 1, true), (2, 2, true)],
915 },
916 )]);
917 assert_eq!(
918 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
919 Outcome::Pass
920 );
921 }
922
923 #[test]
924 fn rust_patch_below_floor_fails_and_names_the_metrics() {
925 let detail = rust_detail(&[(
928 "w.rs",
929 RustPatchCoverage {
930 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
931 },
932 )]);
933 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
934 assert!(
935 matches!(&out, Outcome::Fail(m)
936 if m.contains("regions 75.00% < 80%")
937 && m.contains("lines 75.00% < 80%")),
938 "got: {out:?}"
939 );
940 }
941
942 #[test]
943 fn rust_patch_the_same_diff_clears_a_lower_floor() {
944 let detail = rust_detail(&[(
946 "w.rs",
947 RustPatchCoverage {
948 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
949 },
950 )]);
951 let floor_70 = RustThresholds {
952 regions: 70,
953 lines: 70,
954 };
955 assert_eq!(
956 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
957 Outcome::Pass
958 );
959 }
960
961 #[test]
962 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
963 let detail = rust_detail(&[(
966 "w.rs",
967 RustPatchCoverage {
968 regions: vec![(5, 5, false)],
969 },
970 )]);
971 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
972 assert!(
973 matches!(&out, Outcome::Fail(m)
974 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
975 "got: {out:?}"
976 );
977 }
978
979 #[test]
980 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
981 let detail = rust_detail(&[(
984 "w.rs",
985 RustPatchCoverage {
986 regions: vec![(1, 1, true)],
987 },
988 )]);
989 assert_eq!(
990 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
991 Outcome::Pass
992 );
993 }
994
995 #[test]
996 fn rust_patch_a_comment_only_diff_passes() {
997 let detail = rust_detail(&[(
1000 "w.rs",
1001 RustPatchCoverage {
1002 regions: vec![(1, 1, true), (2, 2, true)],
1003 },
1004 )]);
1005 assert_eq!(
1006 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1007 Outcome::Pass
1008 );
1009 }
1010
1011 #[test]
1012 fn rust_patch_an_empty_diff_passes() {
1013 assert_eq!(
1015 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1016 Outcome::Pass
1017 );
1018 }
1019
1020 #[test]
1021 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1022 let detail = rust_detail(&[(
1026 "w.rs",
1027 RustPatchCoverage {
1028 regions: vec![(3, 5, false)],
1029 },
1030 )]);
1031 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1032 assert!(
1033 matches!(&out, Outcome::Fail(m)
1034 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1035 "got: {out:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1041 let detail = rust_detail(&[(
1046 "w.rs",
1047 RustPatchCoverage {
1048 regions: vec![(4, 4, false), (4, 6, true)],
1049 },
1050 )]);
1051 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1052 assert!(
1053 matches!(&out, Outcome::Fail(m)
1054 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1055 "got: {out:?}"
1056 );
1057 }
1058}