1use std::collections::{BTreeMap, BTreeSet};
6use std::path::Path;
7use std::process::Command;
8
9use anyhow::{bail, Context, Result};
10
11use crate::coverage::{
12 self, FileCoverage, Outcome, RustThresholds, Thresholds, TypeScriptThresholds,
13};
14
15const TS_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"];
18
19pub fn measure(
23 root: &Path,
24 base: &str,
25 thresholds: Thresholds,
26 omit: &[String],
27 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
28) -> Result<Outcome> {
29 let mut changed = changed_lines(root, base)?;
30 changed.retain(|path, _| path.ends_with(".py"));
31 lift_exempt_lines(&mut changed, exempt_lines);
32 if changed.is_empty() {
33 return Ok(Outcome::Pass);
34 }
35 let report = coverage::measure_report(root, omit)?;
36 let files = relative_keys(report.files, root);
37 Ok(evaluate_patch(&changed, &files, thresholds))
38}
39
40fn lift_exempt_lines(
44 changed: &mut BTreeMap<String, BTreeSet<u64>>,
45 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
46) {
47 for (file, exempt) in exempt_lines {
48 if let Some(lines) = changed.get_mut(file) {
49 lines.retain(|&line| !u32::try_from(line).is_ok_and(|line| exempt.contains(&line)));
50 }
51 }
52}
53
54fn evaluate_patch(
58 changed: &BTreeMap<String, BTreeSet<u64>>,
59 files: &BTreeMap<String, FileCoverage>,
60 thresholds: Thresholds,
61) -> Outcome {
62 let (covered, total) = python_ratio(changed, files, thresholds.branch);
63 if total == 0 {
64 return Outcome::Pass;
65 }
66 let actual = 100.0 * covered as f64 / total as f64;
67 if actual + 1e-9 >= f64::from(thresholds.fail_under) {
70 Outcome::Pass
71 } else {
72 Outcome::Fail(format!(
73 "changed-line coverage {actual:.2}% is below the required {}%",
74 thresholds.fail_under
75 ))
76 }
77}
78
79fn python_ratio(
83 selected: &BTreeMap<String, BTreeSet<u64>>,
84 files: &BTreeMap<String, FileCoverage>,
85 branch: bool,
86) -> (u64, u64) {
87 let mut covered: u64 = 0;
88 let mut total: u64 = 0;
89 for (file, lines) in selected {
90 let Some(cov) = files.get(file) else {
91 continue;
92 };
93 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
94 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
95 for &line in lines {
96 if executed.contains(&line) {
97 covered += 1;
98 total += 1;
99 } else if missing.contains(&line) {
100 total += 1;
101 }
102 }
103 if branch {
104 for arc in &cov.executed_branches {
105 if arc_source_in(arc, lines) {
106 covered += 1;
107 total += 1;
108 }
109 }
110 for arc in &cov.missing_branches {
111 if arc_source_in(arc, lines) {
112 total += 1;
113 }
114 }
115 }
116 }
117 (covered, total)
118}
119
120fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
122 arc.first()
123 .and_then(|&src| u64::try_from(src).ok())
124 .is_some_and(|src| lines.contains(&src))
125}
126
127pub fn measure_typescript(
131 root: &Path,
132 base: &str,
133 thresholds: TypeScriptThresholds,
134 exclude: &[String],
135 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
136) -> Result<Outcome> {
137 let mut changed = changed_lines(root, base)?;
138 changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
139 lift_exempt_lines(&mut changed, exempt_lines);
140 if changed.is_empty() {
141 return Ok(Outcome::Pass);
142 }
143 let detail = relative_keys(
144 coverage::measure_patch_typescript_detail(root, exclude)?,
145 root,
146 );
147 Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
148}
149
150fn evaluate_patch_typescript(
154 changed: &BTreeMap<String, BTreeSet<u64>>,
155 detail: &BTreeMap<String, coverage::TsPatchCoverage>,
156 thresholds: TypeScriptThresholds,
157) -> Outcome {
158 let (mut s_cov, mut s_tot) = (0u64, 0u64);
159 let (mut l_cov, mut l_tot) = (0u64, 0u64);
160 let (mut b_cov, mut b_tot) = (0u64, 0u64);
161 let (mut f_cov, mut f_tot) = (0u64, 0u64);
162
163 for (file, lines) in changed {
164 let Some(cov) = detail.get(file) else {
165 continue;
166 };
167
168 for &(start, end, covered) in &cov.statements {
169 if (start..=end).any(|line| lines.contains(&line)) {
170 s_tot += 1;
171 if covered {
172 s_cov += 1;
173 }
174 }
175 }
176
177 for &line in lines {
178 let mut starts_here = false;
179 let mut covered_here = false;
180 for &(start, _end, covered) in &cov.statements {
181 if start == line {
182 starts_here = true;
183 covered_here |= covered;
184 }
185 }
186 if starts_here {
187 l_tot += 1;
188 if covered_here {
189 l_cov += 1;
190 }
191 }
192 }
193
194 for &(source_line, covered) in &cov.branch_arms {
195 if lines.contains(&source_line) {
196 b_tot += 1;
197 if covered {
198 b_cov += 1;
199 }
200 }
201 }
202
203 for &(decl_line, covered) in &cov.functions {
204 if lines.contains(&decl_line) {
205 f_tot += 1;
206 if covered {
207 f_cov += 1;
208 }
209 }
210 }
211 }
212
213 let pct = |covered: u64, total: u64| {
214 if total == 0 {
215 100.0
216 } else {
217 100.0 * covered as f64 / total as f64
218 }
219 };
220 let checks = [
221 ("lines", pct(l_cov, l_tot), thresholds.lines),
222 ("branches", pct(b_cov, b_tot), thresholds.branches),
223 ("functions", pct(f_cov, f_tot), thresholds.functions),
224 ("statements", pct(s_cov, s_tot), thresholds.statements),
225 ];
226 let mut shortfalls = Vec::new();
227 for (name, actual, required) in checks {
228 if actual + 1e-9 < f64::from(required) {
231 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
232 }
233 }
234 if shortfalls.is_empty() {
235 Outcome::Pass
236 } else {
237 Outcome::Fail(format!(
238 "coverage below thresholds: {}",
239 shortfalls.join(", ")
240 ))
241 }
242}
243
244pub fn measure_rust(
248 root: &Path,
249 base: &str,
250 thresholds: RustThresholds,
251 ignore: &[String],
252 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
253 features: &[String],
254) -> Result<Outcome> {
255 let mut changed = changed_lines(root, base)?;
256 changed.retain(|path, _| path.ends_with(".rs"));
257 lift_exempt_lines(&mut changed, exempt_lines);
258 if changed.is_empty() {
259 return Ok(Outcome::Pass);
260 }
261 let detail = relative_keys(
262 coverage::measure_patch_rust_detail(root, ignore, features)?,
263 root,
264 );
265 Ok(crate::rust_patch_coverage::evaluate_patch_rust(
266 &changed, &detail, thresholds,
267 ))
268}
269
270pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
274 let range = format!("{base}...HEAD");
275 let output = Command::new("git")
276 .current_dir(repo)
277 .args([
278 "-c",
279 "core.quotepath=off",
280 "diff",
281 "--no-color",
282 "--no-ext-diff",
283 "--no-renames",
284 "--unified=0",
285 "--relative",
286 "--src-prefix=a/",
287 "--dst-prefix=b/",
288 &range,
289 ])
290 .output()
291 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
292 if !output.status.success() {
293 bail!(
294 "`git diff {range}` failed in `{}`: {}",
295 repo.display(),
296 String::from_utf8_lossy(&output.stderr).trim()
297 );
298 }
299 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
300}
301
302fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
306 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
307 let mut current: Option<String> = None;
308 let mut next_line: u64 = 0;
309 let mut in_hunk = false;
310 for line in diff.lines() {
311 if line.starts_with("diff --git ") {
312 in_hunk = false;
313 current = None;
314 } else if line.starts_with("@@") {
315 in_hunk = true;
316 if let Some(start) = hunk_new_start(line) {
317 next_line = start;
318 }
319 } else if !in_hunk {
320 if let Some(header) = line.strip_prefix("+++ ") {
321 current = new_side_path(header);
322 }
323 } else if line.starts_with('+') {
324 if let Some(file) = ¤t {
325 changed.entry(file.clone()).or_default().insert(next_line);
326 }
327 next_line += 1;
328 }
329 }
330 changed
331}
332
333fn new_side_path(header: &str) -> Option<String> {
336 let raw = header
337 .split('\t')
338 .next()
339 .unwrap_or(header)
340 .trim_end_matches('\r');
341 if raw == "/dev/null" {
342 return None;
343 }
344 let unquoted = unquote_c_path(raw);
346 let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
347 Some(path.replace('\\', "/"))
348}
349
350pub(crate) fn unquote_c_path(path: &str) -> String {
354 let bytes = path.as_bytes();
355 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
356 return path.to_string();
357 }
358 let inner = &bytes[1..bytes.len() - 1];
359 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
360 let mut i = 0;
361 while i < inner.len() {
362 if inner[i] != b'\\' || i + 1 >= inner.len() {
363 out.push(inner[i]);
364 i += 1;
365 continue;
366 }
367 let next = inner[i + 1];
368 if (b'0'..=b'7').contains(&next) {
369 let mut value: u32 = 0;
370 let mut k = i + 1;
371 while k < inner.len() && k < i + 4 && (b'0'..=b'7').contains(&inner[k]) {
372 value = value * 8 + u32::from(inner[k] - b'0');
373 k += 1;
374 }
375 out.push(value as u8);
376 i = k;
377 } else {
378 let decoded = match next {
379 b'a' => 0x07,
380 b'b' => 0x08,
381 b't' => b'\t',
382 b'n' => b'\n',
383 b'v' => 0x0b,
384 b'f' => 0x0c,
385 b'r' => b'\r',
386 other => other, };
388 out.push(decoded);
389 i += 2;
390 }
391 }
392 String::from_utf8_lossy(&out).into_owned()
393}
394
395fn hunk_new_start(header: &str) -> Option<u64> {
398 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
399 let digits = plus.trim_start_matches('+');
400 digits.split(',').next().unwrap_or(digits).parse().ok()
401}
402
403fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
407 files
408 .into_iter()
409 .map(|(key, value)| {
410 let path = Path::new(&key);
411 let rel = path
412 .strip_prefix(root)
413 .unwrap_or(path)
414 .to_string_lossy()
415 .replace('\\', "/");
416 (rel, value)
417 })
418 .collect()
419}
420
421pub fn measure_line_exempt(
425 root: &Path,
426 thresholds: Thresholds,
427 omit: &[String],
428 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
429) -> Result<Outcome> {
430 let report = coverage::measure_report(root, omit)?;
431 let files = relative_keys(report.files, root);
432 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
433 .iter()
434 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
435 .collect();
436 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
437 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
438 Ok(floor_outcome(covered, total, thresholds.fail_under))
439}
440
441pub fn measure_line_exempt_typescript(
444 root: &Path,
445 thresholds: TypeScriptThresholds,
446 exclude: &[String],
447 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
448) -> Result<Outcome> {
449 let detail = relative_keys(
450 coverage::measure_patch_typescript_detail(root, exclude)?,
451 root,
452 );
453 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
454 .iter()
455 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
456 .collect();
457 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
458 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
459}
460
461pub fn measure_line_exempt_rust(
464 root: &Path,
465 thresholds: RustThresholds,
466 ignore: &[String],
467 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
468 features: &[String],
469) -> Result<Outcome> {
470 let detail = relative_keys(
471 coverage::measure_patch_rust_detail(root, ignore, features)?,
472 root,
473 );
474 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
475 .iter()
476 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
477 .collect();
478 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
479 Ok(crate::rust_patch_coverage::evaluate_patch_rust(
480 &line_set, &detail, thresholds,
481 ))
482}
483
484fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
487 if total == 0 {
488 return Outcome::Pass;
489 }
490 let actual = 100.0 * covered as f64 / total as f64;
491 if actual + 1e-9 >= f64::from(fail_under) {
492 Outcome::Pass
493 } else {
494 Outcome::Fail(format!(
495 "coverage {actual:.2}% is below the required {fail_under}%"
496 ))
497 }
498}
499
500fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
503 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
504 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
505 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
506 let mut missed = missing;
507 if branch {
508 for arc in &cov.missing_branches {
509 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
510 if measured.contains(&src) {
511 missed.insert(src);
512 }
513 }
514 }
515 }
516 (measured, missed)
517}
518
519fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
522 let mut measured = BTreeSet::new();
523 let mut missed = BTreeSet::new();
524 let units = cov
525 .statements
526 .iter()
527 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
528 .chain(cov.branch_arms.iter().copied())
529 .chain(cov.functions.iter().copied());
530 for (line, covered) in units {
531 measured.insert(line);
532 if !covered {
533 missed.insert(line);
534 }
535 }
536 (measured, missed)
537}
538
539fn rust_measured_missed(
543 cov: &coverage::RustPatchCoverage,
544 thresholds: RustThresholds,
545) -> (BTreeSet<u64>, BTreeSet<u64>) {
546 let mut measured = BTreeSet::new();
547 for &(start, end, _covered) in &cov.regions {
548 for line in start..=end {
549 measured.insert(line);
550 }
551 }
552 let mut missed = BTreeSet::new();
553 for &line in &measured {
554 let mut covered_here = false;
555 let mut uncovered_region = false;
556 for &(start, end, covered) in &cov.regions {
557 if start <= line && line <= end {
558 if covered {
559 covered_here = true;
560 } else {
561 uncovered_region = true;
562 }
563 }
564 }
565 let is_missed = if thresholds.regions.is_some() {
566 uncovered_region
567 } else {
568 !covered_here
569 };
570 if is_missed {
571 missed.insert(line);
572 }
573 }
574 (measured, missed)
575}
576
577fn apply_line_exemptions(
581 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
582 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
583) -> Result<BTreeMap<String, BTreeSet<u64>>> {
584 let mut over: Vec<String> = Vec::new();
585 for (file, lines) in exempt_lines {
586 let missed = detail.get(file).map(|(_, missed)| missed);
587 for &line in lines {
588 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
589 if !failing {
590 over.push(format!("\n {file}:{line}"));
591 }
592 }
593 }
594 if !over.is_empty() {
595 bail!(
596 "a line-scoped coverage exemption may only list uncovered lines, but these are \
597 covered or carry no measured code:{}",
598 over.concat()
599 );
600 }
601 let mut line_set = BTreeMap::new();
602 for (file, (measured, _)) in detail {
603 let exempt = exempt_lines.get(file);
604 let kept: BTreeSet<u64> = measured
605 .iter()
606 .copied()
607 .filter(|&line| {
608 !exempt.is_some_and(|exempt| {
609 u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
610 })
611 })
612 .collect();
613 line_set.insert(file.clone(), kept);
614 }
615 Ok(line_set)
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
623 entries
624 .iter()
625 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
626 .collect()
627 }
628
629 #[test]
630 fn changed_lines_reports_a_spawn_failure() {
631 let err = changed_lines(Path::new("/nonexistent-tc-patch-coverage"), "main").unwrap_err();
632 assert!(err.to_string().contains("running `git diff`"), "got: {err}");
633 }
634
635 #[test]
636 fn parses_added_lines_from_a_hunk() {
637 let diff = "diff --git a/widget.py b/widget.py\n\
638 index abc..def 100644\n\
639 --- a/widget.py\n\
640 +++ b/widget.py\n\
641 @@ -3,0 +4,2 @@ def f(x):\n\
642 + if x == 99:\n\
643 + return 7\n";
644 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
645 }
646
647 #[test]
648 fn parses_a_new_file_as_added_from_line_one() {
649 let diff = "diff --git a/lonely.py b/lonely.py\n\
650 new file mode 100644\n\
651 index 0000000..bbb\n\
652 --- /dev/null\n\
653 +++ b/lonely.py\n\
654 @@ -0,0 +1,2 @@\n\
655 +def lonely():\n\
656 + return 41\n";
657 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
658 }
659
660 #[test]
661 fn a_deletion_only_hunk_records_no_added_lines() {
662 let diff = "diff --git a/widget.py b/widget.py\n\
663 index abc..def 100644\n\
664 --- a/widget.py\n\
665 +++ b/widget.py\n\
666 @@ -4,2 +3,0 @@ def f(x):\n\
667 - dead = 1\n\
668 - return dead\n";
669 assert!(parse_unified_diff(diff).is_empty());
670 }
671
672 #[test]
673 fn a_deleted_file_yields_no_entry() {
674 let diff = "diff --git a/gone.py b/gone.py\n\
675 deleted file mode 100644\n\
676 index abc..0000000\n\
677 --- a/gone.py\n\
678 +++ /dev/null\n\
679 @@ -1,2 +0,0 @@\n\
680 -def gone():\n\
681 - return 0\n";
682 assert!(parse_unified_diff(diff).is_empty());
683 }
684
685 #[test]
686 fn parses_multiple_files_and_a_single_line_hunk() {
687 let diff = "diff --git a/a.py b/a.py\n\
688 --- a/a.py\n\
689 +++ b/a.py\n\
690 @@ -1,0 +2 @@ def a():\n\
691 + x = 1\n\
692 diff --git a/pkg/b.py b/pkg/b.py\n\
693 --- a/pkg/b.py\n\
694 +++ b/pkg/b.py\n\
695 @@ -10,0 +11,1 @@\n\
696 + y = 2\n";
697 assert_eq!(
698 parse_unified_diff(diff),
699 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
700 );
701 }
702
703 #[test]
704 fn a_plus_plus_body_line_is_not_a_file_header() {
705 let diff = "diff --git a/w.py b/w.py\n\
709 index abc..def 100644\n\
710 --- a/w.py\n\
711 +++ b/w.py\n\
712 @@ -1,0 +1,3 @@\n\
713 +++ 1\n\
714 +y = 1\n\
715 +z = 2\n";
716 assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
717 }
718
719 #[test]
720 fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
721 assert_eq!(
725 new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
726 Some("src/föö.py")
727 );
728 assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
729 }
730
731 #[test]
732 fn unquote_c_path_decodes_octal_and_named_escapes() {
733 assert_eq!(
734 unquote_c_path("\"src/f\\303\\266\\303\\266.py\""),
735 "src/föö.py"
736 );
737 assert_eq!(unquote_c_path("\"a\\tb\\\"c\\\\d\""), "a\tb\"c\\d");
738 assert_eq!(
739 unquote_c_path("\"\\a\\b\\n\\v\\f\\r\""),
740 "\u{7}\u{8}\n\u{b}\u{c}\r"
741 );
742 assert_eq!(unquote_c_path("\"\\1015\""), "A5");
743 }
744
745 #[test]
746 fn unquote_c_path_leaves_an_unquoted_path_unchanged() {
747 assert_eq!(unquote_c_path("src/föö.py"), "src/föö.py");
748 assert_eq!(unquote_c_path("\""), "\"");
749 assert_eq!(unquote_c_path(""), "");
750 assert_eq!(unquote_c_path("\"a\\\""), "a\\");
751 }
752
753 fn cov(
754 executed: &[u64],
755 missing: &[u64],
756 executed_branches: &[[i64; 2]],
757 missing_branches: &[[i64; 2]],
758 ) -> FileCoverage {
759 FileCoverage {
760 executed_lines: executed.to_vec(),
761 missing_lines: missing.to_vec(),
762 excluded_lines: Vec::new(),
763 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
764 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
765 }
766 }
767
768 const FLOOR_85: Thresholds = Thresholds {
769 fail_under: 85,
770 branch: true,
771 };
772
773 #[test]
774 fn patch_a_fully_covered_diff_passes() {
775 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
776 assert_eq!(
777 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
778 Outcome::Pass
779 );
780 }
781
782 #[test]
783 fn patch_below_floor_fails_and_names_the_percent() {
784 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
785 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
786 assert!(
787 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
788 "got: {out:?}"
789 );
790 }
791
792 #[test]
793 fn patch_the_same_diff_clears_a_lower_floor() {
794 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
795 let floor_70 = Thresholds {
796 fail_under: 70,
797 branch: true,
798 };
799 assert_eq!(
800 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
801 Outcome::Pass
802 );
803 }
804
805 #[test]
806 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
807 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
808 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
809 assert!(
810 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
811 "got: {out:?}"
812 );
813 }
814
815 #[test]
816 fn patch_branches_off_ignores_arcs() {
817 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
818 let no_branch = Thresholds {
819 fail_under: 85,
820 branch: false,
821 };
822 assert_eq!(
823 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
824 Outcome::Pass
825 );
826 }
827
828 #[test]
829 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
830 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
831 assert_eq!(
832 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
833 Outcome::Pass
834 );
835 }
836
837 #[test]
838 fn patch_a_diff_with_no_executable_changed_lines_passes() {
839 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
840 assert_eq!(
841 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
842 Outcome::Pass
843 );
844 }
845
846 use coverage::TsPatchCoverage;
847
848 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
849 entries
850 .iter()
851 .map(|(path, cov)| (path.to_string(), cov.clone()))
852 .collect()
853 }
854
855 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
856 lines: 80,
857 branches: 80,
858 functions: 80,
859 statements: 80,
860 };
861
862 #[test]
863 fn ts_patch_a_fully_covered_diff_passes() {
864 let detail = ts_detail(&[(
865 "w.ts",
866 TsPatchCoverage {
867 statements: vec![(1, 1, true), (2, 2, true)],
868 branch_arms: vec![(2, true)],
869 functions: vec![(1, true)],
870 },
871 )]);
872 assert_eq!(
873 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
874 Outcome::Pass
875 );
876 }
877
878 #[test]
879 fn ts_patch_below_floor_fails_and_names_the_metric() {
880 let detail = ts_detail(&[(
881 "w.ts",
882 TsPatchCoverage {
883 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
884 branch_arms: vec![],
885 functions: vec![],
886 },
887 )]);
888 let out =
889 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
890 assert!(
891 matches!(&out, Outcome::Fail(m)
892 if m.contains("statements 75.00% < 80%")
893 && m.contains("lines 75.00% < 80%")
894 && !m.contains("branches")
895 && !m.contains("functions")),
896 "got: {out:?}"
897 );
898 }
899
900 #[test]
901 fn ts_patch_the_same_diff_clears_a_lower_floor() {
902 let detail = ts_detail(&[(
903 "w.ts",
904 TsPatchCoverage {
905 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
906 branch_arms: vec![],
907 functions: vec![],
908 },
909 )]);
910 let floor_70 = TypeScriptThresholds {
911 lines: 70,
912 branches: 70,
913 functions: 70,
914 statements: 70,
915 };
916 assert_eq!(
917 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
918 Outcome::Pass
919 );
920 }
921
922 #[test]
923 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
924 let detail = ts_detail(&[(
925 "w.ts",
926 TsPatchCoverage {
927 statements: vec![(3, 3, true)],
928 branch_arms: vec![(3, true), (3, false)],
929 functions: vec![],
930 },
931 )]);
932 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
933 assert!(
934 matches!(&out, Outcome::Fail(m)
935 if m.contains("branches 50.00% < 80%")
936 && !m.contains("lines")
937 && !m.contains("statements")),
938 "got: {out:?}"
939 );
940 }
941
942 #[test]
943 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
944 let detail = ts_detail(&[(
945 "w.ts",
946 TsPatchCoverage {
947 statements: vec![],
948 branch_arms: vec![],
949 functions: vec![(9, false)],
950 },
951 )]);
952 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
953 assert!(
954 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
955 "got: {out:?}"
956 );
957 }
958
959 #[test]
960 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
961 let detail = ts_detail(&[(
962 "w.ts",
963 TsPatchCoverage {
964 statements: vec![(1, 1, true)],
965 branch_arms: vec![],
966 functions: vec![],
967 },
968 )]);
969 assert_eq!(
970 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
971 Outcome::Pass
972 );
973 }
974
975 #[test]
976 fn ts_patch_a_comment_only_diff_passes() {
977 let detail = ts_detail(&[(
978 "w.ts",
979 TsPatchCoverage {
980 statements: vec![(1, 1, true), (2, 2, true)],
981 branch_arms: vec![(2, true)],
982 functions: vec![(1, true)],
983 },
984 )]);
985 assert_eq!(
986 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
987 Outcome::Pass
988 );
989 }
990
991 #[test]
992 fn ts_patch_an_empty_diff_passes() {
993 assert_eq!(
994 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
995 Outcome::Pass
996 );
997 }
998
999 #[test]
1000 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1001 let detail = ts_detail(&[(
1002 "w.ts",
1003 TsPatchCoverage {
1004 statements: vec![(3, 5, false)],
1005 branch_arms: vec![],
1006 functions: vec![],
1007 },
1008 )]);
1009 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1010 assert!(
1011 matches!(&out, Outcome::Fail(m)
1012 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1013 "got: {out:?}"
1014 );
1015 }
1016
1017 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1018 entries
1019 .iter()
1020 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1021 .collect()
1022 }
1023
1024 #[test]
1025 fn python_measured_missed_reads_lines_and_branch_sources() {
1026 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1027 let (measured, missed) = python_measured_missed(&full, true);
1028 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1029 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1030 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1031 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1032 assert!(missed_no_branch.is_empty());
1033 let (_, missed_branch) = python_measured_missed(&partial, true);
1034 assert_eq!(missed_branch, [5].into_iter().collect());
1035 }
1036
1037 #[test]
1038 fn python_measured_missed_skips_an_arc_from_an_unmeasured_line() {
1039 let partial = cov(&[1], &[], &[], &[[99, 1]]);
1040 let (measured, missed) = python_measured_missed(&partial, true);
1041 assert_eq!(measured, [1].into_iter().collect());
1042 assert!(missed.is_empty(), "got: {missed:?}");
1043 }
1044
1045 #[test]
1046 fn python_measured_missed_skips_an_arc_with_a_negative_source() {
1047 let partial = cov(&[1], &[], &[], &[[-1, 1]]);
1048 let (measured, missed) = python_measured_missed(&partial, true);
1049 assert_eq!(measured, [1].into_iter().collect());
1050 assert!(missed.is_empty(), "got: {missed:?}");
1051 }
1052
1053 #[test]
1054 fn ts_measured_missed_anchors_units_on_their_lines() {
1055 let cov = coverage::TsPatchCoverage {
1056 statements: vec![(1, 1, true), (3, 4, false)],
1057 branch_arms: vec![(1, false)],
1058 functions: vec![(6, false)],
1059 };
1060 let (measured, missed) = ts_measured_missed(&cov);
1061 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1062 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1063 }
1064
1065 #[test]
1066 fn rust_measured_missed_honors_the_enforced_metrics() {
1067 let cov = coverage::RustPatchCoverage {
1068 regions: vec![(1, 1, true), (5, 6, false)],
1069 };
1070 let with_regions = RustThresholds {
1071 regions: Some(100),
1072 lines: 100,
1073 functions: None,
1074 branch: None,
1075 };
1076 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1077 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1078 assert_eq!(missed, [5, 6].into_iter().collect());
1079 let lines_only = RustThresholds {
1080 regions: None,
1081 lines: 100,
1082 functions: None,
1083 branch: None,
1084 };
1085 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1086 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1087 }
1088
1089 #[test]
1090 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1091 let detail = BTreeMap::from([(
1092 "shim.py".to_string(),
1093 (
1094 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1095 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1096 ),
1097 )]);
1098 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1099 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1100 }
1101
1102 #[test]
1103 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1104 let detail = BTreeMap::from([(
1105 "shim.py".to_string(),
1106 (
1107 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1108 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1109 ),
1110 )]);
1111 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1112 assert!(
1113 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1114 "got: {err}"
1115 );
1116 }
1117
1118 #[test]
1119 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1120 let detail = BTreeMap::from([(
1121 "w.py".to_string(),
1122 (
1123 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1124 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1125 ),
1126 )]);
1127 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1128 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1129 }
1130
1131 #[test]
1132 fn floor_outcome_matches_the_whole_tree_message() {
1133 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1134 let out = floor_outcome(7, 8, 100);
1135 assert!(
1136 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1137 "got: {out:?}"
1138 );
1139 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1140 }
1141
1142 #[test]
1143 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1144 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1145 lift_exempt_lines(
1146 &mut changed,
1147 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1148 );
1149 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1150 assert_eq!(changed["core.py"], [5].into_iter().collect());
1151 }
1152}