1use std::collections::{BTreeMap, BTreeSet};
14
15use super::diff::{DiffModel, FileDiff};
16use super::markers::{FileMarkers, MarkerKind, Region};
17use super::model::{CoverageReport, FileCoverage};
18
19type BaseToHead<'a> = Box<dyn Fn(u32) -> Option<u32> + 'a>;
21
22#[derive(Debug, Clone, Default)]
35pub struct Markers {
36 pub head: BTreeMap<String, FileMarkers>,
38 pub base: BTreeMap<String, FileMarkers>,
40}
41
42impl Markers {
43 pub fn is_empty(&self) -> bool {
45 self.head.values().all(FileMarkers::is_empty)
46 && self.base.values().all(FileMarkers::is_empty)
47 }
48
49 fn tolerated(&self, path: &str) -> Option<&BTreeSet<u32>> {
51 self.head
52 .get(path)
53 .map(|m| &m.tolerated)
54 .filter(|t| !t.is_empty())
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AppliedMarker {
64 pub path: String,
66 pub kind: MarkerKind,
68 pub side: MarkerSide,
70 pub start: u32,
72 pub end: u32,
74 pub reason: String,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum MarkerSide {
81 Both,
84 Head,
86 Base,
88}
89
90impl MarkerSide {
91 pub fn as_str(self) -> &'static str {
93 match self {
94 Self::Both => "both",
95 Self::Head => "head",
96 Self::Base => "base",
97 }
98 }
99}
100
101const NOTABLE_UNCHANGED_LINES: u64 = 10;
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum DiffScope {
119 #[default]
122 DiffOnly,
123 All,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub struct PatchCoverage {
131 pub covered: u64,
133 pub uncovered: u64,
135}
136
137impl PatchCoverage {
138 pub fn total(&self) -> u64 {
140 self.covered + self.uncovered
141 }
142
143 pub fn percent(&self) -> Option<f64> {
145 let total = self.total();
146 if total == 0 {
147 None
148 } else {
149 Some(self.covered as f64 / total as f64 * 100.0)
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct FilePatch {
157 pub path: String,
159 pub patch: PatchCoverage,
161 pub uncovered_lines: Vec<u32>,
163}
164
165#[derive(Debug, Clone, PartialEq)]
167pub struct FileDelta {
168 pub path: String,
170 pub before: Option<f64>,
172 pub after: Option<f64>,
176 pub after_effective: Option<f64>,
183}
184
185impl FileDelta {
186 pub fn new(path: impl Into<String>, before: Option<f64>, after: Option<f64>) -> Self {
189 Self {
190 path: path.into(),
191 before,
192 after,
193 after_effective: after,
194 }
195 }
196
197 pub fn delta(&self) -> Option<f64> {
202 match (self.before, self.after_effective) {
203 (Some(b), Some(a)) => Some(a - b),
204 (Some(b), None) => Some(0.0 - b),
205 _ => None,
206 }
207 }
208
209 pub fn is_masked(&self) -> bool {
211 self.after_effective != self.after
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct IndirectChange {
218 pub path: String,
220 pub base_line: u32,
222 pub head_line: u32,
224 pub became_covered: bool,
226}
227
228#[derive(Debug, Clone, Default)]
230pub struct CoverageDiff {
231 pub patch: PatchCoverage,
233 pub file_patches: Vec<FilePatch>,
235 pub uncovered_new_lines: Vec<(String, u32)>,
237 pub has_baseline: bool,
239 pub total_after: Option<f64>,
242 pub total_after_effective: Option<f64>,
246 pub total_before: Option<f64>,
248 pub file_deltas: Vec<FileDelta>,
251 pub notable_unchanged: Vec<FileDelta>,
257 pub indirect: Vec<IndirectChange>,
263 pub markers: Vec<AppliedMarker>,
266}
267
268impl CoverageDiff {
269 pub fn indirect_newly_covered(&self) -> usize {
271 self.indirect.iter().filter(|c| c.became_covered).count()
272 }
273
274 pub fn indirect_newly_uncovered(&self) -> usize {
276 self.indirect.iter().filter(|c| !c.became_covered).count()
277 }
278}
279
280pub fn analyze(
282 head: &CoverageReport,
283 diff: &DiffModel,
284 baseline: Option<&CoverageReport>,
285 scope: DiffScope,
286) -> CoverageDiff {
287 analyze_with_markers(head, diff, baseline, scope, &Markers::default())
288}
289
290pub fn analyze_with_markers(
300 head: &CoverageReport,
301 diff: &DiffModel,
302 baseline: Option<&CoverageReport>,
303 scope: DiffScope,
304 markers: &Markers,
305) -> CoverageDiff {
306 let mut result = CoverageDiff {
307 total_after: head.percent(),
308 has_baseline: baseline.is_some(),
309 markers: applied_markers(markers),
310 ..Default::default()
311 };
312
313 patch_coverage(head, diff, &mut result);
314
315 if let Some(baseline) = baseline {
316 result.total_before = baseline.percent();
317 project_delta(head, baseline, diff, scope, markers, &mut result);
318 indirect_changes(head, baseline, diff, scope, markers, &mut result);
319 }
320
321 result
322}
323
324fn applied_markers(markers: &Markers) -> Vec<AppliedMarker> {
328 let same_region = |a: &Region, b: &Region| a.kind == b.kind && a.reason == b.reason;
333
334 let mut applied: Vec<AppliedMarker> = Vec::new();
335 for (path, file) in &markers.head {
336 for region in &file.regions {
337 let same_at_base = markers
338 .base
339 .get(path)
340 .is_some_and(|base| base.regions.iter().any(|other| same_region(other, region)));
341 applied.push(AppliedMarker {
342 path: path.clone(),
343 kind: region.kind,
344 side: if same_at_base {
345 MarkerSide::Both
346 } else {
347 MarkerSide::Head
348 },
349 start: region.start,
350 end: region.end,
351 reason: region.reason.clone(),
352 });
353 }
354 }
355 for (path, file) in &markers.base {
356 for region in &file.regions {
357 let seen_at_head = markers
358 .head
359 .get(path)
360 .is_some_and(|head| head.regions.iter().any(|other| same_region(other, region)));
361 if seen_at_head {
362 continue;
363 }
364 applied.push(AppliedMarker {
365 path: path.clone(),
366 kind: region.kind,
367 side: MarkerSide::Base,
368 start: region.start,
369 end: region.end,
370 reason: region.reason.clone(),
371 });
372 }
373 }
374 applied.sort_by(|a, b| a.path.cmp(&b.path).then(a.start.cmp(&b.start)));
375 applied
376}
377
378fn tolerated_substitutions(
391 base_file: &FileCoverage,
392 map: &BaseToHead<'_>,
393 tolerated: &BTreeSet<u32>,
394) -> BTreeMap<u32, u64> {
395 let mut substitutions = BTreeMap::new();
396 for (&base_line, &base_hits) in &base_file.lines {
397 let Some(head_line) = map(base_line) else {
398 continue;
399 };
400 if tolerated.contains(&head_line) {
401 substitutions.insert(head_line, base_hits);
402 }
403 }
404 substitutions
405}
406
407fn effective_covered(file: &FileCoverage, substitutions: &BTreeMap<u32, u64>) -> u64 {
410 file.lines
411 .iter()
412 .filter(|(line, hits)| {
413 let effective = substitutions.get(line).unwrap_or(hits);
414 *effective > 0
415 })
416 .count() as u64
417}
418
419fn patch_coverage(head: &CoverageReport, diff: &DiffModel, result: &mut CoverageDiff) {
421 for file in diff.files.values() {
422 let mut patch = PatchCoverage::default();
423 let mut uncovered_lines = Vec::new();
424 for &line in &file.added {
425 match head.hits(&file.new_path, line) {
426 Some(h) if h > 0 => patch.covered += 1,
427 Some(_) => {
428 patch.uncovered += 1;
429 uncovered_lines.push(line);
430 }
431 None => {}
433 }
434 }
435 if patch.total() == 0 {
436 continue;
437 }
438 result.patch.covered += patch.covered;
439 result.patch.uncovered += patch.uncovered;
440 for &line in &uncovered_lines {
441 result
442 .uncovered_new_lines
443 .push((file.new_path.clone(), line));
444 }
445 result.file_patches.push(FilePatch {
446 path: file.new_path.clone(),
447 patch,
448 uncovered_lines,
449 });
450 }
451
452 result.file_patches.sort_by(|a, b| a.path.cmp(&b.path));
453 result
454 .uncovered_new_lines
455 .sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
456}
457
458fn project_delta(
465 head: &CoverageReport,
466 baseline: &CoverageReport,
467 diff: &DiffModel,
468 scope: DiffScope,
469 markers: &Markers,
470 result: &mut CoverageDiff,
471) {
472 let by_old_path = index_by_old_path(diff);
473 let mut effective_covered_total = 0_u64;
474
475 for (path, file) in &head.files {
476 let substitutions = markers
479 .tolerated(path)
480 .and_then(|tolerated| {
481 let (base_path, map) = base_side(path, diff, &by_old_path)?;
482 let base_file = baseline.files.get(&base_path)?;
483 Some(tolerated_substitutions(base_file, &map, tolerated))
484 })
485 .unwrap_or_default();
486
487 let covered_after = file.covered_lines();
488 let covered_effective = if substitutions.is_empty() {
489 covered_after
490 } else {
491 effective_covered(file, &substitutions)
492 };
493 effective_covered_total += covered_effective;
494
495 let total = file.total_lines();
496 let percent = |covered: u64| (total > 0).then(|| covered as f64 / total as f64 * 100.0);
497 let delta = FileDelta {
498 path: path.clone(),
499 before: baseline.files.get(path).and_then(FileCoverage::percent),
500 after: percent(covered_after),
501 after_effective: percent(covered_effective),
502 };
503
504 if scope == DiffScope::All || diff.files.contains_key(path) {
505 result.file_deltas.push(delta);
506 continue;
507 }
508
509 let covered_before = baseline
513 .files
514 .get(path)
515 .map_or(0, FileCoverage::covered_lines);
516 let net = covered_effective.abs_diff(covered_before);
517 if net >= NOTABLE_UNCHANGED_LINES {
518 result.notable_unchanged.push(delta);
519 }
520 }
521
522 let total_lines = head.total_lines();
523 result.total_after_effective =
524 (total_lines > 0).then(|| effective_covered_total as f64 / total_lines as f64 * 100.0);
525
526 result.file_deltas.sort_by(|a, b| a.path.cmp(&b.path));
527 result.notable_unchanged.sort_by(|a, b| a.path.cmp(&b.path));
528}
529
530fn index_by_old_path(diff: &DiffModel) -> BTreeMap<&str, &FileDiff> {
532 diff.files
533 .values()
534 .filter_map(|f| f.old_path.as_deref().map(|p| (p, f)))
535 .collect()
536}
537
538fn base_side<'a>(
544 head_path: &str,
545 diff: &'a DiffModel,
546 by_old_path: &BTreeMap<&'a str, &'a FileDiff>,
547) -> Option<(String, BaseToHead<'a>)> {
548 match diff.files.get(head_path) {
549 Some(fd) if fd.is_new => None,
550 Some(fd) => {
551 let old_path = fd.old_path.clone()?;
552 Some((old_path, Box::new(move |l| fd.map_base_to_head(l))))
553 }
554 None => {
555 let _ = by_old_path;
558 Some((head_path.to_string(), Box::new(Some)))
559 }
560 }
561}
562
563fn indirect_changes(
572 head: &CoverageReport,
573 baseline: &CoverageReport,
574 diff: &DiffModel,
575 scope: DiffScope,
576 markers: &Markers,
577 result: &mut CoverageDiff,
578) {
579 let by_old_path = index_by_old_path(diff);
580
581 for (base_path, base_file) in &baseline.files {
582 let (new_path, map): (&str, BaseToHead<'_>) =
584 if let Some(fd) = by_old_path.get(base_path.as_str()) {
585 let fd = *fd;
586 (
587 fd.new_path.as_str(),
588 Box::new(move |l| fd.map_base_to_head(l)),
589 )
590 } else if scope == DiffScope::All
591 && head.files.contains_key(base_path)
592 && !diff.files.contains_key(base_path)
593 {
594 (base_path.as_str(), Box::new(Some))
598 } else {
599 continue;
601 };
602
603 for (&base_line, &base_hits) in &base_file.lines {
604 let Some(head_line) = map(base_line) else {
605 continue;
606 };
607 let Some(head_hits) = head.hits(new_path, head_line) else {
608 continue;
609 };
610 if markers
613 .tolerated(new_path)
614 .is_some_and(|t| t.contains(&head_line))
615 {
616 continue;
617 }
618 let covered_before = base_hits > 0;
619 let covered_after = head_hits > 0;
620 if covered_before != covered_after {
621 result.indirect.push(IndirectChange {
622 path: new_path.to_string(),
623 base_line,
624 head_line,
625 became_covered: covered_after,
626 });
627 }
628 }
629 }
630
631 result
632 .indirect
633 .sort_by(|a, b| a.path.cmp(&b.path).then(a.head_line.cmp(&b.head_line)));
634}
635
636#[cfg(test)]
637#[allow(clippy::unwrap_used, clippy::expect_used)]
638mod tests {
639 use super::*;
640 use crate::coverage::model::FileCoverage;
641 use std::collections::{BTreeMap, BTreeSet};
642
643 pub(super) fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
644 let mut r = CoverageReport::new();
645 for (path, lines) in files {
646 let mut f = FileCoverage::new(*path);
647 for &(n, h) in *lines {
648 f.record(n, h);
649 }
650 r.insert(f);
651 }
652 r
653 }
654
655 pub(super) fn diff_added(path: &str, is_new: bool, added: &[u32]) -> DiffModel {
657 let old_path = if is_new { None } else { Some(path.to_string()) };
658 let fd = FileDiff::new(
659 path,
660 old_path,
661 is_new,
662 false,
663 added.iter().copied().collect::<BTreeSet<u32>>(),
664 BTreeSet::new(),
665 );
666 let mut files = BTreeMap::new();
667 files.insert(path.to_string(), fd);
668 DiffModel { files }
669 }
670
671 #[test]
672 fn patch_coverage_counts_added_lines_only() {
673 let head = report(&[("src/a.rs", &[(1, 1), (2, 1), (3, 0), (4, 1)])]);
675 let diff = diff_added("src/a.rs", false, &[2, 3]);
676 let out = analyze(&head, &diff, None, DiffScope::All);
677 assert_eq!(
678 out.patch,
679 PatchCoverage {
680 covered: 1,
681 uncovered: 1
682 }
683 );
684 assert_eq!(out.patch.percent(), Some(50.0));
685 assert_eq!(out.uncovered_new_lines, vec![("src/a.rs".to_string(), 3)]);
686 }
687
688 #[test]
689 fn added_non_executable_lines_excluded_from_denominator() {
690 let head = report(&[("src/a.rs", &[(1, 1), (2, 0)])]);
692 let diff = diff_added("src/a.rs", false, &[2, 5]);
693 let out = analyze(&head, &diff, None, DiffScope::All);
694 assert_eq!(
695 out.patch,
696 PatchCoverage {
697 covered: 0,
698 uncovered: 1
699 }
700 );
701 }
702
703 #[test]
704 fn new_file_patch_coverage() {
705 let head = report(&[("src/new.rs", &[(1, 1), (2, 0), (3, 1)])]);
706 let diff = diff_added("src/new.rs", true, &[1, 2, 3]);
707 let out = analyze(&head, &diff, None, DiffScope::All);
708 assert_eq!(
709 out.patch,
710 PatchCoverage {
711 covered: 2,
712 uncovered: 1
713 }
714 );
715 assert_eq!(out.file_patches.len(), 1);
716 assert_eq!(out.file_patches[0].uncovered_lines, vec![2]);
717 }
718
719 #[test]
720 fn project_delta_with_baseline() {
721 let baseline = report(&[("src/a.rs", &[(1, 1), (2, 0)])]); let head = report(&[("src/a.rs", &[(1, 1), (2, 1)])]); let diff = diff_added("src/a.rs", false, &[2]);
724 let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
725 assert!(out.has_baseline);
726 assert_eq!(out.total_before, Some(50.0));
727 assert_eq!(out.total_after, Some(100.0));
728 assert_eq!(out.file_deltas.len(), 1);
729 assert_eq!(out.file_deltas[0].delta(), Some(50.0));
730 }
731
732 #[test]
733 fn delta_for_new_file_is_after_minus_nothing() {
734 let baseline = report(&[]);
735 let head = report(&[("src/new.rs", &[(1, 1)])]);
736 let diff = diff_added("src/new.rs", true, &[1]);
737 let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
738 assert_eq!(out.file_deltas[0].before, None);
739 assert_eq!(out.file_deltas[0].after, Some(100.0));
740 }
741
742 #[test]
743 fn indirect_change_on_unchanged_file() {
744 let baseline = report(&[("src/b.rs", &[(5, 3)])]);
746 let head = report(&[("src/b.rs", &[(5, 0)])]);
747 let diff = diff_added("src/a.rs", true, &[1]); let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
749 assert_eq!(out.indirect.len(), 1);
750 assert_eq!(out.indirect[0].path, "src/b.rs");
751 assert_eq!(out.indirect[0].base_line, 5);
752 assert!(!out.indirect[0].became_covered);
753 assert_eq!(out.indirect_newly_uncovered(), 1);
754 }
755
756 #[test]
757 fn patch_percent_none_when_empty() {
758 assert_eq!(PatchCoverage::default().percent(), None);
759 assert_eq!(PatchCoverage::default().total(), 0);
760 }
761
762 #[test]
763 fn file_delta_handles_all_combinations() {
764 let d = |before, after| FileDelta::new("x", before, after);
765 assert_eq!(d(Some(80.0), Some(90.0)).delta(), Some(10.0));
766 assert_eq!(d(Some(50.0), None).delta(), Some(-50.0));
767 assert_eq!(d(None, Some(50.0)).delta(), None);
768 }
769
770 #[test]
771 fn indirect_change_newly_covered() {
772 let baseline = report(&[("src/b.rs", &[(5, 0)])]);
773 let head = report(&[("src/b.rs", &[(5, 3)])]);
774 let diff = diff_added("src/a.rs", true, &[1]);
775 let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
776 assert_eq!(out.indirect_newly_covered(), 1);
777 assert!(out.indirect[0].became_covered);
778 }
779
780 #[test]
781 fn added_lines_are_not_counted_as_indirect() {
782 let baseline = report(&[("src/a.rs", &[(1, 1)])]);
784 let head = report(&[("src/a.rs", &[(1, 0)])]);
785 let diff = diff_added("src/a.rs", true, &[1]); let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
787 assert!(out.indirect.is_empty());
789 }
790
791 #[test]
794 fn diff_only_suppresses_untouched_file_indirect() {
795 let baseline = report(&[("src/b.rs", &[(5, 3)])]);
797 let head = report(&[("src/b.rs", &[(5, 0)])]);
798 let diff = diff_added("src/a.rs", true, &[1]); let out = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
800 assert!(
801 out.indirect.is_empty(),
802 "an untouched-file flip is cross-run noise under DiffOnly"
803 );
804 assert!(out.notable_unchanged.is_empty());
806 }
807
808 #[test]
809 fn diff_only_delta_table_scoped_to_changed_files() {
810 let baseline = report(&[
811 ("src/a.rs", &[(1, 1), (2, 0)]),
812 ("src/b.rs", &[(1, 1), (2, 1)]),
813 ]);
814 let head = report(&[
815 ("src/a.rs", &[(1, 1), (2, 1)]),
816 ("src/b.rs", &[(1, 1), (2, 0)]),
817 ]);
818 let diff = diff_added("src/a.rs", false, &[2]); let out = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
820 let paths: Vec<&str> = out.file_deltas.iter().map(|d| d.path.as_str()).collect();
821 assert_eq!(paths, vec!["src/a.rs"], "only the changed file appears");
822 assert!(out.notable_unchanged.is_empty(), "b.rs moved < threshold");
823 }
824
825 #[test]
826 fn diff_only_surfaces_substantial_unchanged_move() {
827 let before: Vec<(u32, u64)> = (1..=12).map(|n| (n, 1)).collect();
829 let after: Vec<(u32, u64)> = (1..=12).map(|n| (n, 0)).collect();
830 let baseline = report(&[("src/c.rs", &before)]);
831 let head = report(&[("src/c.rs", &after)]);
832 let diff = diff_added("src/a.rs", true, &[1]); let out = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
834 assert!(out.file_deltas.is_empty(), "c.rs is not in the diff");
835 assert_eq!(
836 out.notable_unchanged.len(),
837 1,
838 "12-line drop exceeds threshold"
839 );
840 assert_eq!(out.notable_unchanged[0].path, "src/c.rs");
841 assert!(
842 out.indirect.is_empty(),
843 "per-line indirect still suppressed"
844 );
845 }
846}
847
848#[cfg(test)]
849#[allow(clippy::unwrap_used, clippy::expect_used)]
850mod marker_tests {
851 use super::tests::*;
852 use super::*;
853 use crate::coverage::markers::Region;
854
855 fn tolerate(path: &str, lines: &[u32]) -> Markers {
857 let regions = lines
858 .iter()
859 .map(|&line| Region {
860 kind: MarkerKind::Tolerate,
861 start: line,
862 end: line,
863 reason: "CPU-gated".to_string(),
864 })
865 .collect();
866 Markers {
867 head: BTreeMap::from([(path.to_string(), FileMarkers::new(regions))]),
868 base: BTreeMap::new(),
869 }
870 }
871
872 #[test]
876 fn tolerated_flip_in_an_untouched_file_does_not_move_the_headline() {
877 let head = report(&[
879 ("src/gated.rs", &[(1, 0), (2, 0)]),
880 ("src/other.rs", &[(1, 1), (2, 1)]),
881 ]);
882 let baseline = report(&[
883 ("src/gated.rs", &[(1, 5), (2, 5)]),
884 ("src/other.rs", &[(1, 1), (2, 1)]),
885 ]);
886 let diff = DiffModel::default();
887
888 let bare = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
889 assert_eq!(bare.total_after, Some(50.0));
890 assert_eq!(bare.total_after_effective, Some(50.0));
891 assert_eq!(bare.total_before, Some(100.0));
892
893 let markers = tolerate("src/gated.rs", &[1, 2]);
894 let masked =
895 analyze_with_markers(&head, &diff, Some(&baseline), DiffScope::DiffOnly, &markers);
896 assert_eq!(
897 masked.total_after,
898 Some(50.0),
899 "the reported percentage must stay the real measured value"
900 );
901 assert_eq!(
902 masked.total_after_effective,
903 Some(100.0),
904 "the headline delta must see the baseline status of tolerated lines"
905 );
906 }
907
908 #[test]
911 fn untolerated_lines_in_a_tolerated_file_still_count() {
912 let head = report(&[("src/gated.rs", &[(1, 0), (2, 0)])]);
913 let baseline = report(&[("src/gated.rs", &[(1, 5), (2, 5)])]);
914 let markers = tolerate("src/gated.rs", &[1]);
915 let out = analyze_with_markers(
916 &head,
917 &DiffModel::default(),
918 Some(&baseline),
919 DiffScope::DiffOnly,
920 &markers,
921 );
922 assert_eq!(out.total_after, Some(0.0));
923 assert_eq!(out.total_after_effective, Some(50.0));
924 }
925
926 #[test]
929 fn a_tolerated_added_line_keeps_its_real_status() {
930 let head = report(&[("src/a.rs", &[(1, 1), (2, 0)])]);
931 let baseline = report(&[("src/a.rs", &[(1, 1)])]);
932 let diff = diff_added("src/a.rs", false, &[2]);
933 let markers = tolerate("src/a.rs", &[2]);
934 let out =
935 analyze_with_markers(&head, &diff, Some(&baseline), DiffScope::DiffOnly, &markers);
936 assert_eq!(
937 out.total_after_effective,
938 Some(50.0),
939 "an added line has no baseline status to inherit"
940 );
941 assert_eq!(out.patch.covered, 0);
942 assert_eq!(
943 out.patch.uncovered, 1,
944 "a tolerated added line stays in the patch denominator"
945 );
946 }
947
948 #[test]
950 fn per_file_delta_is_masked_but_the_percentage_is_real() {
951 let head = report(&[("src/a.rs", &[(1, 0), (2, 1)])]);
952 let baseline = report(&[("src/a.rs", &[(1, 5), (2, 1)])]);
953 let diff = diff_added("src/a.rs", false, &[]);
954 let markers = tolerate("src/a.rs", &[1]);
955 let out =
956 analyze_with_markers(&head, &diff, Some(&baseline), DiffScope::DiffOnly, &markers);
957 let fd = &out.file_deltas[0];
958 assert_eq!(fd.after, Some(50.0), "displayed percentage stays real");
959 assert_eq!(fd.after_effective, Some(100.0));
960 assert_eq!(fd.delta(), Some(0.0));
961 assert!(fd.is_masked());
962 }
963
964 #[test]
967 fn tolerated_flip_does_not_reach_the_notable_threshold() {
968 let lines_head: Vec<(u32, u64)> = (1..=12).map(|n| (n, 0)).collect();
969 let lines_base: Vec<(u32, u64)> = (1..=12).map(|n| (n, 3)).collect();
970 let head = report(&[("src/gated.rs", &lines_head)]);
971 let baseline = report(&[("src/gated.rs", &lines_base)]);
972 let diff = DiffModel::default();
973
974 let bare = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
975 assert_eq!(bare.notable_unchanged.len(), 1, "12 lines flipped");
976
977 let all: Vec<u32> = (1..=12).collect();
978 let markers = tolerate("src/gated.rs", &all);
979 let masked =
980 analyze_with_markers(&head, &diff, Some(&baseline), DiffScope::DiffOnly, &markers);
981 assert!(masked.notable_unchanged.is_empty());
982 }
983
984 #[test]
987 fn indirect_changes_skip_tolerated_lines() {
988 let head = report(&[("src/a.rs", &[(1, 0), (2, 0)])]);
989 let baseline = report(&[("src/a.rs", &[(1, 5), (2, 5)])]);
990 let diff = diff_added("src/a.rs", false, &[]);
991
992 let bare = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
993 assert_eq!(bare.indirect.len(), 2);
994
995 let markers = tolerate("src/a.rs", &[1]);
996 let masked =
997 analyze_with_markers(&head, &diff, Some(&baseline), DiffScope::DiffOnly, &markers);
998 assert_eq!(masked.indirect.len(), 1);
999 assert_eq!(masked.indirect[0].head_line, 2);
1000 }
1001
1002 #[test]
1005 fn tolerate_is_inert_without_a_baseline() {
1006 let head = report(&[("src/a.rs", &[(1, 0), (2, 1)])]);
1007 let markers = tolerate("src/a.rs", &[1]);
1008 let out = analyze_with_markers(
1009 &head,
1010 &diff_added("src/a.rs", false, &[]),
1011 None,
1012 DiffScope::DiffOnly,
1013 &markers,
1014 );
1015 assert_eq!(out.total_after, Some(50.0));
1016 assert_eq!(out.total_after_effective, None);
1017 }
1018
1019 #[test]
1022 fn applied_markers_collapse_when_identical_on_both_sides() {
1023 let shared = Region {
1024 kind: MarkerKind::Tolerate,
1025 start: 3,
1026 end: 5,
1027 reason: "CPU-gated".to_string(),
1028 };
1029 let base_only = Region {
1030 kind: MarkerKind::Ignore,
1031 start: 9,
1032 end: 9,
1033 reason: "removed in head".to_string(),
1034 };
1035 let markers = Markers {
1036 head: BTreeMap::from([(
1037 "src/a.rs".to_string(),
1038 FileMarkers::new(vec![shared.clone()]),
1039 )]),
1040 base: BTreeMap::from([(
1041 "src/a.rs".to_string(),
1042 FileMarkers::new(vec![shared, base_only]),
1043 )]),
1044 };
1045 let out = analyze_with_markers(
1046 &report(&[("src/a.rs", &[(1, 1)])]),
1047 &DiffModel::default(),
1048 None,
1049 DiffScope::DiffOnly,
1050 &markers,
1051 );
1052 assert_eq!(out.markers.len(), 2);
1053 assert_eq!(out.markers[0].side, MarkerSide::Both);
1054 assert_eq!(out.markers[0].start, 3);
1055 assert_eq!(out.markers[1].side, MarkerSide::Base);
1056 assert_eq!(out.markers[1].kind, MarkerKind::Ignore);
1057 }
1058}