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>>> {
471 let range = format!("{base}...HEAD");
472 let output = Command::new("git")
473 .current_dir(repo)
474 .args([
475 "-c",
476 "core.quotepath=off",
477 "diff",
478 "--no-color",
479 "--no-ext-diff",
480 "--no-renames",
481 "--unified=0",
482 "--relative",
483 "--src-prefix=a/",
484 "--dst-prefix=b/",
485 &range,
486 ])
487 .output()
488 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
489 if !output.status.success() {
490 bail!(
491 "`git diff {range}` failed in `{}`: {}",
492 repo.display(),
493 String::from_utf8_lossy(&output.stderr).trim()
494 );
495 }
496 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
497}
498
499fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
511 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
512 let mut current: Option<String> = None;
513 let mut next_line: u64 = 0;
514 let mut in_hunk = false;
515 for line in diff.lines() {
516 if line.starts_with("diff --git ") {
517 in_hunk = false;
520 current = None;
521 } else if line.starts_with("@@") {
522 in_hunk = true;
523 if let Some(start) = hunk_new_start(line) {
524 next_line = start;
525 }
526 } else if !in_hunk {
527 if let Some(header) = line.strip_prefix("+++ ") {
530 current = new_side_path(header);
531 }
532 } else if line.starts_with('+') {
533 if let Some(file) = ¤t {
536 changed.entry(file.clone()).or_default().insert(next_line);
537 }
538 next_line += 1;
539 }
540 }
542 changed
543}
544
545fn new_side_path(header: &str) -> Option<String> {
549 let raw = header
550 .split('\t')
551 .next()
552 .unwrap_or(header)
553 .trim_end_matches('\r');
554 if raw == "/dev/null" {
555 return None;
556 }
557 let unquoted = unquote_c_path(raw);
559 let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
560 Some(path.replace('\\', "/"))
561}
562
563pub(crate) fn unquote_c_path(path: &str) -> String {
569 let bytes = path.as_bytes();
570 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
571 return path.to_string();
572 }
573 let inner = &bytes[1..bytes.len() - 1];
574 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
575 let mut i = 0;
576 while i < inner.len() {
577 if inner[i] != b'\\' || i + 1 >= inner.len() {
578 out.push(inner[i]);
579 i += 1;
580 continue;
581 }
582 let next = inner[i + 1];
583 if (b'0'..=b'7').contains(&next) {
584 let mut value: u32 = 0;
586 let mut k = i + 1;
587 while k < inner.len() && k < i + 4 && (b'0'..=b'7').contains(&inner[k]) {
588 value = value * 8 + u32::from(inner[k] - b'0');
589 k += 1;
590 }
591 out.push(value as u8);
592 i = k;
593 } else {
594 let decoded = match next {
595 b'a' => 0x07,
596 b'b' => 0x08,
597 b't' => b'\t',
598 b'n' => b'\n',
599 b'v' => 0x0b,
600 b'f' => 0x0c,
601 b'r' => b'\r',
602 other => other, };
604 out.push(decoded);
605 i += 2;
606 }
607 }
608 String::from_utf8_lossy(&out).into_owned()
609}
610
611fn hunk_new_start(header: &str) -> Option<u64> {
614 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
615 let digits = plus.trim_start_matches('+');
616 digits.split(',').next().unwrap_or(digits).parse().ok()
617}
618
619fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
624 files
625 .into_iter()
626 .map(|(key, value)| {
627 let path = Path::new(&key);
628 let rel = path
629 .strip_prefix(root)
630 .unwrap_or(path)
631 .to_string_lossy()
632 .replace('\\', "/");
633 (rel, value)
634 })
635 .collect()
636}
637
638pub fn measure_line_exempt(
654 root: &Path,
655 thresholds: Thresholds,
656 omit: &[String],
657 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
658) -> Result<Outcome> {
659 let report = coverage::measure_report(root, omit)?;
660 let files = relative_keys(report.files, root);
661 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
662 .iter()
663 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
664 .collect();
665 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
666 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
667 Ok(floor_outcome(covered, total, thresholds.fail_under))
668}
669
670pub fn measure_line_exempt_typescript(
675 root: &Path,
676 thresholds: TypeScriptThresholds,
677 exclude: &[String],
678 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
679) -> Result<Outcome> {
680 let detail = relative_keys(
681 coverage::measure_patch_typescript_detail(root, exclude)?,
682 root,
683 );
684 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
685 .iter()
686 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
687 .collect();
688 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
689 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
690}
691
692pub fn measure_line_exempt_rust(
697 root: &Path,
698 thresholds: RustThresholds,
699 ignore: &[String],
700 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
701 features: &[String],
702) -> Result<Outcome> {
703 let detail = relative_keys(
704 coverage::measure_patch_rust_detail(root, ignore, features)?,
705 root,
706 );
707 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
708 .iter()
709 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
710 .collect();
711 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
712 Ok(evaluate_patch_rust(&line_set, &detail, thresholds))
713}
714
715fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
719 if total == 0 {
720 return Outcome::Pass;
721 }
722 let actual = 100.0 * covered as f64 / total as f64;
723 if actual + 1e-9 >= f64::from(fail_under) {
724 Outcome::Pass
725 } else {
726 Outcome::Fail(format!(
727 "coverage {actual:.2}% is below the required {fail_under}%"
728 ))
729 }
730}
731
732fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
737 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
738 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
739 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
740 let mut missed = missing;
741 if branch {
742 for arc in &cov.missing_branches {
743 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
744 if measured.contains(&src) {
745 missed.insert(src);
746 }
747 }
748 }
749 }
750 (measured, missed)
751}
752
753fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
758 let mut measured = BTreeSet::new();
759 let mut missed = BTreeSet::new();
760 let units = cov
764 .statements
765 .iter()
766 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
767 .chain(cov.branch_arms.iter().copied())
768 .chain(cov.functions.iter().copied());
769 for (line, covered) in units {
770 measured.insert(line);
771 if !covered {
772 missed.insert(line);
773 }
774 }
775 (measured, missed)
776}
777
778fn rust_measured_missed(
783 cov: &coverage::RustPatchCoverage,
784 thresholds: RustThresholds,
785) -> (BTreeSet<u64>, BTreeSet<u64>) {
786 let mut measured = BTreeSet::new();
787 for &(start, end, _covered) in &cov.regions {
788 for line in start..=end {
789 measured.insert(line);
790 }
791 }
792 let mut missed = BTreeSet::new();
793 for &line in &measured {
794 let mut covered_here = false;
795 let mut uncovered_region = false;
796 for &(start, end, covered) in &cov.regions {
797 if start <= line && line <= end {
798 if covered {
799 covered_here = true;
800 } else {
801 uncovered_region = true;
802 }
803 }
804 }
805 let is_missed = if thresholds.regions.is_some() {
806 uncovered_region
807 } else {
808 !covered_here
809 };
810 if is_missed {
811 missed.insert(line);
812 }
813 }
814 (measured, missed)
815}
816
817fn apply_line_exemptions(
822 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
823 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
824) -> Result<BTreeMap<String, BTreeSet<u64>>> {
825 let mut over: Vec<String> = Vec::new();
826 for (file, lines) in exempt_lines {
827 let missed = detail.get(file).map(|(_, missed)| missed);
828 for &line in lines {
829 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
830 if !failing {
831 over.push(format!("\n {file}:{line}"));
832 }
833 }
834 }
835 if !over.is_empty() {
836 bail!(
837 "a line-scoped coverage exemption may only list uncovered lines, but these are \
838 covered or carry no measured code:{}",
839 over.concat()
840 );
841 }
842 let mut line_set = BTreeMap::new();
843 for (file, (measured, _)) in detail {
844 let exempt = exempt_lines.get(file);
845 let kept: BTreeSet<u64> = measured
846 .iter()
847 .copied()
848 .filter(|&line| {
849 !exempt.is_some_and(|exempt| {
850 u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
851 })
852 })
853 .collect();
854 line_set.insert(file.clone(), kept);
855 }
856 Ok(line_set)
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862
863 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
864 entries
865 .iter()
866 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
867 .collect()
868 }
869
870 #[test]
871 fn parses_added_lines_from_a_hunk() {
872 let diff = "diff --git a/widget.py b/widget.py\n\
875 index abc..def 100644\n\
876 --- a/widget.py\n\
877 +++ b/widget.py\n\
878 @@ -3,0 +4,2 @@ def f(x):\n\
879 + if x == 99:\n\
880 + return 7\n";
881 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
882 }
883
884 #[test]
885 fn parses_a_new_file_as_added_from_line_one() {
886 let diff = "diff --git a/lonely.py b/lonely.py\n\
887 new file mode 100644\n\
888 index 0000000..bbb\n\
889 --- /dev/null\n\
890 +++ b/lonely.py\n\
891 @@ -0,0 +1,2 @@\n\
892 +def lonely():\n\
893 + return 41\n";
894 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
895 }
896
897 #[test]
898 fn a_deletion_only_hunk_records_no_added_lines() {
899 let diff = "diff --git a/widget.py b/widget.py\n\
901 index abc..def 100644\n\
902 --- a/widget.py\n\
903 +++ b/widget.py\n\
904 @@ -4,2 +3,0 @@ def f(x):\n\
905 - dead = 1\n\
906 - return dead\n";
907 assert!(parse_unified_diff(diff).is_empty());
908 }
909
910 #[test]
911 fn a_deleted_file_yields_no_entry() {
912 let diff = "diff --git a/gone.py b/gone.py\n\
913 deleted file mode 100644\n\
914 index abc..0000000\n\
915 --- a/gone.py\n\
916 +++ /dev/null\n\
917 @@ -1,2 +0,0 @@\n\
918 -def gone():\n\
919 - return 0\n";
920 assert!(parse_unified_diff(diff).is_empty());
921 }
922
923 #[test]
924 fn parses_multiple_files_and_a_single_line_hunk() {
925 let diff = "diff --git a/a.py b/a.py\n\
927 --- a/a.py\n\
928 +++ b/a.py\n\
929 @@ -1,0 +2 @@ def a():\n\
930 + x = 1\n\
931 diff --git a/pkg/b.py b/pkg/b.py\n\
932 --- a/pkg/b.py\n\
933 +++ b/pkg/b.py\n\
934 @@ -10,0 +11,1 @@\n\
935 + y = 2\n";
936 assert_eq!(
937 parse_unified_diff(diff),
938 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
939 );
940 }
941
942 #[test]
943 fn a_plus_plus_body_line_is_not_a_file_header() {
944 let diff = "diff --git a/w.py b/w.py\n\
950 index abc..def 100644\n\
951 --- a/w.py\n\
952 +++ b/w.py\n\
953 @@ -1,0 +1,3 @@\n\
954 +++ 1\n\
955 +y = 1\n\
956 +z = 2\n";
957 assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
958 }
959
960 #[test]
961 fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
962 assert_eq!(
968 new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
969 Some("src/föö.py")
970 );
971 assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
973 }
974
975 #[test]
976 fn unquote_c_path_decodes_octal_and_named_escapes() {
977 assert_eq!(
979 unquote_c_path("\"src/f\\303\\266\\303\\266.py\""),
980 "src/föö.py"
981 );
982 assert_eq!(unquote_c_path("\"a\\tb\\\"c\\\\d\""), "a\tb\"c\\d");
984 assert_eq!(
985 unquote_c_path("\"\\a\\b\\n\\v\\f\\r\""),
986 "\u{7}\u{8}\n\u{b}\u{c}\r"
987 );
988 assert_eq!(unquote_c_path("\"\\1015\""), "A5");
990 }
991
992 #[test]
993 fn unquote_c_path_leaves_an_unquoted_path_unchanged() {
994 assert_eq!(unquote_c_path("src/föö.py"), "src/föö.py");
996 assert_eq!(unquote_c_path("\""), "\"");
998 assert_eq!(unquote_c_path(""), "");
999 assert_eq!(unquote_c_path("\"a\\\""), "a\\");
1001 }
1002
1003 fn cov(
1004 executed: &[u64],
1005 missing: &[u64],
1006 executed_branches: &[[i64; 2]],
1007 missing_branches: &[[i64; 2]],
1008 ) -> FileCoverage {
1009 FileCoverage {
1010 executed_lines: executed.to_vec(),
1011 missing_lines: missing.to_vec(),
1012 excluded_lines: Vec::new(),
1013 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
1014 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
1015 }
1016 }
1017
1018 const FLOOR_85: Thresholds = Thresholds {
1019 fail_under: 85,
1020 branch: true,
1021 };
1022
1023 #[test]
1024 fn patch_a_fully_covered_diff_passes() {
1025 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
1026 assert_eq!(
1027 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
1028 Outcome::Pass
1029 );
1030 }
1031
1032 #[test]
1033 fn patch_below_floor_fails_and_names_the_percent() {
1034 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
1036 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
1037 assert!(
1038 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
1039 "got: {out:?}"
1040 );
1041 }
1042
1043 #[test]
1044 fn patch_the_same_diff_clears_a_lower_floor() {
1045 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
1047 let floor_70 = Thresholds {
1048 fail_under: 70,
1049 branch: true,
1050 };
1051 assert_eq!(
1052 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
1053 Outcome::Pass
1054 );
1055 }
1056
1057 #[test]
1058 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
1059 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
1062 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
1063 assert!(
1064 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
1065 "got: {out:?}"
1066 );
1067 }
1068
1069 #[test]
1070 fn patch_branches_off_ignores_arcs() {
1071 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
1073 let no_branch = Thresholds {
1074 fail_under: 85,
1075 branch: false,
1076 };
1077 assert_eq!(
1078 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
1079 Outcome::Pass
1080 );
1081 }
1082
1083 #[test]
1084 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
1085 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
1088 assert_eq!(
1089 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
1090 Outcome::Pass
1091 );
1092 }
1093
1094 #[test]
1095 fn patch_a_diff_with_no_executable_changed_lines_passes() {
1096 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
1098 assert_eq!(
1099 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
1100 Outcome::Pass
1101 );
1102 }
1103
1104 use coverage::TsPatchCoverage;
1105
1106 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
1107 entries
1108 .iter()
1109 .map(|(path, cov)| (path.to_string(), cov.clone()))
1110 .collect()
1111 }
1112
1113 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
1114 lines: 80,
1115 branches: 80,
1116 functions: 80,
1117 statements: 80,
1118 };
1119
1120 #[test]
1121 fn ts_patch_a_fully_covered_diff_passes() {
1122 let detail = ts_detail(&[(
1125 "w.ts",
1126 TsPatchCoverage {
1127 statements: vec![(1, 1, true), (2, 2, true)],
1128 branch_arms: vec![(2, true)],
1129 functions: vec![(1, true)],
1130 },
1131 )]);
1132 assert_eq!(
1133 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1134 Outcome::Pass
1135 );
1136 }
1137
1138 #[test]
1139 fn ts_patch_below_floor_fails_and_names_the_metric() {
1140 let detail = ts_detail(&[(
1144 "w.ts",
1145 TsPatchCoverage {
1146 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1147 branch_arms: vec![],
1148 functions: vec![],
1149 },
1150 )]);
1151 let out =
1152 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
1153 assert!(
1154 matches!(&out, Outcome::Fail(m)
1155 if m.contains("statements 75.00% < 80%")
1156 && m.contains("lines 75.00% < 80%")
1157 && !m.contains("branches")
1158 && !m.contains("functions")),
1159 "got: {out:?}"
1160 );
1161 }
1162
1163 #[test]
1164 fn ts_patch_the_same_diff_clears_a_lower_floor() {
1165 let detail = ts_detail(&[(
1167 "w.ts",
1168 TsPatchCoverage {
1169 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1170 branch_arms: vec![],
1171 functions: vec![],
1172 },
1173 )]);
1174 let floor_70 = TypeScriptThresholds {
1175 lines: 70,
1176 branches: 70,
1177 functions: 70,
1178 statements: 70,
1179 };
1180 assert_eq!(
1181 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
1182 Outcome::Pass
1183 );
1184 }
1185
1186 #[test]
1187 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
1188 let detail = ts_detail(&[(
1191 "w.ts",
1192 TsPatchCoverage {
1193 statements: vec![(3, 3, true)],
1194 branch_arms: vec![(3, true), (3, false)],
1195 functions: vec![],
1196 },
1197 )]);
1198 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1199 assert!(
1200 matches!(&out, Outcome::Fail(m)
1201 if m.contains("branches 50.00% < 80%")
1202 && !m.contains("lines")
1203 && !m.contains("statements")),
1204 "got: {out:?}"
1205 );
1206 }
1207
1208 #[test]
1209 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1210 let detail = ts_detail(&[(
1212 "w.ts",
1213 TsPatchCoverage {
1214 statements: vec![],
1215 branch_arms: vec![],
1216 functions: vec![(9, false)],
1217 },
1218 )]);
1219 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1220 assert!(
1221 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1222 "got: {out:?}"
1223 );
1224 }
1225
1226 #[test]
1227 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1228 let detail = ts_detail(&[(
1231 "w.ts",
1232 TsPatchCoverage {
1233 statements: vec![(1, 1, true)],
1234 branch_arms: vec![],
1235 functions: vec![],
1236 },
1237 )]);
1238 assert_eq!(
1239 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1240 Outcome::Pass
1241 );
1242 }
1243
1244 #[test]
1245 fn ts_patch_a_comment_only_diff_passes() {
1246 let detail = ts_detail(&[(
1249 "w.ts",
1250 TsPatchCoverage {
1251 statements: vec![(1, 1, true), (2, 2, true)],
1252 branch_arms: vec![(2, true)],
1253 functions: vec![(1, true)],
1254 },
1255 )]);
1256 assert_eq!(
1257 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
1258 Outcome::Pass
1259 );
1260 }
1261
1262 #[test]
1263 fn ts_patch_an_empty_diff_passes() {
1264 assert_eq!(
1266 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1267 Outcome::Pass
1268 );
1269 }
1270
1271 #[test]
1272 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1273 let detail = ts_detail(&[(
1277 "w.ts",
1278 TsPatchCoverage {
1279 statements: vec![(3, 5, false)],
1280 branch_arms: vec![],
1281 functions: vec![],
1282 },
1283 )]);
1284 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1285 assert!(
1286 matches!(&out, Outcome::Fail(m)
1287 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1288 "got: {out:?}"
1289 );
1290 }
1291
1292 use coverage::RustPatchCoverage;
1293
1294 fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
1295 entries
1296 .iter()
1297 .map(|(path, cov)| (path.to_string(), cov.clone()))
1298 .collect()
1299 }
1300
1301 const RUST_FLOOR_80: RustThresholds = RustThresholds {
1302 regions: Some(80),
1303 lines: 80,
1304 functions: None,
1305 branch: None,
1306 };
1307
1308 #[test]
1309 fn rust_patch_a_fully_covered_diff_passes() {
1310 let detail = rust_detail(&[(
1313 "w.rs",
1314 RustPatchCoverage {
1315 regions: vec![(1, 1, true), (2, 2, true)],
1316 },
1317 )]);
1318 assert_eq!(
1319 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1320 Outcome::Pass
1321 );
1322 }
1323
1324 #[test]
1325 fn rust_patch_below_floor_fails_and_names_the_metrics() {
1326 let detail = rust_detail(&[(
1329 "w.rs",
1330 RustPatchCoverage {
1331 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1332 },
1333 )]);
1334 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
1335 assert!(
1336 matches!(&out, Outcome::Fail(m)
1337 if m.contains("regions 75.00% < 80%")
1338 && m.contains("lines 75.00% < 80%")),
1339 "got: {out:?}"
1340 );
1341 }
1342
1343 #[test]
1344 fn rust_patch_the_same_diff_clears_a_lower_floor() {
1345 let detail = rust_detail(&[(
1347 "w.rs",
1348 RustPatchCoverage {
1349 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1350 },
1351 )]);
1352 let floor_70 = RustThresholds {
1353 regions: Some(70),
1354 lines: 70,
1355 functions: None,
1356 branch: None,
1357 };
1358 assert_eq!(
1359 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
1360 Outcome::Pass
1361 );
1362 }
1363
1364 #[test]
1365 fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
1366 let detail = rust_detail(&[(
1371 "w.rs",
1372 RustPatchCoverage {
1373 regions: vec![(1, 4, true), (4, 4, false)],
1374 },
1375 )]);
1376 let lines_only = RustThresholds {
1377 regions: None,
1378 lines: 100,
1379 functions: None,
1380 branch: None,
1381 };
1382 assert_eq!(
1383 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
1384 Outcome::Pass
1385 );
1386 }
1387
1388 #[test]
1389 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
1390 let detail = rust_detail(&[(
1393 "w.rs",
1394 RustPatchCoverage {
1395 regions: vec![(5, 5, false)],
1396 },
1397 )]);
1398 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
1399 assert!(
1400 matches!(&out, Outcome::Fail(m)
1401 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1402 "got: {out:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1408 let detail = rust_detail(&[(
1411 "w.rs",
1412 RustPatchCoverage {
1413 regions: vec![(1, 1, true)],
1414 },
1415 )]);
1416 assert_eq!(
1417 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1418 Outcome::Pass
1419 );
1420 }
1421
1422 #[test]
1423 fn rust_patch_a_comment_only_diff_passes() {
1424 let detail = rust_detail(&[(
1427 "w.rs",
1428 RustPatchCoverage {
1429 regions: vec![(1, 1, true), (2, 2, true)],
1430 },
1431 )]);
1432 assert_eq!(
1433 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1434 Outcome::Pass
1435 );
1436 }
1437
1438 #[test]
1439 fn rust_patch_an_empty_diff_passes() {
1440 assert_eq!(
1442 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1443 Outcome::Pass
1444 );
1445 }
1446
1447 #[test]
1448 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1449 let detail = rust_detail(&[(
1453 "w.rs",
1454 RustPatchCoverage {
1455 regions: vec![(3, 5, false)],
1456 },
1457 )]);
1458 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1459 assert!(
1460 matches!(&out, Outcome::Fail(m)
1461 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1462 "got: {out:?}"
1463 );
1464 }
1465
1466 #[test]
1467 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1468 let detail = rust_detail(&[(
1473 "w.rs",
1474 RustPatchCoverage {
1475 regions: vec![(4, 4, false), (4, 6, true)],
1476 },
1477 )]);
1478 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1479 assert!(
1480 matches!(&out, Outcome::Fail(m)
1481 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1482 "got: {out:?}"
1483 );
1484 }
1485
1486 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1487 entries
1488 .iter()
1489 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1490 .collect()
1491 }
1492
1493 #[test]
1494 fn python_measured_missed_reads_lines_and_branch_sources() {
1495 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1499 let (measured, missed) = python_measured_missed(&full, true);
1500 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1501 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1502 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1504 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1505 assert!(missed_no_branch.is_empty());
1506 let (_, missed_branch) = python_measured_missed(&partial, true);
1507 assert_eq!(missed_branch, [5].into_iter().collect());
1508 }
1509
1510 #[test]
1511 fn ts_measured_missed_anchors_units_on_their_lines() {
1512 let cov = coverage::TsPatchCoverage {
1515 statements: vec![(1, 1, true), (3, 4, false)],
1516 branch_arms: vec![(1, false)],
1517 functions: vec![(6, false)],
1518 };
1519 let (measured, missed) = ts_measured_missed(&cov);
1520 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1521 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1523 }
1524
1525 #[test]
1526 fn rust_measured_missed_honors_the_enforced_metrics() {
1527 let cov = coverage::RustPatchCoverage {
1529 regions: vec![(1, 1, true), (5, 6, false)],
1530 };
1531 let with_regions = RustThresholds {
1532 regions: Some(100),
1533 lines: 100,
1534 functions: None,
1535 branch: None,
1536 };
1537 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1538 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1539 assert_eq!(missed, [5, 6].into_iter().collect());
1540 let lines_only = RustThresholds {
1543 regions: None,
1544 lines: 100,
1545 functions: None,
1546 branch: None,
1547 };
1548 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1549 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1550 }
1551
1552 #[test]
1553 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1554 let detail = BTreeMap::from([(
1556 "shim.py".to_string(),
1557 (
1558 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1559 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1560 ),
1561 )]);
1562 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1563 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1564 }
1565
1566 #[test]
1567 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1568 let detail = BTreeMap::from([(
1570 "shim.py".to_string(),
1571 (
1572 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1573 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1574 ),
1575 )]);
1576 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1577 assert!(
1578 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1579 "got: {err}"
1580 );
1581 }
1582
1583 #[test]
1584 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1585 let detail = BTreeMap::from([(
1587 "w.py".to_string(),
1588 (
1589 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1590 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1591 ),
1592 )]);
1593 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1594 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1595 }
1596
1597 #[test]
1598 fn floor_outcome_matches_the_whole_tree_message() {
1599 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1600 let out = floor_outcome(7, 8, 100);
1601 assert!(
1602 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1603 "got: {out:?}"
1604 );
1605 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1607 }
1608
1609 #[test]
1610 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1611 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1615 lift_exempt_lines(
1616 &mut changed,
1617 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1618 );
1619 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1620 assert_eq!(changed["core.py"], [5].into_iter().collect());
1621 }
1622}