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_patch_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(evaluate_patch_rust(&changed, &detail, thresholds))
266}
267
268fn evaluate_patch_rust(
272 changed: &BTreeMap<String, BTreeSet<u64>>,
273 detail: &BTreeMap<String, coverage::RustPatchCoverage>,
274 thresholds: RustThresholds,
275) -> Outcome {
276 let (mut r_cov, mut r_tot) = (0u64, 0u64);
277 let (mut l_cov, mut l_tot) = (0u64, 0u64);
278
279 for (file, lines) in changed {
280 let Some(cov) = detail.get(file) else {
281 continue;
282 };
283
284 for &(start, end, covered) in &cov.regions {
285 if (start..=end).any(|line| lines.contains(&line)) {
286 r_tot += 1;
287 if covered {
288 r_cov += 1;
289 }
290 }
291 }
292
293 for &line in lines {
294 let mut measured = false;
295 let mut covered_here = false;
296 for &(start, end, covered) in &cov.regions {
297 if start <= line && line <= end {
298 measured = true;
299 covered_here |= covered;
300 }
301 }
302 if measured {
303 l_tot += 1;
304 if covered_here {
305 l_cov += 1;
306 }
307 }
308 }
309 }
310
311 let pct = |covered: u64, total: u64| {
312 if total == 0 {
313 100.0
314 } else {
315 100.0 * covered as f64 / total as f64
316 }
317 };
318 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
321 if let Some(regions) = thresholds.regions {
322 checks.push(("regions", pct(r_cov, r_tot), regions));
323 }
324 checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
325 let mut shortfalls = Vec::new();
326 for (name, actual, required) in checks {
327 if actual + 1e-9 < f64::from(required) {
330 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
331 }
332 }
333 if shortfalls.is_empty() {
334 Outcome::Pass
335 } else {
336 Outcome::Fail(format!(
337 "coverage below thresholds: {}",
338 shortfalls.join(", ")
339 ))
340 }
341}
342
343pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
347 let range = format!("{base}...HEAD");
348 let output = Command::new("git")
349 .current_dir(repo)
350 .args([
351 "-c",
352 "core.quotepath=off",
353 "diff",
354 "--no-color",
355 "--no-ext-diff",
356 "--no-renames",
357 "--unified=0",
358 "--relative",
359 "--src-prefix=a/",
360 "--dst-prefix=b/",
361 &range,
362 ])
363 .output()
364 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
365 if !output.status.success() {
366 bail!(
367 "`git diff {range}` failed in `{}`: {}",
368 repo.display(),
369 String::from_utf8_lossy(&output.stderr).trim()
370 );
371 }
372 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
373}
374
375fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
379 let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
380 let mut current: Option<String> = None;
381 let mut next_line: u64 = 0;
382 let mut in_hunk = false;
383 for line in diff.lines() {
384 if line.starts_with("diff --git ") {
385 in_hunk = false;
386 current = None;
387 } else if line.starts_with("@@") {
388 in_hunk = true;
389 if let Some(start) = hunk_new_start(line) {
390 next_line = start;
391 }
392 } else if !in_hunk {
393 if let Some(header) = line.strip_prefix("+++ ") {
394 current = new_side_path(header);
395 }
396 } else if line.starts_with('+') {
397 if let Some(file) = ¤t {
398 changed.entry(file.clone()).or_default().insert(next_line);
399 }
400 next_line += 1;
401 }
402 }
403 changed
404}
405
406fn new_side_path(header: &str) -> Option<String> {
409 let raw = header
410 .split('\t')
411 .next()
412 .unwrap_or(header)
413 .trim_end_matches('\r');
414 if raw == "/dev/null" {
415 return None;
416 }
417 let unquoted = unquote_c_path(raw);
419 let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
420 Some(path.replace('\\', "/"))
421}
422
423pub(crate) fn unquote_c_path(path: &str) -> String {
427 let bytes = path.as_bytes();
428 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
429 return path.to_string();
430 }
431 let inner = &bytes[1..bytes.len() - 1];
432 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
433 let mut i = 0;
434 while i < inner.len() {
435 if inner[i] != b'\\' || i + 1 >= inner.len() {
436 out.push(inner[i]);
437 i += 1;
438 continue;
439 }
440 let next = inner[i + 1];
441 if (b'0'..=b'7').contains(&next) {
442 let mut value: u32 = 0;
443 let mut k = i + 1;
444 while k < inner.len() && k < i + 4 && (b'0'..=b'7').contains(&inner[k]) {
445 value = value * 8 + u32::from(inner[k] - b'0');
446 k += 1;
447 }
448 out.push(value as u8);
449 i = k;
450 } else {
451 let decoded = match next {
452 b'a' => 0x07,
453 b'b' => 0x08,
454 b't' => b'\t',
455 b'n' => b'\n',
456 b'v' => 0x0b,
457 b'f' => 0x0c,
458 b'r' => b'\r',
459 other => other, };
461 out.push(decoded);
462 i += 2;
463 }
464 }
465 String::from_utf8_lossy(&out).into_owned()
466}
467
468fn hunk_new_start(header: &str) -> Option<u64> {
471 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
472 let digits = plus.trim_start_matches('+');
473 digits.split(',').next().unwrap_or(digits).parse().ok()
474}
475
476fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
480 files
481 .into_iter()
482 .map(|(key, value)| {
483 let path = Path::new(&key);
484 let rel = path
485 .strip_prefix(root)
486 .unwrap_or(path)
487 .to_string_lossy()
488 .replace('\\', "/");
489 (rel, value)
490 })
491 .collect()
492}
493
494pub fn measure_line_exempt(
498 root: &Path,
499 thresholds: Thresholds,
500 omit: &[String],
501 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
502) -> Result<Outcome> {
503 let report = coverage::measure_report(root, omit)?;
504 let files = relative_keys(report.files, root);
505 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
506 .iter()
507 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
508 .collect();
509 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
510 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
511 Ok(floor_outcome(covered, total, thresholds.fail_under))
512}
513
514pub fn measure_line_exempt_typescript(
517 root: &Path,
518 thresholds: TypeScriptThresholds,
519 exclude: &[String],
520 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
521) -> Result<Outcome> {
522 let detail = relative_keys(
523 coverage::measure_patch_typescript_detail(root, exclude)?,
524 root,
525 );
526 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
527 .iter()
528 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
529 .collect();
530 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
531 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
532}
533
534pub fn measure_line_exempt_rust(
537 root: &Path,
538 thresholds: RustThresholds,
539 ignore: &[String],
540 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
541 features: &[String],
542) -> Result<Outcome> {
543 let detail = relative_keys(
544 coverage::measure_patch_rust_detail(root, ignore, features)?,
545 root,
546 );
547 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
548 .iter()
549 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
550 .collect();
551 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
552 Ok(evaluate_patch_rust(&line_set, &detail, thresholds))
553}
554
555fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
558 if total == 0 {
559 return Outcome::Pass;
560 }
561 let actual = 100.0 * covered as f64 / total as f64;
562 if actual + 1e-9 >= f64::from(fail_under) {
563 Outcome::Pass
564 } else {
565 Outcome::Fail(format!(
566 "coverage {actual:.2}% is below the required {fail_under}%"
567 ))
568 }
569}
570
571fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
574 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
575 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
576 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
577 let mut missed = missing;
578 if branch {
579 for arc in &cov.missing_branches {
580 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
581 if measured.contains(&src) {
582 missed.insert(src);
583 }
584 }
585 }
586 }
587 (measured, missed)
588}
589
590fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
593 let mut measured = BTreeSet::new();
594 let mut missed = BTreeSet::new();
595 let units = cov
596 .statements
597 .iter()
598 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
599 .chain(cov.branch_arms.iter().copied())
600 .chain(cov.functions.iter().copied());
601 for (line, covered) in units {
602 measured.insert(line);
603 if !covered {
604 missed.insert(line);
605 }
606 }
607 (measured, missed)
608}
609
610fn rust_measured_missed(
614 cov: &coverage::RustPatchCoverage,
615 thresholds: RustThresholds,
616) -> (BTreeSet<u64>, BTreeSet<u64>) {
617 let mut measured = BTreeSet::new();
618 for &(start, end, _covered) in &cov.regions {
619 for line in start..=end {
620 measured.insert(line);
621 }
622 }
623 let mut missed = BTreeSet::new();
624 for &line in &measured {
625 let mut covered_here = false;
626 let mut uncovered_region = false;
627 for &(start, end, covered) in &cov.regions {
628 if start <= line && line <= end {
629 if covered {
630 covered_here = true;
631 } else {
632 uncovered_region = true;
633 }
634 }
635 }
636 let is_missed = if thresholds.regions.is_some() {
637 uncovered_region
638 } else {
639 !covered_here
640 };
641 if is_missed {
642 missed.insert(line);
643 }
644 }
645 (measured, missed)
646}
647
648fn apply_line_exemptions(
652 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
653 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
654) -> Result<BTreeMap<String, BTreeSet<u64>>> {
655 let mut over: Vec<String> = Vec::new();
656 for (file, lines) in exempt_lines {
657 let missed = detail.get(file).map(|(_, missed)| missed);
658 for &line in lines {
659 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
660 if !failing {
661 over.push(format!("\n {file}:{line}"));
662 }
663 }
664 }
665 if !over.is_empty() {
666 bail!(
667 "a line-scoped coverage exemption may only list uncovered lines, but these are \
668 covered or carry no measured code:{}",
669 over.concat()
670 );
671 }
672 let mut line_set = BTreeMap::new();
673 for (file, (measured, _)) in detail {
674 let exempt = exempt_lines.get(file);
675 let kept: BTreeSet<u64> = measured
676 .iter()
677 .copied()
678 .filter(|&line| {
679 !exempt.is_some_and(|exempt| {
680 u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
681 })
682 })
683 .collect();
684 line_set.insert(file.clone(), kept);
685 }
686 Ok(line_set)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
694 entries
695 .iter()
696 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
697 .collect()
698 }
699
700 #[test]
701 fn changed_lines_reports_a_spawn_failure() {
702 let err = changed_lines(Path::new("/nonexistent-tc-patch-coverage"), "main").unwrap_err();
703 assert!(err.to_string().contains("running `git diff`"), "got: {err}");
704 }
705
706 #[test]
707 fn parses_added_lines_from_a_hunk() {
708 let diff = "diff --git a/widget.py b/widget.py\n\
709 index abc..def 100644\n\
710 --- a/widget.py\n\
711 +++ b/widget.py\n\
712 @@ -3,0 +4,2 @@ def f(x):\n\
713 + if x == 99:\n\
714 + return 7\n";
715 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
716 }
717
718 #[test]
719 fn parses_a_new_file_as_added_from_line_one() {
720 let diff = "diff --git a/lonely.py b/lonely.py\n\
721 new file mode 100644\n\
722 index 0000000..bbb\n\
723 --- /dev/null\n\
724 +++ b/lonely.py\n\
725 @@ -0,0 +1,2 @@\n\
726 +def lonely():\n\
727 + return 41\n";
728 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
729 }
730
731 #[test]
732 fn a_deletion_only_hunk_records_no_added_lines() {
733 let diff = "diff --git a/widget.py b/widget.py\n\
734 index abc..def 100644\n\
735 --- a/widget.py\n\
736 +++ b/widget.py\n\
737 @@ -4,2 +3,0 @@ def f(x):\n\
738 - dead = 1\n\
739 - return dead\n";
740 assert!(parse_unified_diff(diff).is_empty());
741 }
742
743 #[test]
744 fn a_deleted_file_yields_no_entry() {
745 let diff = "diff --git a/gone.py b/gone.py\n\
746 deleted file mode 100644\n\
747 index abc..0000000\n\
748 --- a/gone.py\n\
749 +++ /dev/null\n\
750 @@ -1,2 +0,0 @@\n\
751 -def gone():\n\
752 - return 0\n";
753 assert!(parse_unified_diff(diff).is_empty());
754 }
755
756 #[test]
757 fn parses_multiple_files_and_a_single_line_hunk() {
758 let diff = "diff --git a/a.py b/a.py\n\
759 --- a/a.py\n\
760 +++ b/a.py\n\
761 @@ -1,0 +2 @@ def a():\n\
762 + x = 1\n\
763 diff --git a/pkg/b.py b/pkg/b.py\n\
764 --- a/pkg/b.py\n\
765 +++ b/pkg/b.py\n\
766 @@ -10,0 +11,1 @@\n\
767 + y = 2\n";
768 assert_eq!(
769 parse_unified_diff(diff),
770 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
771 );
772 }
773
774 #[test]
775 fn a_plus_plus_body_line_is_not_a_file_header() {
776 let diff = "diff --git a/w.py b/w.py\n\
780 index abc..def 100644\n\
781 --- a/w.py\n\
782 +++ b/w.py\n\
783 @@ -1,0 +1,3 @@\n\
784 +++ 1\n\
785 +y = 1\n\
786 +z = 2\n";
787 assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
788 }
789
790 #[test]
791 fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
792 assert_eq!(
796 new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
797 Some("src/föö.py")
798 );
799 assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
800 }
801
802 #[test]
803 fn unquote_c_path_decodes_octal_and_named_escapes() {
804 assert_eq!(
805 unquote_c_path("\"src/f\\303\\266\\303\\266.py\""),
806 "src/föö.py"
807 );
808 assert_eq!(unquote_c_path("\"a\\tb\\\"c\\\\d\""), "a\tb\"c\\d");
809 assert_eq!(
810 unquote_c_path("\"\\a\\b\\n\\v\\f\\r\""),
811 "\u{7}\u{8}\n\u{b}\u{c}\r"
812 );
813 assert_eq!(unquote_c_path("\"\\1015\""), "A5");
814 }
815
816 #[test]
817 fn unquote_c_path_leaves_an_unquoted_path_unchanged() {
818 assert_eq!(unquote_c_path("src/föö.py"), "src/föö.py");
819 assert_eq!(unquote_c_path("\""), "\"");
820 assert_eq!(unquote_c_path(""), "");
821 assert_eq!(unquote_c_path("\"a\\\""), "a\\");
822 }
823
824 fn cov(
825 executed: &[u64],
826 missing: &[u64],
827 executed_branches: &[[i64; 2]],
828 missing_branches: &[[i64; 2]],
829 ) -> FileCoverage {
830 FileCoverage {
831 executed_lines: executed.to_vec(),
832 missing_lines: missing.to_vec(),
833 excluded_lines: Vec::new(),
834 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
835 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
836 }
837 }
838
839 const FLOOR_85: Thresholds = Thresholds {
840 fail_under: 85,
841 branch: true,
842 };
843
844 #[test]
845 fn patch_a_fully_covered_diff_passes() {
846 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
847 assert_eq!(
848 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
849 Outcome::Pass
850 );
851 }
852
853 #[test]
854 fn patch_below_floor_fails_and_names_the_percent() {
855 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
856 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
857 assert!(
858 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
859 "got: {out:?}"
860 );
861 }
862
863 #[test]
864 fn patch_the_same_diff_clears_a_lower_floor() {
865 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
866 let floor_70 = Thresholds {
867 fail_under: 70,
868 branch: true,
869 };
870 assert_eq!(
871 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
872 Outcome::Pass
873 );
874 }
875
876 #[test]
877 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
878 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
879 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
880 assert!(
881 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
882 "got: {out:?}"
883 );
884 }
885
886 #[test]
887 fn patch_branches_off_ignores_arcs() {
888 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
889 let no_branch = Thresholds {
890 fail_under: 85,
891 branch: false,
892 };
893 assert_eq!(
894 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
895 Outcome::Pass
896 );
897 }
898
899 #[test]
900 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
901 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
902 assert_eq!(
903 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
904 Outcome::Pass
905 );
906 }
907
908 #[test]
909 fn patch_a_diff_with_no_executable_changed_lines_passes() {
910 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
911 assert_eq!(
912 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
913 Outcome::Pass
914 );
915 }
916
917 use coverage::TsPatchCoverage;
918
919 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
920 entries
921 .iter()
922 .map(|(path, cov)| (path.to_string(), cov.clone()))
923 .collect()
924 }
925
926 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
927 lines: 80,
928 branches: 80,
929 functions: 80,
930 statements: 80,
931 };
932
933 #[test]
934 fn ts_patch_a_fully_covered_diff_passes() {
935 let detail = ts_detail(&[(
936 "w.ts",
937 TsPatchCoverage {
938 statements: vec![(1, 1, true), (2, 2, true)],
939 branch_arms: vec![(2, true)],
940 functions: vec![(1, true)],
941 },
942 )]);
943 assert_eq!(
944 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
945 Outcome::Pass
946 );
947 }
948
949 #[test]
950 fn ts_patch_below_floor_fails_and_names_the_metric() {
951 let detail = ts_detail(&[(
952 "w.ts",
953 TsPatchCoverage {
954 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
955 branch_arms: vec![],
956 functions: vec![],
957 },
958 )]);
959 let out =
960 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
961 assert!(
962 matches!(&out, Outcome::Fail(m)
963 if m.contains("statements 75.00% < 80%")
964 && m.contains("lines 75.00% < 80%")
965 && !m.contains("branches")
966 && !m.contains("functions")),
967 "got: {out:?}"
968 );
969 }
970
971 #[test]
972 fn ts_patch_the_same_diff_clears_a_lower_floor() {
973 let detail = ts_detail(&[(
974 "w.ts",
975 TsPatchCoverage {
976 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
977 branch_arms: vec![],
978 functions: vec![],
979 },
980 )]);
981 let floor_70 = TypeScriptThresholds {
982 lines: 70,
983 branches: 70,
984 functions: 70,
985 statements: 70,
986 };
987 assert_eq!(
988 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
989 Outcome::Pass
990 );
991 }
992
993 #[test]
994 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
995 let detail = ts_detail(&[(
996 "w.ts",
997 TsPatchCoverage {
998 statements: vec![(3, 3, true)],
999 branch_arms: vec![(3, true), (3, false)],
1000 functions: vec![],
1001 },
1002 )]);
1003 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1004 assert!(
1005 matches!(&out, Outcome::Fail(m)
1006 if m.contains("branches 50.00% < 80%")
1007 && !m.contains("lines")
1008 && !m.contains("statements")),
1009 "got: {out:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1015 let detail = ts_detail(&[(
1016 "w.ts",
1017 TsPatchCoverage {
1018 statements: vec![],
1019 branch_arms: vec![],
1020 functions: vec![(9, false)],
1021 },
1022 )]);
1023 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1024 assert!(
1025 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1026 "got: {out:?}"
1027 );
1028 }
1029
1030 #[test]
1031 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1032 let detail = ts_detail(&[(
1033 "w.ts",
1034 TsPatchCoverage {
1035 statements: vec![(1, 1, true)],
1036 branch_arms: vec![],
1037 functions: vec![],
1038 },
1039 )]);
1040 assert_eq!(
1041 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1042 Outcome::Pass
1043 );
1044 }
1045
1046 #[test]
1047 fn ts_patch_a_comment_only_diff_passes() {
1048 let detail = ts_detail(&[(
1049 "w.ts",
1050 TsPatchCoverage {
1051 statements: vec![(1, 1, true), (2, 2, true)],
1052 branch_arms: vec![(2, true)],
1053 functions: vec![(1, true)],
1054 },
1055 )]);
1056 assert_eq!(
1057 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
1058 Outcome::Pass
1059 );
1060 }
1061
1062 #[test]
1063 fn ts_patch_an_empty_diff_passes() {
1064 assert_eq!(
1065 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1066 Outcome::Pass
1067 );
1068 }
1069
1070 #[test]
1071 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1072 let detail = ts_detail(&[(
1073 "w.ts",
1074 TsPatchCoverage {
1075 statements: vec![(3, 5, false)],
1076 branch_arms: vec![],
1077 functions: vec![],
1078 },
1079 )]);
1080 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1081 assert!(
1082 matches!(&out, Outcome::Fail(m)
1083 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1084 "got: {out:?}"
1085 );
1086 }
1087
1088 use coverage::RustPatchCoverage;
1089
1090 fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
1091 entries
1092 .iter()
1093 .map(|(path, cov)| (path.to_string(), cov.clone()))
1094 .collect()
1095 }
1096
1097 const RUST_FLOOR_80: RustThresholds = RustThresholds {
1098 regions: Some(80),
1099 lines: 80,
1100 functions: None,
1101 branch: None,
1102 };
1103
1104 #[test]
1105 fn rust_patch_a_fully_covered_diff_passes() {
1106 let detail = rust_detail(&[(
1107 "w.rs",
1108 RustPatchCoverage {
1109 regions: vec![(1, 1, true), (2, 2, true)],
1110 },
1111 )]);
1112 assert_eq!(
1113 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1114 Outcome::Pass
1115 );
1116 }
1117
1118 #[test]
1119 fn rust_patch_below_floor_fails_and_names_the_metrics() {
1120 let detail = rust_detail(&[(
1121 "w.rs",
1122 RustPatchCoverage {
1123 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1124 },
1125 )]);
1126 let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
1127 assert!(
1128 matches!(&out, Outcome::Fail(m)
1129 if m.contains("regions 75.00% < 80%")
1130 && m.contains("lines 75.00% < 80%")),
1131 "got: {out:?}"
1132 );
1133 }
1134
1135 #[test]
1136 fn rust_patch_the_same_diff_clears_a_lower_floor() {
1137 let detail = rust_detail(&[(
1138 "w.rs",
1139 RustPatchCoverage {
1140 regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1141 },
1142 )]);
1143 let floor_70 = RustThresholds {
1144 regions: Some(70),
1145 lines: 70,
1146 functions: None,
1147 branch: None,
1148 };
1149 assert_eq!(
1150 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
1151 Outcome::Pass
1152 );
1153 }
1154
1155 #[test]
1156 fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
1157 let detail = rust_detail(&[(
1158 "w.rs",
1159 RustPatchCoverage {
1160 regions: vec![(1, 4, true), (4, 4, false)],
1161 },
1162 )]);
1163 let lines_only = RustThresholds {
1164 regions: None,
1165 lines: 100,
1166 functions: None,
1167 branch: None,
1168 };
1169 assert_eq!(
1170 evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
1171 Outcome::Pass
1172 );
1173 }
1174
1175 #[test]
1176 fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
1177 let detail = rust_detail(&[(
1178 "w.rs",
1179 RustPatchCoverage {
1180 regions: vec![(5, 5, false)],
1181 },
1182 )]);
1183 let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
1184 assert!(
1185 matches!(&out, Outcome::Fail(m)
1186 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1187 "got: {out:?}"
1188 );
1189 }
1190
1191 #[test]
1192 fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
1193 let detail = rust_detail(&[(
1194 "w.rs",
1195 RustPatchCoverage {
1196 regions: vec![(1, 1, true)],
1197 },
1198 )]);
1199 assert_eq!(
1200 evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
1201 Outcome::Pass
1202 );
1203 }
1204
1205 #[test]
1206 fn rust_patch_a_comment_only_diff_passes() {
1207 let detail = rust_detail(&[(
1208 "w.rs",
1209 RustPatchCoverage {
1210 regions: vec![(1, 1, true), (2, 2, true)],
1211 },
1212 )]);
1213 assert_eq!(
1214 evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
1215 Outcome::Pass
1216 );
1217 }
1218
1219 #[test]
1220 fn rust_patch_an_empty_diff_passes() {
1221 assert_eq!(
1222 evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
1223 Outcome::Pass
1224 );
1225 }
1226
1227 #[test]
1228 fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
1229 let detail = rust_detail(&[(
1230 "w.rs",
1231 RustPatchCoverage {
1232 regions: vec![(3, 5, false)],
1233 },
1234 )]);
1235 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1236 assert!(
1237 matches!(&out, Outcome::Fail(m)
1238 if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
1239 "got: {out:?}"
1240 );
1241 }
1242
1243 #[test]
1244 fn rust_patch_a_line_covered_by_any_region_is_covered() {
1245 let detail = rust_detail(&[(
1246 "w.rs",
1247 RustPatchCoverage {
1248 regions: vec![(4, 4, false), (4, 6, true)],
1249 },
1250 )]);
1251 let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
1252 assert!(
1253 matches!(&out, Outcome::Fail(m)
1254 if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
1255 "got: {out:?}"
1256 );
1257 }
1258
1259 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1260 entries
1261 .iter()
1262 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1263 .collect()
1264 }
1265
1266 #[test]
1267 fn python_measured_missed_reads_lines_and_branch_sources() {
1268 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1269 let (measured, missed) = python_measured_missed(&full, true);
1270 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1271 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1272 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1273 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1274 assert!(missed_no_branch.is_empty());
1275 let (_, missed_branch) = python_measured_missed(&partial, true);
1276 assert_eq!(missed_branch, [5].into_iter().collect());
1277 }
1278
1279 #[test]
1280 fn python_measured_missed_skips_an_arc_from_an_unmeasured_line() {
1281 let partial = cov(&[1], &[], &[], &[[99, 1]]);
1282 let (measured, missed) = python_measured_missed(&partial, true);
1283 assert_eq!(measured, [1].into_iter().collect());
1284 assert!(missed.is_empty(), "got: {missed:?}");
1285 }
1286
1287 #[test]
1288 fn python_measured_missed_skips_an_arc_with_a_negative_source() {
1289 let partial = cov(&[1], &[], &[], &[[-1, 1]]);
1290 let (measured, missed) = python_measured_missed(&partial, true);
1291 assert_eq!(measured, [1].into_iter().collect());
1292 assert!(missed.is_empty(), "got: {missed:?}");
1293 }
1294
1295 #[test]
1296 fn ts_measured_missed_anchors_units_on_their_lines() {
1297 let cov = coverage::TsPatchCoverage {
1298 statements: vec![(1, 1, true), (3, 4, false)],
1299 branch_arms: vec![(1, false)],
1300 functions: vec![(6, false)],
1301 };
1302 let (measured, missed) = ts_measured_missed(&cov);
1303 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1304 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1305 }
1306
1307 #[test]
1308 fn rust_measured_missed_honors_the_enforced_metrics() {
1309 let cov = coverage::RustPatchCoverage {
1310 regions: vec![(1, 1, true), (5, 6, false)],
1311 };
1312 let with_regions = RustThresholds {
1313 regions: Some(100),
1314 lines: 100,
1315 functions: None,
1316 branch: None,
1317 };
1318 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1319 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1320 assert_eq!(missed, [5, 6].into_iter().collect());
1321 let lines_only = RustThresholds {
1322 regions: None,
1323 lines: 100,
1324 functions: None,
1325 branch: None,
1326 };
1327 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1328 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1329 }
1330
1331 #[test]
1332 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1333 let detail = BTreeMap::from([(
1334 "shim.py".to_string(),
1335 (
1336 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1337 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1338 ),
1339 )]);
1340 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1341 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1342 }
1343
1344 #[test]
1345 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1346 let detail = BTreeMap::from([(
1347 "shim.py".to_string(),
1348 (
1349 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1350 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1351 ),
1352 )]);
1353 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1354 assert!(
1355 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1356 "got: {err}"
1357 );
1358 }
1359
1360 #[test]
1361 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1362 let detail = BTreeMap::from([(
1363 "w.py".to_string(),
1364 (
1365 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1366 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1367 ),
1368 )]);
1369 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1370 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1371 }
1372
1373 #[test]
1374 fn floor_outcome_matches_the_whole_tree_message() {
1375 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1376 let out = floor_outcome(7, 8, 100);
1377 assert!(
1378 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1379 "got: {out:?}"
1380 );
1381 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1382 }
1383
1384 #[test]
1385 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1386 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1387 lift_exempt_lines(
1388 &mut changed,
1389 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1390 );
1391 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1392 assert_eq!(changed["core.py"], [5].into_iter().collect());
1393 }
1394}