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
150#[derive(Debug, Default, Clone, Copy, PartialEq)]
152struct Tally {
153 covered: u64,
154 total: u64,
155}
156
157impl Tally {
158 fn count(&mut self, covered: bool) {
160 self.total += 1;
161 self.covered += u64::from(covered);
162 }
163
164 fn add(&mut self, other: Tally) {
165 self.covered += other.covered;
166 self.total += other.total;
167 }
168
169 fn percent(self) -> f64 {
171 if self.total == 0 {
172 100.0
173 } else {
174 100.0 * self.covered as f64 / self.total as f64
175 }
176 }
177}
178
179fn evaluate_patch_typescript(
181 changed: &BTreeMap<String, BTreeSet<u64>>,
182 detail: &BTreeMap<String, coverage::TsPatchCoverage>,
183 thresholds: TypeScriptThresholds,
184) -> Outcome {
185 let mut statements = Tally::default();
186 let mut lines = Tally::default();
187 let mut branches = Tally::default();
188 let mut functions = Tally::default();
189
190 for (file, touched) in changed {
191 let Some(cov) = detail.get(file) else {
192 continue;
193 };
194 statements.add(statements_tally(&cov.statements, touched));
195 lines.add(lines_tally(&cov.statements, touched));
196 branches.add(on_line_tally(&cov.branch_arms, touched));
197 functions.add(on_line_tally(&cov.functions, touched));
198 }
199
200 let checks = [
201 ("lines", lines.percent(), thresholds.lines),
202 ("branches", branches.percent(), thresholds.branches),
203 ("functions", functions.percent(), thresholds.functions),
204 ("statements", statements.percent(), thresholds.statements),
205 ];
206 coverage::verdict(
207 checks
208 .into_iter()
209 .filter_map(|(name, actual, required)| coverage::shortfall(name, actual, required))
210 .collect(),
211 )
212}
213
214fn statements_tally(statements: &[(u64, u64, bool)], touched: &BTreeSet<u64>) -> Tally {
216 let mut tally = Tally::default();
217 for &(start, end, covered) in statements {
218 if (start..=end).any(|line| touched.contains(&line)) {
219 tally.count(covered);
220 }
221 }
222 tally
223}
224
225fn lines_tally(statements: &[(u64, u64, bool)], touched: &BTreeSet<u64>) -> Tally {
228 let mut tally = Tally::default();
229 for &line in touched {
230 let mut starts_here = false;
231 let mut covered_here = false;
232 for &(start, _end, covered) in statements {
233 if start == line {
234 starts_here = true;
235 covered_here |= covered;
236 }
237 }
238 if starts_here {
239 tally.count(covered_here);
240 }
241 }
242 tally
243}
244
245fn on_line_tally(sites: &[(u64, bool)], touched: &BTreeSet<u64>) -> Tally {
247 let mut tally = Tally::default();
248 for &(line, covered) in sites {
249 if touched.contains(&line) {
250 tally.count(covered);
251 }
252 }
253 tally
254}
255
256pub fn measure_rust(
260 root: &Path,
261 base: &str,
262 thresholds: RustThresholds,
263 ignore: &[String],
264 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
265 features: &[String],
266) -> Result<Outcome> {
267 let mut changed = changed_lines(root, base)?;
268 changed.retain(|path, _| path.ends_with(".rs"));
269 lift_exempt_lines(&mut changed, exempt_lines);
270 if changed.is_empty() {
271 return Ok(Outcome::Pass);
272 }
273 let detail = relative_keys(
274 coverage::measure_patch_rust_detail(root, ignore, features)?,
275 root,
276 );
277 Ok(crate::rust_patch_coverage::evaluate_patch_rust(
278 &changed, &detail, thresholds,
279 ))
280}
281
282pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
286 let range = format!("{base}...HEAD");
287 let output = Command::new("git")
288 .current_dir(repo)
289 .args([
290 "-c",
291 "core.quotepath=off",
292 "diff",
293 "--no-color",
294 "--no-ext-diff",
295 "--no-renames",
296 "--unified=0",
297 "--relative",
298 "--src-prefix=a/",
299 "--dst-prefix=b/",
300 &range,
301 ])
302 .output()
303 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
304 if !output.status.success() {
305 bail!(
306 "`git diff {range}` failed in `{}`: {}",
307 repo.display(),
308 String::from_utf8_lossy(&output.stderr).trim()
309 );
310 }
311 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
312}
313
314fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
318 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
319 let mut current: Option<String> = None;
320 let mut next_line: u64 = 0;
321 let mut in_hunk = false;
322 for line in diff.lines() {
323 if line.starts_with("diff --git ") {
324 in_hunk = false;
325 current = None;
326 } else if line.starts_with("@@") {
327 in_hunk = true;
328 if let Some(start) = hunk_new_start(line) {
329 next_line = start;
330 }
331 } else if !in_hunk {
332 if let Some(header) = line.strip_prefix("+++ ") {
333 current = new_side_path(header);
334 }
335 } else if line.starts_with('+') {
336 if let Some(file) = ¤t {
337 changed.entry(file.clone()).or_default().insert(next_line);
338 }
339 next_line += 1;
340 }
341 }
342 changed
343}
344
345fn new_side_path(header: &str) -> Option<String> {
348 let raw = header
349 .split('\t')
350 .next()
351 .unwrap_or(header)
352 .trim_end_matches('\r');
353 if raw == "/dev/null" {
354 return None;
355 }
356 let unquoted = crate::git_path::unquote_c_path(raw);
358 let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
359 Some(path.replace('\\', "/"))
360}
361
362fn hunk_new_start(header: &str) -> Option<u64> {
365 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
366 let digits = plus.trim_start_matches('+');
367 digits.split(',').next().unwrap_or(digits).parse().ok()
368}
369
370fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
374 files
375 .into_iter()
376 .map(|(key, value)| {
377 let path = Path::new(&key);
378 let rel = path
379 .strip_prefix(root)
380 .unwrap_or(path)
381 .to_string_lossy()
382 .replace('\\', "/");
383 (rel, value)
384 })
385 .collect()
386}
387
388pub fn measure_line_exempt(
392 root: &Path,
393 thresholds: Thresholds,
394 omit: &[String],
395 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
396) -> Result<Outcome> {
397 let report = coverage::measure_report(root, omit)?;
398 let files = relative_keys(report.files, root);
399 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
400 .iter()
401 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
402 .collect();
403 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
404 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
405 Ok(floor_outcome(covered, total, thresholds.fail_under))
406}
407
408pub fn measure_line_exempt_typescript(
411 root: &Path,
412 thresholds: TypeScriptThresholds,
413 exclude: &[String],
414 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
415) -> Result<Outcome> {
416 let detail = relative_keys(
417 coverage::measure_patch_typescript_detail(root, exclude)?,
418 root,
419 );
420 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
421 .iter()
422 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
423 .collect();
424 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
425 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
426}
427
428pub fn measure_line_exempt_rust(
431 root: &Path,
432 thresholds: RustThresholds,
433 ignore: &[String],
434 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
435 features: &[String],
436) -> Result<Outcome> {
437 let detail = relative_keys(
438 coverage::measure_patch_rust_detail(root, ignore, features)?,
439 root,
440 );
441 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
442 .iter()
443 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
444 .collect();
445 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
446 Ok(crate::rust_patch_coverage::evaluate_patch_rust(
447 &line_set, &detail, thresholds,
448 ))
449}
450
451fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
454 if total == 0 {
455 return Outcome::Pass;
456 }
457 let actual = 100.0 * covered as f64 / total as f64;
458 if actual + 1e-9 >= f64::from(fail_under) {
459 Outcome::Pass
460 } else {
461 Outcome::Fail(format!(
462 "coverage {actual:.2}% is below the required {fail_under}%"
463 ))
464 }
465}
466
467fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
470 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
471 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
472 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
473 let mut missed = missing;
474 if branch {
475 for arc in &cov.missing_branches {
476 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
477 if measured.contains(&src) {
478 missed.insert(src);
479 }
480 }
481 }
482 }
483 (measured, missed)
484}
485
486fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
489 let mut measured = BTreeSet::new();
490 let mut missed = BTreeSet::new();
491 let units = cov
492 .statements
493 .iter()
494 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
495 .chain(cov.branch_arms.iter().copied())
496 .chain(cov.functions.iter().copied());
497 for (line, covered) in units {
498 measured.insert(line);
499 if !covered {
500 missed.insert(line);
501 }
502 }
503 (measured, missed)
504}
505
506fn rust_measured_missed(
510 cov: &coverage::RustPatchCoverage,
511 thresholds: RustThresholds,
512) -> (BTreeSet<u64>, BTreeSet<u64>) {
513 let mut measured = BTreeSet::new();
514 for &(start, end, _covered) in &cov.regions {
515 for line in start..=end {
516 measured.insert(line);
517 }
518 }
519 let mut missed = BTreeSet::new();
520 for &line in &measured {
521 let mut covered_here = false;
522 let mut uncovered_region = false;
523 for &(start, end, covered) in &cov.regions {
524 if start <= line && line <= end {
525 if covered {
526 covered_here = true;
527 } else {
528 uncovered_region = true;
529 }
530 }
531 }
532 let is_missed = if thresholds.regions.is_some() {
533 uncovered_region
534 } else {
535 !covered_here
536 };
537 if is_missed {
538 missed.insert(line);
539 }
540 }
541 (measured, missed)
542}
543
544fn apply_line_exemptions(
547 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
548 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
549) -> Result<BTreeMap<String, BTreeSet<u64>>> {
550 guard_exempt_lines_are_failing(detail, exempt_lines)?;
551 Ok(detail
552 .iter()
553 .map(|(file, (measured, _))| (file.clone(), kept_lines(measured, exempt_lines.get(file))))
554 .collect())
555}
556
557fn guard_exempt_lines_are_failing(
560 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
561 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
562) -> Result<()> {
563 let mut over: Vec<String> = Vec::new();
564 for (file, lines) in exempt_lines {
565 let missed = detail.get(file).map(|(_, missed)| missed);
566 for &line in lines {
567 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
568 if !failing {
569 over.push(format!("\n {file}:{line}"));
570 }
571 }
572 }
573 if !over.is_empty() {
574 bail!(
575 "a line-scoped coverage exemption may only list uncovered lines, but these are \
576 covered or carry no measured code:{}",
577 over.concat()
578 );
579 }
580 Ok(())
581}
582
583fn kept_lines(measured: &BTreeSet<u64>, exempt: Option<&BTreeSet<u32>>) -> BTreeSet<u64> {
586 measured
587 .iter()
588 .copied()
589 .filter(|&line| {
590 !exempt
591 .is_some_and(|exempt| u32::try_from(line).is_ok_and(|line| exempt.contains(&line)))
592 })
593 .collect()
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
601 entries
602 .iter()
603 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
604 .collect()
605 }
606
607 #[test]
608 fn changed_lines_reports_a_spawn_failure() {
609 let err = changed_lines(Path::new("/nonexistent-tc-patch-coverage"), "main").unwrap_err();
610 assert!(err.to_string().contains("running `git diff`"), "got: {err}");
611 }
612
613 #[test]
614 fn parses_added_lines_from_a_hunk() {
615 let diff = "diff --git a/widget.py b/widget.py\n\
616 index abc..def 100644\n\
617 --- a/widget.py\n\
618 +++ b/widget.py\n\
619 @@ -3,0 +4,2 @@ def f(x):\n\
620 + if x == 99:\n\
621 + return 7\n";
622 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
623 }
624
625 #[test]
626 fn parses_a_new_file_as_added_from_line_one() {
627 let diff = "diff --git a/lonely.py b/lonely.py\n\
628 new file mode 100644\n\
629 index 0000000..bbb\n\
630 --- /dev/null\n\
631 +++ b/lonely.py\n\
632 @@ -0,0 +1,2 @@\n\
633 +def lonely():\n\
634 + return 41\n";
635 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
636 }
637
638 #[test]
639 fn a_deletion_only_hunk_records_no_added_lines() {
640 let diff = "diff --git a/widget.py b/widget.py\n\
641 index abc..def 100644\n\
642 --- a/widget.py\n\
643 +++ b/widget.py\n\
644 @@ -4,2 +3,0 @@ def f(x):\n\
645 - dead = 1\n\
646 - return dead\n";
647 assert!(parse_unified_diff(diff).is_empty());
648 }
649
650 #[test]
651 fn a_deleted_file_yields_no_entry() {
652 let diff = "diff --git a/gone.py b/gone.py\n\
653 deleted file mode 100644\n\
654 index abc..0000000\n\
655 --- a/gone.py\n\
656 +++ /dev/null\n\
657 @@ -1,2 +0,0 @@\n\
658 -def gone():\n\
659 - return 0\n";
660 assert!(parse_unified_diff(diff).is_empty());
661 }
662
663 #[test]
664 fn parses_multiple_files_and_a_single_line_hunk() {
665 let diff = "diff --git a/a.py b/a.py\n\
666 --- a/a.py\n\
667 +++ b/a.py\n\
668 @@ -1,0 +2 @@ def a():\n\
669 + x = 1\n\
670 diff --git a/pkg/b.py b/pkg/b.py\n\
671 --- a/pkg/b.py\n\
672 +++ b/pkg/b.py\n\
673 @@ -10,0 +11,1 @@\n\
674 + y = 2\n";
675 assert_eq!(
676 parse_unified_diff(diff),
677 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
678 );
679 }
680
681 #[test]
682 fn a_plus_plus_body_line_is_not_a_file_header() {
683 let diff = "diff --git a/w.py b/w.py\n\
687 index abc..def 100644\n\
688 --- a/w.py\n\
689 +++ b/w.py\n\
690 @@ -1,0 +1,3 @@\n\
691 +++ 1\n\
692 +y = 1\n\
693 +z = 2\n";
694 assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
695 }
696
697 #[test]
698 fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
699 assert_eq!(
703 new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
704 Some("src/föö.py")
705 );
706 assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
707 }
708
709 fn cov(
710 executed: &[u64],
711 missing: &[u64],
712 executed_branches: &[[i64; 2]],
713 missing_branches: &[[i64; 2]],
714 ) -> FileCoverage {
715 FileCoverage {
716 executed_lines: executed.to_vec(),
717 missing_lines: missing.to_vec(),
718 excluded_lines: Vec::new(),
719 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
720 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
721 }
722 }
723
724 const FLOOR_85: Thresholds = Thresholds {
725 fail_under: 85,
726 branch: true,
727 };
728
729 #[test]
730 fn patch_a_fully_covered_diff_passes() {
731 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
732 assert_eq!(
733 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
734 Outcome::Pass
735 );
736 }
737
738 #[test]
739 fn patch_below_floor_fails_and_names_the_percent() {
740 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
741 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
742 assert!(
743 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
744 "got: {out:?}"
745 );
746 }
747
748 #[test]
749 fn patch_the_same_diff_clears_a_lower_floor() {
750 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
751 let floor_70 = Thresholds {
752 fail_under: 70,
753 branch: true,
754 };
755 assert_eq!(
756 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
757 Outcome::Pass
758 );
759 }
760
761 #[test]
762 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
763 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
764 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
765 assert!(
766 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
767 "got: {out:?}"
768 );
769 }
770
771 #[test]
772 fn patch_branches_off_ignores_arcs() {
773 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
774 let no_branch = Thresholds {
775 fail_under: 85,
776 branch: false,
777 };
778 assert_eq!(
779 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
780 Outcome::Pass
781 );
782 }
783
784 #[test]
785 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
786 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
787 assert_eq!(
788 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
789 Outcome::Pass
790 );
791 }
792
793 #[test]
794 fn patch_a_diff_with_no_executable_changed_lines_passes() {
795 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
796 assert_eq!(
797 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
798 Outcome::Pass
799 );
800 }
801
802 use coverage::TsPatchCoverage;
803
804 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
805 entries
806 .iter()
807 .map(|(path, cov)| (path.to_string(), cov.clone()))
808 .collect()
809 }
810
811 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
812 lines: 80,
813 branches: 80,
814 functions: 80,
815 statements: 80,
816 };
817
818 #[test]
819 fn ts_patch_a_fully_covered_diff_passes() {
820 let detail = ts_detail(&[(
821 "w.ts",
822 TsPatchCoverage {
823 statements: vec![(1, 1, true), (2, 2, true)],
824 branch_arms: vec![(2, true)],
825 functions: vec![(1, true)],
826 },
827 )]);
828 assert_eq!(
829 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
830 Outcome::Pass
831 );
832 }
833
834 #[test]
835 fn ts_patch_below_floor_fails_and_names_the_metric() {
836 let detail = ts_detail(&[(
837 "w.ts",
838 TsPatchCoverage {
839 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
840 branch_arms: vec![],
841 functions: vec![],
842 },
843 )]);
844 let out =
845 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
846 assert!(
847 matches!(&out, Outcome::Fail(m)
848 if m.contains("statements 75.00% < 80%")
849 && m.contains("lines 75.00% < 80%")
850 && !m.contains("branches")
851 && !m.contains("functions")),
852 "got: {out:?}"
853 );
854 }
855
856 #[test]
857 fn ts_patch_the_same_diff_clears_a_lower_floor() {
858 let detail = ts_detail(&[(
859 "w.ts",
860 TsPatchCoverage {
861 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
862 branch_arms: vec![],
863 functions: vec![],
864 },
865 )]);
866 let floor_70 = TypeScriptThresholds {
867 lines: 70,
868 branches: 70,
869 functions: 70,
870 statements: 70,
871 };
872 assert_eq!(
873 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
874 Outcome::Pass
875 );
876 }
877
878 #[test]
879 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
880 let detail = ts_detail(&[(
881 "w.ts",
882 TsPatchCoverage {
883 statements: vec![(3, 3, true)],
884 branch_arms: vec![(3, true), (3, false)],
885 functions: vec![],
886 },
887 )]);
888 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
889 assert!(
890 matches!(&out, Outcome::Fail(m)
891 if m.contains("branches 50.00% < 80%")
892 && !m.contains("lines")
893 && !m.contains("statements")),
894 "got: {out:?}"
895 );
896 }
897
898 #[test]
899 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
900 let detail = ts_detail(&[(
901 "w.ts",
902 TsPatchCoverage {
903 statements: vec![],
904 branch_arms: vec![],
905 functions: vec![(9, false)],
906 },
907 )]);
908 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
909 assert!(
910 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
911 "got: {out:?}"
912 );
913 }
914
915 #[test]
916 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
917 let detail = ts_detail(&[(
918 "w.ts",
919 TsPatchCoverage {
920 statements: vec![(1, 1, true)],
921 branch_arms: vec![],
922 functions: vec![],
923 },
924 )]);
925 assert_eq!(
926 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
927 Outcome::Pass
928 );
929 }
930
931 #[test]
932 fn ts_patch_a_comment_only_diff_passes() {
933 let detail = ts_detail(&[(
934 "w.ts",
935 TsPatchCoverage {
936 statements: vec![(1, 1, true), (2, 2, true)],
937 branch_arms: vec![(2, true)],
938 functions: vec![(1, true)],
939 },
940 )]);
941 assert_eq!(
942 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
943 Outcome::Pass
944 );
945 }
946
947 #[test]
948 fn ts_patch_an_empty_diff_passes() {
949 assert_eq!(
950 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
951 Outcome::Pass
952 );
953 }
954
955 #[test]
956 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
957 let detail = ts_detail(&[(
958 "w.ts",
959 TsPatchCoverage {
960 statements: vec![(3, 5, false)],
961 branch_arms: vec![],
962 functions: vec![],
963 },
964 )]);
965 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
966 assert!(
967 matches!(&out, Outcome::Fail(m)
968 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
969 "got: {out:?}"
970 );
971 }
972
973 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
974 entries
975 .iter()
976 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
977 .collect()
978 }
979
980 #[test]
981 fn python_measured_missed_reads_lines_and_branch_sources() {
982 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
983 let (measured, missed) = python_measured_missed(&full, true);
984 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
985 assert_eq!(missed, [2, 3, 4].into_iter().collect());
986 let partial = cov(&[5], &[], &[], &[[5, 6]]);
987 let (_, missed_no_branch) = python_measured_missed(&partial, false);
988 assert!(missed_no_branch.is_empty());
989 let (_, missed_branch) = python_measured_missed(&partial, true);
990 assert_eq!(missed_branch, [5].into_iter().collect());
991 }
992
993 #[test]
994 fn python_measured_missed_skips_an_arc_from_an_unmeasured_line() {
995 let partial = cov(&[1], &[], &[], &[[99, 1]]);
996 let (measured, missed) = python_measured_missed(&partial, true);
997 assert_eq!(measured, [1].into_iter().collect());
998 assert!(missed.is_empty(), "got: {missed:?}");
999 }
1000
1001 #[test]
1002 fn python_measured_missed_skips_an_arc_with_a_negative_source() {
1003 let partial = cov(&[1], &[], &[], &[[-1, 1]]);
1004 let (measured, missed) = python_measured_missed(&partial, true);
1005 assert_eq!(measured, [1].into_iter().collect());
1006 assert!(missed.is_empty(), "got: {missed:?}");
1007 }
1008
1009 #[test]
1010 fn ts_measured_missed_anchors_units_on_their_lines() {
1011 let cov = coverage::TsPatchCoverage {
1012 statements: vec![(1, 1, true), (3, 4, false)],
1013 branch_arms: vec![(1, false)],
1014 functions: vec![(6, false)],
1015 };
1016 let (measured, missed) = ts_measured_missed(&cov);
1017 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1018 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1019 }
1020
1021 #[test]
1022 fn rust_measured_missed_honors_the_enforced_metrics() {
1023 let cov = coverage::RustPatchCoverage {
1024 regions: vec![(1, 1, true), (5, 6, false)],
1025 };
1026 let with_regions = RustThresholds {
1027 regions: Some(100),
1028 lines: 100,
1029 functions: None,
1030 branch: None,
1031 };
1032 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1033 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1034 assert_eq!(missed, [5, 6].into_iter().collect());
1035 let lines_only = RustThresholds {
1036 regions: None,
1037 lines: 100,
1038 functions: None,
1039 branch: None,
1040 };
1041 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1042 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1043 }
1044
1045 #[test]
1046 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1047 let detail = BTreeMap::from([(
1048 "shim.py".to_string(),
1049 (
1050 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1051 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1052 ),
1053 )]);
1054 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1055 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1056 }
1057
1058 #[test]
1059 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1060 let detail = BTreeMap::from([(
1061 "shim.py".to_string(),
1062 (
1063 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1064 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1065 ),
1066 )]);
1067 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1068 assert!(
1069 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1070 "got: {err}"
1071 );
1072 }
1073
1074 #[test]
1075 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1076 let detail = BTreeMap::from([(
1077 "w.py".to_string(),
1078 (
1079 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1080 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1081 ),
1082 )]);
1083 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1084 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1085 }
1086
1087 #[test]
1088 fn floor_outcome_matches_the_whole_tree_message() {
1089 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1090 let out = floor_outcome(7, 8, 100);
1091 assert!(
1092 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1093 "got: {out:?}"
1094 );
1095 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1096 }
1097
1098 #[test]
1099 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1100 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1101 lift_exempt_lines(
1102 &mut changed,
1103 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1104 );
1105 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1106 assert_eq!(changed["core.py"], [5].into_iter().collect());
1107 }
1108
1109 #[test]
1110 fn kept_lines_drops_only_the_exempt_lines() {
1111 let measured = BTreeSet::from([3u64, 4, 5]);
1112 assert_eq!(
1113 kept_lines(&measured, Some(&BTreeSet::from([4u32]))),
1114 BTreeSet::from([3, 5])
1115 );
1116 assert_eq!(kept_lines(&measured, None), measured);
1117 }
1118
1119 #[test]
1120 fn a_measured_line_too_large_for_a_u32_can_carry_no_exemption() {
1121 let measured = BTreeSet::from([u64::from(u32::MAX) + 1]);
1124 assert_eq!(
1125 kept_lines(&measured, Some(&BTreeSet::from([u32::MAX]))),
1126 measured
1127 );
1128 }
1129}