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 tallies = typescript_tallies(changed, detail);
186 let checks = [
187 ("lines", tallies.lines.percent(), thresholds.lines),
188 ("branches", tallies.branches.percent(), thresholds.branches),
189 (
190 "functions",
191 tallies.functions.percent(),
192 thresholds.functions,
193 ),
194 (
195 "statements",
196 tallies.statements.percent(),
197 thresholds.statements,
198 ),
199 ];
200 coverage::verdict(
201 checks
202 .into_iter()
203 .filter_map(|(name, actual, required)| coverage::shortfall(name, actual, required))
204 .collect(),
205 )
206}
207
208#[derive(Default)]
210struct TsTallies {
211 statements: Tally,
212 lines: Tally,
213 branches: Tally,
214 functions: Tally,
215}
216
217fn typescript_tallies(
221 changed: &BTreeMap<String, BTreeSet<u64>>,
222 detail: &BTreeMap<String, coverage::TsPatchCoverage>,
223) -> TsTallies {
224 let mut tallies = TsTallies::default();
225 for (file, touched) in changed {
226 let Some(cov) = detail.get(file) else {
227 continue;
228 };
229 tallies
230 .statements
231 .add(statements_tally(&cov.statements, touched));
232 tallies.lines.add(lines_tally(&cov.statements, touched));
233 tallies
234 .branches
235 .add(on_line_tally(&cov.branch_arms, touched));
236 tallies
237 .functions
238 .add(on_line_tally(&cov.functions, touched));
239 }
240 tallies
241}
242
243fn statements_tally(statements: &[(u64, u64, bool)], touched: &BTreeSet<u64>) -> Tally {
245 let mut tally = Tally::default();
246 for &(start, end, covered) in statements {
247 if (start..=end).any(|line| touched.contains(&line)) {
248 tally.count(covered);
249 }
250 }
251 tally
252}
253
254fn lines_tally(statements: &[(u64, u64, bool)], touched: &BTreeSet<u64>) -> Tally {
257 let mut tally = Tally::default();
258 for &line in touched {
259 let mut starts_here = false;
260 let mut covered_here = false;
261 for &(start, _end, covered) in statements {
262 if start == line {
263 starts_here = true;
264 covered_here |= covered;
265 }
266 }
267 if starts_here {
268 tally.count(covered_here);
269 }
270 }
271 tally
272}
273
274fn on_line_tally(sites: &[(u64, bool)], touched: &BTreeSet<u64>) -> Tally {
276 let mut tally = Tally::default();
277 for &(line, covered) in sites {
278 if touched.contains(&line) {
279 tally.count(covered);
280 }
281 }
282 tally
283}
284
285pub fn measure_rust(
289 root: &Path,
290 base: &str,
291 thresholds: RustThresholds,
292 ignore: &[String],
293 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
294 features: &[String],
295) -> Result<Outcome> {
296 let mut changed = changed_lines(root, base)?;
297 changed.retain(|path, _| path.ends_with(".rs"));
298 lift_exempt_lines(&mut changed, exempt_lines);
299 if changed.is_empty() {
300 return Ok(Outcome::Pass);
301 }
302 let detail = relative_keys(
303 coverage::measure_patch_rust_detail(root, ignore, features)?,
304 root,
305 );
306 Ok(crate::rust_patch_coverage::evaluate_patch_rust(
307 &changed, &detail, thresholds,
308 ))
309}
310
311pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
315 let range = format!("{base}...HEAD");
316 let output = Command::new("git")
317 .current_dir(repo)
318 .args(unified_diff_argv(&range))
319 .output()
320 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
321 diff_exit(&output, repo, &range)?;
322 Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
323}
324
325fn unified_diff_argv(range: &str) -> [&str; 11] {
330 [
331 "-c",
332 "core.quotepath=off",
333 "diff",
334 "--no-color",
335 "--no-ext-diff",
336 "--no-renames",
337 "--unified=0",
338 "--relative",
339 "--src-prefix=a/",
340 "--dst-prefix=b/",
341 range,
342 ]
343}
344
345fn diff_exit(output: &std::process::Output, repo: &Path, range: &str) -> Result<()> {
348 if !output.status.success() {
349 bail!(
350 "`git diff {range}` failed in `{}`: {}",
351 repo.display(),
352 String::from_utf8_lossy(&output.stderr).trim()
353 );
354 }
355 Ok(())
356}
357
358fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
362 let mut scan = DiffScan::default();
363 for line in diff.lines() {
364 scan.feed(line);
365 }
366 scan.changed
367}
368
369#[derive(Default)]
373struct DiffScan {
374 changed: BTreeMap<String, BTreeSet<u64>>,
375 current: Option<String>,
376 next_line: u64,
377 in_hunk: bool,
378}
379
380impl DiffScan {
381 fn feed(&mut self, line: &str) {
383 if line.starts_with("diff --git ") {
384 self.in_hunk = false;
385 self.current = None;
386 } else if line.starts_with("@@") {
387 self.in_hunk = true;
388 if let Some(start) = hunk_new_start(line) {
389 self.next_line = start;
390 }
391 } else if !self.in_hunk {
392 if let Some(header) = line.strip_prefix("+++ ") {
393 self.current = new_side_path(header);
394 }
395 } else if line.starts_with('+') {
396 if let Some(file) = &self.current {
397 self.changed
398 .entry(file.clone())
399 .or_default()
400 .insert(self.next_line);
401 }
402 self.next_line += 1;
403 }
404 }
405}
406
407fn new_side_path(header: &str) -> Option<String> {
410 let raw = header
411 .split('\t')
412 .next()
413 .unwrap_or(header)
414 .trim_end_matches('\r');
415 if raw == "/dev/null" {
416 return None;
417 }
418 let unquoted = crate::git_path::unquote_c_path(raw);
420 let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
421 Some(path.replace('\\', "/"))
422}
423
424fn hunk_new_start(header: &str) -> Option<u64> {
427 let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
428 let digits = plus.trim_start_matches('+');
429 digits.split(',').next().unwrap_or(digits).parse().ok()
430}
431
432fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
436 files
437 .into_iter()
438 .map(|(key, value)| {
439 let path = Path::new(&key);
440 let rel = path
441 .strip_prefix(root)
442 .unwrap_or(path)
443 .to_string_lossy()
444 .replace('\\', "/");
445 (rel, value)
446 })
447 .collect()
448}
449
450pub fn measure_line_exempt(
454 root: &Path,
455 thresholds: Thresholds,
456 omit: &[String],
457 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
458) -> Result<Outcome> {
459 let report = coverage::measure_report(root, omit)?;
460 let files = relative_keys(report.files, root);
461 let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
462 .iter()
463 .map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
464 .collect();
465 let line_set = apply_line_exemptions(&detail, exempt_lines)?;
466 let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
467 Ok(floor_outcome(covered, total, thresholds.fail_under))
468}
469
470pub fn measure_line_exempt_typescript(
473 root: &Path,
474 thresholds: TypeScriptThresholds,
475 exclude: &[String],
476 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
477) -> Result<Outcome> {
478 let detail = relative_keys(
479 coverage::measure_patch_typescript_detail(root, exclude)?,
480 root,
481 );
482 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
483 .iter()
484 .map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
485 .collect();
486 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
487 Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
488}
489
490pub fn measure_line_exempt_rust(
493 root: &Path,
494 thresholds: RustThresholds,
495 ignore: &[String],
496 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
497 features: &[String],
498) -> Result<Outcome> {
499 let detail = relative_keys(
500 coverage::measure_patch_rust_detail(root, ignore, features)?,
501 root,
502 );
503 let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
504 .iter()
505 .map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
506 .collect();
507 let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
508 Ok(crate::rust_patch_coverage::evaluate_patch_rust(
509 &line_set, &detail, thresholds,
510 ))
511}
512
513fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
516 if total == 0 {
517 return Outcome::Pass;
518 }
519 let actual = 100.0 * covered as f64 / total as f64;
520 if actual + 1e-9 >= f64::from(fail_under) {
521 Outcome::Pass
522 } else {
523 Outcome::Fail(format!(
524 "coverage {actual:.2}% is below the required {fail_under}%"
525 ))
526 }
527}
528
529fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
532 let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
533 let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
534 let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
535 let mut missed = missing;
536 if branch {
537 for arc in &cov.missing_branches {
538 if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
539 if measured.contains(&src) {
540 missed.insert(src);
541 }
542 }
543 }
544 }
545 (measured, missed)
546}
547
548fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
551 let mut measured = BTreeSet::new();
552 let mut missed = BTreeSet::new();
553 let units = cov
554 .statements
555 .iter()
556 .flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
557 .chain(cov.branch_arms.iter().copied())
558 .chain(cov.functions.iter().copied());
559 for (line, covered) in units {
560 measured.insert(line);
561 if !covered {
562 missed.insert(line);
563 }
564 }
565 (measured, missed)
566}
567
568fn rust_measured_missed(
572 cov: &coverage::RustPatchCoverage,
573 thresholds: RustThresholds,
574) -> (BTreeSet<u64>, BTreeSet<u64>) {
575 let measured = measured_lines(cov);
576 let missed = measured
577 .iter()
578 .copied()
579 .filter(|&line| is_missed(cov, line, thresholds))
580 .collect();
581 (measured, missed)
582}
583
584fn measured_lines(cov: &coverage::RustPatchCoverage) -> BTreeSet<u64> {
586 let mut measured = BTreeSet::new();
587 for &(start, end, _covered) in &cov.regions {
588 for line in start..=end {
589 measured.insert(line);
590 }
591 }
592 measured
593}
594
595fn is_missed(cov: &coverage::RustPatchCoverage, line: u64, thresholds: RustThresholds) -> bool {
600 let mut covered_here = false;
601 let mut uncovered_region = false;
602 for &(start, end, covered) in &cov.regions {
603 if start <= line && line <= end {
604 if covered {
605 covered_here = true;
606 } else {
607 uncovered_region = true;
608 }
609 }
610 }
611 if thresholds.regions.is_some() {
612 uncovered_region
613 } else {
614 !covered_here
615 }
616}
617
618fn apply_line_exemptions(
621 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
622 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
623) -> Result<BTreeMap<String, BTreeSet<u64>>> {
624 guard_exempt_lines_are_failing(detail, exempt_lines)?;
625 Ok(detail
626 .iter()
627 .map(|(file, (measured, _))| (file.clone(), kept_lines(measured, exempt_lines.get(file))))
628 .collect())
629}
630
631fn guard_exempt_lines_are_failing(
634 detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
635 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
636) -> Result<()> {
637 let mut over: Vec<String> = Vec::new();
638 for (file, lines) in exempt_lines {
639 let missed = detail.get(file).map(|(_, missed)| missed);
640 for &line in lines {
641 let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
642 if !failing {
643 over.push(format!("\n {file}:{line}"));
644 }
645 }
646 }
647 if !over.is_empty() {
648 bail!(
649 "a line-scoped coverage exemption may only list uncovered lines, but these are \
650 covered or carry no measured code:{}",
651 over.concat()
652 );
653 }
654 Ok(())
655}
656
657fn kept_lines(measured: &BTreeSet<u64>, exempt: Option<&BTreeSet<u32>>) -> BTreeSet<u64> {
660 measured
661 .iter()
662 .copied()
663 .filter(|&line| {
664 !exempt
665 .is_some_and(|exempt| u32::try_from(line).is_ok_and(|line| exempt.contains(&line)))
666 })
667 .collect()
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn the_diff_argv_pins_every_flag_its_doc_comment_calls_load_bearing() {
676 let argv = unified_diff_argv("main...HEAD");
677 assert!(argv.starts_with(&["-c", "core.quotepath=off", "diff"]));
680 assert!(argv.contains(&"--src-prefix=a/"));
681 assert!(argv.contains(&"--dst-prefix=b/"));
682 assert!(argv.contains(&"--no-ext-diff"));
683 assert!(argv.contains(&"--no-color"));
684 assert!(argv.contains(&"--no-renames"));
685 assert!(argv.contains(&"--unified=0"));
687 assert_eq!(argv.last(), Some(&"main...HEAD"));
688 }
689
690 #[test]
691 fn a_failed_diff_names_the_range_and_the_stderr() {
692 let output = std::process::Output {
693 status: failed_status(),
694 stdout: Vec::new(),
695 stderr: b"fatal: bad revision".to_vec(),
696 };
697 let err = diff_exit(&output, Path::new("/repo"), "nope...HEAD").unwrap_err();
698 let text = err.to_string();
699 assert!(text.contains("nope...HEAD"), "got: {text}");
700 assert!(text.contains("fatal: bad revision"), "got: {text}");
701 }
702
703 #[test]
704 fn a_successful_diff_is_not_an_error() {
705 let output = std::process::Output {
706 status: ok_status(),
707 stdout: Vec::new(),
708 stderr: Vec::new(),
709 };
710 assert!(diff_exit(&output, Path::new("/repo"), "main...HEAD").is_ok());
711 }
712
713 #[test]
714 fn measured_lines_spans_every_region_inclusive_of_both_ends() {
715 let cov = coverage::RustPatchCoverage {
716 regions: vec![(3, 5, true), (9, 9, false)],
717 };
718 assert_eq!(
719 measured_lines(&cov),
720 [3, 4, 5, 9].into_iter().collect::<BTreeSet<u64>>()
721 );
722 }
723
724 #[test]
725 fn with_a_region_floor_any_uncovered_region_misses_the_line() {
726 let cov = coverage::RustPatchCoverage {
728 regions: vec![(4, 4, true), (4, 4, false)],
729 };
730 assert!(is_missed(&cov, 4, region_thresholds()));
731 }
732
733 #[test]
734 fn with_lines_only_one_covered_region_redeems_the_line() {
735 let cov = coverage::RustPatchCoverage {
736 regions: vec![(4, 4, true), (4, 4, false)],
737 };
738 assert!(!is_missed(&cov, 4, lines_only_thresholds()));
739 }
740
741 #[test]
742 fn a_line_no_region_covers_is_missed_under_either_reading() {
743 let cov = coverage::RustPatchCoverage {
744 regions: vec![(4, 4, false)],
745 };
746 assert!(is_missed(&cov, 4, region_thresholds()));
747 assert!(is_missed(&cov, 4, lines_only_thresholds()));
748 }
749
750 fn region_thresholds() -> RustThresholds {
751 RustThresholds {
752 regions: Some(100),
753 lines: 100,
754 functions: None,
755 branch: None,
756 }
757 }
758
759 fn lines_only_thresholds() -> RustThresholds {
760 RustThresholds {
761 regions: None,
762 lines: 100,
763 functions: None,
764 branch: None,
765 }
766 }
767
768 fn status(code: i32) -> std::process::ExitStatus {
771 use std::os::unix::process::ExitStatusExt;
772 std::process::ExitStatus::from_raw(code << 8)
773 }
774
775 fn ok_status() -> std::process::ExitStatus {
776 status(0)
777 }
778
779 fn failed_status() -> std::process::ExitStatus {
780 status(1)
781 }
782
783 fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
784 entries
785 .iter()
786 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
787 .collect()
788 }
789
790 #[test]
791 fn changed_lines_reports_a_spawn_failure() {
792 let err = changed_lines(Path::new("/nonexistent-tc-patch-coverage"), "main").unwrap_err();
793 assert!(err.to_string().contains("running `git diff`"), "got: {err}");
794 }
795
796 #[test]
797 fn parses_added_lines_from_a_hunk() {
798 let diff = "diff --git a/widget.py b/widget.py\n\
799 index abc..def 100644\n\
800 --- a/widget.py\n\
801 +++ b/widget.py\n\
802 @@ -3,0 +4,2 @@ def f(x):\n\
803 + if x == 99:\n\
804 + return 7\n";
805 assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
806 }
807
808 #[test]
809 fn parses_a_new_file_as_added_from_line_one() {
810 let diff = "diff --git a/lonely.py b/lonely.py\n\
811 new file mode 100644\n\
812 index 0000000..bbb\n\
813 --- /dev/null\n\
814 +++ b/lonely.py\n\
815 @@ -0,0 +1,2 @@\n\
816 +def lonely():\n\
817 + return 41\n";
818 assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
819 }
820
821 #[test]
822 fn a_deletion_only_hunk_records_no_added_lines() {
823 let diff = "diff --git a/widget.py b/widget.py\n\
824 index abc..def 100644\n\
825 --- a/widget.py\n\
826 +++ b/widget.py\n\
827 @@ -4,2 +3,0 @@ def f(x):\n\
828 - dead = 1\n\
829 - return dead\n";
830 assert!(parse_unified_diff(diff).is_empty());
831 }
832
833 #[test]
834 fn a_deleted_file_yields_no_entry() {
835 let diff = "diff --git a/gone.py b/gone.py\n\
836 deleted file mode 100644\n\
837 index abc..0000000\n\
838 --- a/gone.py\n\
839 +++ /dev/null\n\
840 @@ -1,2 +0,0 @@\n\
841 -def gone():\n\
842 - return 0\n";
843 assert!(parse_unified_diff(diff).is_empty());
844 }
845
846 #[test]
847 fn parses_multiple_files_and_a_single_line_hunk() {
848 let diff = "diff --git a/a.py b/a.py\n\
849 --- a/a.py\n\
850 +++ b/a.py\n\
851 @@ -1,0 +2 @@ def a():\n\
852 + x = 1\n\
853 diff --git a/pkg/b.py b/pkg/b.py\n\
854 --- a/pkg/b.py\n\
855 +++ b/pkg/b.py\n\
856 @@ -10,0 +11,1 @@\n\
857 + y = 2\n";
858 assert_eq!(
859 parse_unified_diff(diff),
860 changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
861 );
862 }
863
864 #[test]
865 fn a_plus_plus_body_line_is_not_a_file_header() {
866 let diff = "diff --git a/w.py b/w.py\n\
870 index abc..def 100644\n\
871 --- a/w.py\n\
872 +++ b/w.py\n\
873 @@ -1,0 +1,3 @@\n\
874 +++ 1\n\
875 +y = 1\n\
876 +z = 2\n";
877 assert_eq!(parse_unified_diff(diff), changed(&[("w.py", &[1, 2, 3])]));
878 }
879
880 #[test]
881 fn new_side_path_decodes_a_c_quoted_non_ascii_path() {
882 assert_eq!(
886 new_side_path("\"b/src/f\\303\\266\\303\\266.py\"").as_deref(),
887 Some("src/föö.py")
888 );
889 assert_eq!(new_side_path("b/src/föö.py").as_deref(), Some("src/föö.py"));
890 }
891
892 fn cov(
893 executed: &[u64],
894 missing: &[u64],
895 executed_branches: &[[i64; 2]],
896 missing_branches: &[[i64; 2]],
897 ) -> FileCoverage {
898 FileCoverage {
899 executed_lines: executed.to_vec(),
900 missing_lines: missing.to_vec(),
901 excluded_lines: Vec::new(),
902 executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
903 missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
904 }
905 }
906
907 const FLOOR_85: Thresholds = Thresholds {
908 fail_under: 85,
909 branch: true,
910 };
911
912 #[test]
913 fn patch_a_fully_covered_diff_passes() {
914 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
915 assert_eq!(
916 evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
917 Outcome::Pass
918 );
919 }
920
921 #[test]
922 fn patch_below_floor_fails_and_names_the_percent() {
923 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
924 let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
925 assert!(
926 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
927 "got: {out:?}"
928 );
929 }
930
931 #[test]
932 fn patch_the_same_diff_clears_a_lower_floor() {
933 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
934 let floor_70 = Thresholds {
935 fail_under: 70,
936 branch: true,
937 };
938 assert_eq!(
939 evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
940 Outcome::Pass
941 );
942 }
943
944 #[test]
945 fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
946 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
947 let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
948 assert!(
949 matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
950 "got: {out:?}"
951 );
952 }
953
954 #[test]
955 fn patch_branches_off_ignores_arcs() {
956 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
957 let no_branch = Thresholds {
958 fail_under: 85,
959 branch: false,
960 };
961 assert_eq!(
962 evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
963 Outcome::Pass
964 );
965 }
966
967 #[test]
968 fn patch_a_changed_file_absent_from_coverage_is_skipped() {
969 let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
970 assert_eq!(
971 evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
972 Outcome::Pass
973 );
974 }
975
976 #[test]
977 fn patch_a_diff_with_no_executable_changed_lines_passes() {
978 let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
979 assert_eq!(
980 evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
981 Outcome::Pass
982 );
983 }
984
985 use coverage::TsPatchCoverage;
986
987 fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
988 entries
989 .iter()
990 .map(|(path, cov)| (path.to_string(), cov.clone()))
991 .collect()
992 }
993
994 const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
995 lines: 80,
996 branches: 80,
997 functions: 80,
998 statements: 80,
999 };
1000
1001 #[test]
1002 fn ts_patch_a_fully_covered_diff_passes() {
1003 let detail = ts_detail(&[(
1004 "w.ts",
1005 TsPatchCoverage {
1006 statements: vec![(1, 1, true), (2, 2, true)],
1007 branch_arms: vec![(2, true)],
1008 functions: vec![(1, true)],
1009 },
1010 )]);
1011 assert_eq!(
1012 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1013 Outcome::Pass
1014 );
1015 }
1016
1017 #[test]
1018 fn ts_patch_below_floor_fails_and_names_the_metric() {
1019 let detail = ts_detail(&[(
1020 "w.ts",
1021 TsPatchCoverage {
1022 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1023 branch_arms: vec![],
1024 functions: vec![],
1025 },
1026 )]);
1027 let out =
1028 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
1029 assert!(
1030 matches!(&out, Outcome::Fail(m)
1031 if m.contains("statements 75.00% < 80%")
1032 && m.contains("lines 75.00% < 80%")
1033 && !m.contains("branches")
1034 && !m.contains("functions")),
1035 "got: {out:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn ts_patch_the_same_diff_clears_a_lower_floor() {
1041 let detail = ts_detail(&[(
1042 "w.ts",
1043 TsPatchCoverage {
1044 statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
1045 branch_arms: vec![],
1046 functions: vec![],
1047 },
1048 )]);
1049 let floor_70 = TypeScriptThresholds {
1050 lines: 70,
1051 branches: 70,
1052 functions: 70,
1053 statements: 70,
1054 };
1055 assert_eq!(
1056 evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
1057 Outcome::Pass
1058 );
1059 }
1060
1061 #[test]
1062 fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
1063 let detail = ts_detail(&[(
1064 "w.ts",
1065 TsPatchCoverage {
1066 statements: vec![(3, 3, true)],
1067 branch_arms: vec![(3, true), (3, false)],
1068 functions: vec![],
1069 },
1070 )]);
1071 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
1072 assert!(
1073 matches!(&out, Outcome::Fail(m)
1074 if m.contains("branches 50.00% < 80%")
1075 && !m.contains("lines")
1076 && !m.contains("statements")),
1077 "got: {out:?}"
1078 );
1079 }
1080
1081 #[test]
1082 fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
1083 let detail = ts_detail(&[(
1084 "w.ts",
1085 TsPatchCoverage {
1086 statements: vec![],
1087 branch_arms: vec![],
1088 functions: vec![(9, false)],
1089 },
1090 )]);
1091 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
1092 assert!(
1093 matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
1094 "got: {out:?}"
1095 );
1096 }
1097
1098 #[test]
1099 fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
1100 let detail = ts_detail(&[(
1101 "w.ts",
1102 TsPatchCoverage {
1103 statements: vec![(1, 1, true)],
1104 branch_arms: vec![],
1105 functions: vec![],
1106 },
1107 )]);
1108 assert_eq!(
1109 evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
1110 Outcome::Pass
1111 );
1112 }
1113
1114 #[test]
1115 fn ts_patch_a_comment_only_diff_passes() {
1116 let detail = ts_detail(&[(
1117 "w.ts",
1118 TsPatchCoverage {
1119 statements: vec![(1, 1, true), (2, 2, true)],
1120 branch_arms: vec![(2, true)],
1121 functions: vec![(1, true)],
1122 },
1123 )]);
1124 assert_eq!(
1125 evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
1126 Outcome::Pass
1127 );
1128 }
1129
1130 #[test]
1131 fn ts_patch_an_empty_diff_passes() {
1132 assert_eq!(
1133 evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
1134 Outcome::Pass
1135 );
1136 }
1137
1138 #[test]
1139 fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
1140 let detail = ts_detail(&[(
1141 "w.ts",
1142 TsPatchCoverage {
1143 statements: vec![(3, 5, false)],
1144 branch_arms: vec![],
1145 functions: vec![],
1146 },
1147 )]);
1148 let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
1149 assert!(
1150 matches!(&out, Outcome::Fail(m)
1151 if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
1152 "got: {out:?}"
1153 );
1154 }
1155
1156 fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
1157 entries
1158 .iter()
1159 .map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
1160 .collect()
1161 }
1162
1163 #[test]
1164 fn python_measured_missed_reads_lines_and_branch_sources() {
1165 let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
1166 let (measured, missed) = python_measured_missed(&full, true);
1167 assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
1168 assert_eq!(missed, [2, 3, 4].into_iter().collect());
1169 let partial = cov(&[5], &[], &[], &[[5, 6]]);
1170 let (_, missed_no_branch) = python_measured_missed(&partial, false);
1171 assert!(missed_no_branch.is_empty());
1172 let (_, missed_branch) = python_measured_missed(&partial, true);
1173 assert_eq!(missed_branch, [5].into_iter().collect());
1174 }
1175
1176 #[test]
1177 fn python_measured_missed_skips_an_arc_from_an_unmeasured_line() {
1178 let partial = cov(&[1], &[], &[], &[[99, 1]]);
1179 let (measured, missed) = python_measured_missed(&partial, true);
1180 assert_eq!(measured, [1].into_iter().collect());
1181 assert!(missed.is_empty(), "got: {missed:?}");
1182 }
1183
1184 #[test]
1185 fn python_measured_missed_skips_an_arc_with_a_negative_source() {
1186 let partial = cov(&[1], &[], &[], &[[-1, 1]]);
1187 let (measured, missed) = python_measured_missed(&partial, true);
1188 assert_eq!(measured, [1].into_iter().collect());
1189 assert!(missed.is_empty(), "got: {missed:?}");
1190 }
1191
1192 #[test]
1193 fn ts_measured_missed_anchors_units_on_their_lines() {
1194 let cov = coverage::TsPatchCoverage {
1195 statements: vec![(1, 1, true), (3, 4, false)],
1196 branch_arms: vec![(1, false)],
1197 functions: vec![(6, false)],
1198 };
1199 let (measured, missed) = ts_measured_missed(&cov);
1200 assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
1201 assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
1202 }
1203
1204 #[test]
1205 fn rust_measured_missed_honors_the_enforced_metrics() {
1206 let cov = coverage::RustPatchCoverage {
1207 regions: vec![(1, 1, true), (5, 6, false)],
1208 };
1209 let with_regions = RustThresholds {
1210 regions: Some(100),
1211 lines: 100,
1212 functions: None,
1213 branch: None,
1214 };
1215 let (measured, missed) = rust_measured_missed(&cov, with_regions);
1216 assert_eq!(measured, [1, 5, 6].into_iter().collect());
1217 assert_eq!(missed, [5, 6].into_iter().collect());
1218 let lines_only = RustThresholds {
1219 regions: None,
1220 lines: 100,
1221 functions: None,
1222 branch: None,
1223 };
1224 let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
1225 assert_eq!(missed_lines, [5, 6].into_iter().collect());
1226 }
1227
1228 #[test]
1229 fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
1230 let detail = BTreeMap::from([(
1231 "shim.py".to_string(),
1232 (
1233 [1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1234 [2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
1235 ),
1236 )]);
1237 let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
1238 assert_eq!(line_set["shim.py"], [1].into_iter().collect());
1239 }
1240
1241 #[test]
1242 fn apply_line_exemptions_rejects_a_covered_listed_line() {
1243 let detail = BTreeMap::from([(
1244 "shim.py".to_string(),
1245 (
1246 [1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
1247 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1248 ),
1249 )]);
1250 let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
1251 assert!(
1252 err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
1253 "got: {err}"
1254 );
1255 }
1256
1257 #[test]
1258 fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
1259 let detail = BTreeMap::from([(
1260 "w.py".to_string(),
1261 (
1262 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1263 [2u64].into_iter().collect::<BTreeSet<u64>>(),
1264 ),
1265 )]);
1266 let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
1267 assert!(err.to_string().contains("w.py:9"), "got: {err}");
1268 }
1269
1270 #[test]
1271 fn floor_outcome_matches_the_whole_tree_message() {
1272 assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
1273 let out = floor_outcome(7, 8, 100);
1274 assert!(
1275 matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
1276 "got: {out:?}"
1277 );
1278 assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
1279 }
1280
1281 #[test]
1282 fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
1283 let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
1284 lift_exempt_lines(
1285 &mut changed,
1286 &exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
1287 );
1288 assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
1289 assert_eq!(changed["core.py"], [5].into_iter().collect());
1290 }
1291
1292 #[test]
1293 fn kept_lines_drops_only_the_exempt_lines() {
1294 let measured = BTreeSet::from([3u64, 4, 5]);
1295 assert_eq!(
1296 kept_lines(&measured, Some(&BTreeSet::from([4u32]))),
1297 BTreeSet::from([3, 5])
1298 );
1299 assert_eq!(kept_lines(&measured, None), measured);
1300 }
1301
1302 #[test]
1303 fn a_measured_line_too_large_for_a_u32_can_carry_no_exemption() {
1304 let measured = BTreeSet::from([u64::from(u32::MAX) + 1]);
1307 assert_eq!(
1308 kept_lines(&measured, Some(&BTreeSet::from([u32::MAX]))),
1309 measured
1310 );
1311 }
1312}