Skip to main content

omni_dev/coverage/
analysis.rs

1//! Coverage attribution: combine a head per-line report with a [`DiffModel`]
2//! (and optionally a baseline report) into the metrics a reviewer wants.
3//!
4//! - **Patch coverage** — of the lines this diff added, how many are covered.
5//!   Needs only the head report + diff; immune to line-shift because added lines
6//!   exist only in head.
7//! - **Uncovered new lines** — the explicit `file:line` list of added lines that
8//!   are not covered (the actionable output).
9//! - **Project delta** — per-file and total before/after coverage *(baseline)*.
10//! - **Indirect changes** — lines whose coverage flipped without their content
11//!   changing, found by aligning base↔head through the diff *(baseline)*.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use super::diff::{DiffModel, FileDiff};
16use super::markers::{FileMarkers, MarkerKind, Region};
17use super::model::{CoverageReport, FileCoverage};
18
19/// A base-side → head-side line mapper used during indirect-change detection.
20type BaseToHead<'a> = Box<dyn Fn(u32) -> Option<u32> + 'a>;
21
22/// The source markers found on each side of the comparison.
23///
24/// `ignore` regions never reach here — they are applied as a filter on the
25/// reports themselves before analysis, so their lines simply do not exist by
26/// this point. What remains is `tolerate`, which needs the analysis to know
27/// which head lines to score against the baseline instead of against the head
28/// run.
29///
30/// Only the **head** tolerated set drives masking. The base side is carried for
31/// reporting only: a tolerated base line whose region disappeared in head has
32/// nothing left to mask, and one that survives is reached through its head
33/// counterpart anyway.
34#[derive(Debug, Clone, Default)]
35pub struct Markers {
36    /// Head-revision markers, keyed by repo-relative head path.
37    pub head: BTreeMap<String, FileMarkers>,
38    /// Base-revision markers, keyed by repo-relative base path.
39    pub base: BTreeMap<String, FileMarkers>,
40}
41
42impl Markers {
43    /// Whether either revision carried any marker at all.
44    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    /// The tolerated head lines of `path`, or an empty set.
50    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/// One region that actually applied, for the visibility note.
59///
60/// Silencing is never invisible: every applied region is reported with the
61/// revision it was observed on, its span there, and its author's reason.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AppliedMarker {
64    /// Repo-relative path of the marked file.
65    pub path: String,
66    /// Whether the region was ignored or tolerated.
67    pub kind: MarkerKind,
68    /// Which revision(s) the region was observed on.
69    pub side: MarkerSide,
70    /// First line of the region.
71    pub start: u32,
72    /// Last line of the region.
73    pub end: u32,
74    /// The marker's mandatory reason.
75    pub reason: String,
76}
77
78/// Which revision a reported region was observed on.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum MarkerSide {
81    /// Present identically on both revisions — the ordinary case for a region
82    /// that neither moved nor changed.
83    Both,
84    /// Present only at head.
85    Head,
86    /// Present only at base.
87    Base,
88}
89
90impl MarkerSide {
91    /// Short label used in rendered output.
92    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
101/// Minimum net covered-line change for an *unchanged* file (one the diff never
102/// touched) to be surfaced under [`DiffScope::DiffOnly`]. Small run-to-run flips
103/// (the usual cross-run measurement noise) stay below this; a real cross-file
104/// effect — e.g. a PR that removes a test, dropping a whole module's coverage —
105/// exceeds it and is reported in `notable_unchanged`.
106const NOTABLE_UNCHANGED_LINES: u64 = 10;
107
108/// Which files the project-delta and indirect-change sections report on.
109///
110/// Coverage is measured by running the test suite twice (baseline vs head), and
111/// that measurement is not perfectly reproducible — lines in code with any
112/// run-to-run variance flip even when the source is identical. Only changes in
113/// files the diff *touches* are causally attributable to the PR; everything else
114/// is measurement noise. `DiffOnly` (the default) reports only touched files,
115/// with a magnitude-gated note for substantially-moved unchanged files so real
116/// cross-file effects still surface.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum DiffScope {
119    /// Report deltas/indirect only for files the diff touches (plus the
120    /// `notable_unchanged` magnitude-gated note). The default.
121    #[default]
122    DiffOnly,
123    /// Report deltas/indirect for *all* files (legacy; includes the cross-run
124    /// measurement noise on files the PR never modified).
125    All,
126}
127
128/// Covered / uncovered tally over a set of lines.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub struct PatchCoverage {
131    /// Lines covered (hit count > 0).
132    pub covered: u64,
133    /// Lines instrumented but uncovered (hit count == 0).
134    pub uncovered: u64,
135}
136
137impl PatchCoverage {
138    /// Instrumented lines considered (covered + uncovered).
139    pub fn total(&self) -> u64 {
140        self.covered + self.uncovered
141    }
142
143    /// Coverage percentage, or `None` when no instrumented lines were considered.
144    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/// Patch coverage for a single file.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct FilePatch {
157    /// Repo-relative head path.
158    pub path: String,
159    /// Covered/uncovered tally over this file's added lines.
160    pub patch: PatchCoverage,
161    /// New-side line numbers that were added but are uncovered.
162    pub uncovered_lines: Vec<u32>,
163}
164
165/// Per-file project coverage delta (requires a baseline report).
166#[derive(Debug, Clone, PartialEq)]
167pub struct FileDelta {
168    /// Repo-relative head path.
169    pub path: String,
170    /// Baseline coverage percentage (`None` for a file new to head).
171    pub before: Option<f64>,
172    /// Head coverage percentage (`None` when the file has no executable lines).
173    ///
174    /// Always the **real, measured** value — this is what is displayed.
175    pub after: Option<f64>,
176    /// Head coverage with `tolerate` masking applied: tolerated lines scored
177    /// with their baseline hit status instead of their head one.
178    ///
179    /// Equal to `after` unless a tolerated line actually flipped. It is what
180    /// [`delta`](Self::delta) reports, so the *number* stays honest while the
181    /// *signal* stops moving with cross-run variance.
182    pub after_effective: Option<f64>,
183}
184
185impl FileDelta {
186    /// Creates a delta whose effective coverage is its real coverage — the case
187    /// for every file with no tolerated line.
188    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    /// Percentage-point change, or `None` when there is no baseline value.
198    ///
199    /// Computed from [`after_effective`](Self::after_effective), so a flip on a
200    /// tolerated line does not register as a change.
201    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    /// Whether masking changed this file's reported movement.
210    pub fn is_masked(&self) -> bool {
211        self.after_effective != self.after
212    }
213}
214
215/// A line whose coverage status flipped without its content changing.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct IndirectChange {
218    /// Repo-relative head path.
219    pub path: String,
220    /// Base-side line number.
221    pub base_line: u32,
222    /// Head-side line number the base line maps to.
223    pub head_line: u32,
224    /// `true` if uncovered→covered, `false` if covered→uncovered.
225    pub became_covered: bool,
226}
227
228/// The full attribution result.
229#[derive(Debug, Clone, Default)]
230pub struct CoverageDiff {
231    /// Project-wide patch coverage.
232    pub patch: PatchCoverage,
233    /// Per-file patch coverage (only files with added, instrumented lines).
234    pub file_patches: Vec<FilePatch>,
235    /// Flattened actionable list of uncovered added lines.
236    pub uncovered_new_lines: Vec<(String, u32)>,
237    /// Whether a baseline report was supplied (enables the fields below).
238    pub has_baseline: bool,
239    /// Head project coverage percentage — the real, measured value, and the one
240    /// that is displayed.
241    pub total_after: Option<f64>,
242    /// Head project coverage with `tolerate` masking applied, used for the
243    /// headline direction and delta. Equal to `total_after` unless a tolerated
244    /// line flipped. `None` without a baseline, where there is nothing to mask.
245    pub total_after_effective: Option<f64>,
246    /// Baseline project coverage percentage (requires a baseline).
247    pub total_before: Option<f64>,
248    /// Per-file project deltas (requires a baseline). Under [`DiffScope::DiffOnly`]
249    /// this lists only files the diff touched.
250    pub file_deltas: Vec<FileDelta>,
251    /// Files the diff did *not* touch whose coverage nonetheless moved by at
252    /// least [`NOTABLE_UNCHANGED_LINES`] covered lines (requires a baseline; only
253    /// populated under [`DiffScope::DiffOnly`]). These are flagged separately as
254    /// not attributable to the PR, so a real cross-file regression still shows
255    /// while small measurement-noise flips stay hidden.
256    pub notable_unchanged: Vec<FileDelta>,
257    /// Indirect coverage flips on unchanged lines (requires a baseline). Under
258    /// [`DiffScope::DiffOnly`] this lists only flips within files the diff touched.
259    ///
260    /// Flips on `tolerate`d head lines are excluded: they are precisely the
261    /// cross-run variance the marker exists to silence.
262    pub indirect: Vec<IndirectChange>,
263    /// Source-marker regions that applied, for the visibility note. Empty when
264    /// no marker was found.
265    pub markers: Vec<AppliedMarker>,
266}
267
268impl CoverageDiff {
269    /// Indirect lines that became covered.
270    pub fn indirect_newly_covered(&self) -> usize {
271        self.indirect.iter().filter(|c| c.became_covered).count()
272    }
273
274    /// Indirect lines that became uncovered.
275    pub fn indirect_newly_uncovered(&self) -> usize {
276        self.indirect.iter().filter(|c| !c.became_covered).count()
277    }
278}
279
280/// Runs the full attribution at the given [`DiffScope`], with no source markers.
281pub 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
290/// Runs the full attribution, applying `tolerate` source markers.
291///
292/// `ignore` markers are *not* handled here: they are a filter on the reports
293/// themselves, applied before this is called, so their lines have already left
294/// both sides. `markers` carries what remains — the tolerated line sets, and the
295/// applied-region list for reporting.
296///
297/// Without a baseline, `tolerate` is inert: masking substitutes a *baseline* hit
298/// status, and there is none.
299pub 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
324/// Flattens both revisions' markers into the reportable list, collapsing a
325/// region that is identical on both sides — the ordinary case for a region that
326/// neither moved nor changed — into a single `both` entry.
327fn applied_markers(markers: &Markers) -> Vec<AppliedMarker> {
328    // A region is "the same region" across revisions when its kind and reason
329    // match — *not* when its span does. Matching on the span would report a
330    // region that merely moved between the two revisions as two separate ones,
331    // which is exactly the case this whole design exists to absorb silently.
332    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
378/// Head-side hit statuses for one file with `tolerate` masking applied.
379///
380/// Returns the substitutions only — head line → the baseline hit status that
381/// should stand in for its measured one — so a caller can leave every other line
382/// alone.
383///
384/// The map is built by walking the **base** file forwards through the diff
385/// alignment rather than inverting it: `map` is base → head, and every
386/// substitution needs a base line anyway. A tolerated head line that no base
387/// line maps onto therefore gets no entry, which is exactly the rule — a line
388/// added inside a tolerated region keeps its real status, because there is no
389/// baseline status to inherit.
390fn 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
407/// Covered-line count for `file` with `substitutions` standing in for the
408/// measured hit status of the lines they name.
409fn 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
419/// Computes patch coverage and the uncovered-new-line list.
420fn 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                // Not instrumented (blank/comment/non-executable): excluded.
432                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
458/// Computes per-file project deltas against the baseline.
459///
460/// Under [`DiffScope::DiffOnly`], a file the diff did not touch goes to
461/// `file_deltas` only if its coverage moved by at least
462/// [`NOTABLE_UNCHANGED_LINES`] covered lines (→ `notable_unchanged`); smaller
463/// moves are dropped as measurement noise.
464fn 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        // A tolerated head line is scored with its base counterpart's status,
477        // which needs both a baseline file and an alignment onto it.
478        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        // Untouched file under DiffOnly: surface only a substantial net move,
510        // measured on the effective count so a fully-tolerated flip cannot
511        // reach the threshold.
512        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
530/// Indexes the diff's changed files by their base-side path.
531fn 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
538/// The base path and base→head alignment for a **head** path.
539///
540/// A file the diff never touched aligns by identity — the case that matters
541/// most, since a CPU-gated region flips in files no PR touches. A file the diff
542/// added has no base counterpart at all.
543fn 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            // Untouched by the diff — unless it is the *target* of a rename,
556            // in which case `diff.files` would have held it. Identity aligns.
557            let _ = by_old_path;
558            Some((head_path.to_string(), Box::new(Some)))
559        }
560    }
561}
562
563/// Detects coverage flips on lines whose content did not change.
564///
565/// Changed files are aligned through their [`FileDiff`]. Under [`DiffScope::All`],
566/// entirely-unchanged files are also compared by identity alignment; under
567/// [`DiffScope::DiffOnly`] (the default) they are skipped, because a per-line
568/// flip in a file the PR never touched is cross-run measurement noise, not a
569/// real change (its file-level move, if substantial, is reported via
570/// `notable_unchanged` instead).
571fn 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        // Determine the head path and the base→head line mapping.
583        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                // File untouched by the diff: identity alignment. (A file added by
595                // the diff is excluded — its lines are direct, not indirect.)
596                // Only under `All` scope — otherwise these per-line flips are noise.
597                (base_path.as_str(), Box::new(Some))
598            } else {
599                // Deleted in head — nothing to compare.
600                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            // A tolerated head line's flip is the variance the marker exists
611            // to silence; reporting it would put back exactly what was masked.
612            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    /// Minimal diff with one added-line set on a (possibly new) file.
656    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        // File has lines 1..4; the diff added lines 2 and 3.
674        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        // Added lines 2 (uncovered), 5 (not instrumented — absent from report).
691        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)])]); // 50%
722        let head = report(&[("src/a.rs", &[(1, 1), (2, 1)])]); // 100%
723        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        // File src/b.rs is untouched by the diff but line 5 lost coverage.
745        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]); // unrelated change
748        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        // The added line 1 is direct (patch), not indirect, even with a baseline.
783        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]); // new file → no old_path
786        let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
787        // New file has no base mapping, so no indirect entries from it.
788        assert!(out.indirect.is_empty());
789    }
790
791    // ── DiffScope::DiffOnly (noise filter) ──
792
793    #[test]
794    fn diff_only_suppresses_untouched_file_indirect() {
795        // Same as indirect_change_on_unchanged_file, but DiffOnly drops the flip.
796        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]); // unrelated change
799        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        // A one-line move is below the notable threshold → not surfaced.
805        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]); // only a.rs is touched
819        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        // An untouched file loses 12 covered lines (e.g. its only test was removed).
828        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]); // unrelated
833        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    /// Builds head-side markers tolerating `lines` of `path`.
856    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    /// The motivating case: an untouched, CPU-gated file whose lines flip
873    /// between two runner CPUs. Without the marker the headline moves; with it
874    /// the headline is flat and the reported percentage is unchanged.
875    #[test]
876    fn tolerated_flip_in_an_untouched_file_does_not_move_the_headline() {
877        // `src/gated.rs` lines 1-2 covered at base, uncovered at head.
878        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    /// Masking is not a blanket amnesty: an untolerated line in the same file
909    /// still moves the number.
910    #[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    /// A tolerated line with *no* base counterpart — one the diff added — keeps
927    /// its real status. New code should still be tested even if it flaps later.
928    #[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    /// The per-file table displays the real coverage while its delta is masked.
949    #[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    /// The notable-unchanged gate counts effective covered lines, so a
965    /// fully-tolerated flip cannot reach the threshold.
966    #[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    /// A flip on a tolerated line must not come back as an indirect change —
985    /// that would put back exactly what was masked.
986    #[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    /// Masking substitutes a *baseline* status, so with no baseline there is
1003    /// nothing to substitute and `tolerate` is inert.
1004    #[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    /// A region present identically on both revisions collapses to one `both`
1020    /// entry; one that exists on only one side is reported against that side.
1021    #[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}