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;
14
15use super::diff::{DiffModel, FileDiff};
16use super::model::{CoverageReport, FileCoverage};
17
18/// A base-side → head-side line mapper used during indirect-change detection.
19type BaseToHead<'a> = Box<dyn Fn(u32) -> Option<u32> + 'a>;
20
21/// Minimum net covered-line change for an *unchanged* file (one the diff never
22/// touched) to be surfaced under [`DiffScope::DiffOnly`]. Small run-to-run flips
23/// (the usual cross-run measurement noise) stay below this; a real cross-file
24/// effect — e.g. a PR that removes a test, dropping a whole module's coverage —
25/// exceeds it and is reported in `notable_unchanged`.
26const NOTABLE_UNCHANGED_LINES: u64 = 10;
27
28/// Which files the project-delta and indirect-change sections report on.
29///
30/// Coverage is measured by running the test suite twice (baseline vs head), and
31/// that measurement is not perfectly reproducible — lines in code with any
32/// run-to-run variance flip even when the source is identical. Only changes in
33/// files the diff *touches* are causally attributable to the PR; everything else
34/// is measurement noise. `DiffOnly` (the default) reports only touched files,
35/// with a magnitude-gated note for substantially-moved unchanged files so real
36/// cross-file effects still surface.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum DiffScope {
39    /// Report deltas/indirect only for files the diff touches (plus the
40    /// `notable_unchanged` magnitude-gated note). The default.
41    #[default]
42    DiffOnly,
43    /// Report deltas/indirect for *all* files (legacy; includes the cross-run
44    /// measurement noise on files the PR never modified).
45    All,
46}
47
48/// Covered / uncovered tally over a set of lines.
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct PatchCoverage {
51    /// Lines covered (hit count > 0).
52    pub covered: u64,
53    /// Lines instrumented but uncovered (hit count == 0).
54    pub uncovered: u64,
55}
56
57impl PatchCoverage {
58    /// Instrumented lines considered (covered + uncovered).
59    pub fn total(&self) -> u64 {
60        self.covered + self.uncovered
61    }
62
63    /// Coverage percentage, or `None` when no instrumented lines were considered.
64    pub fn percent(&self) -> Option<f64> {
65        let total = self.total();
66        if total == 0 {
67            None
68        } else {
69            Some(self.covered as f64 / total as f64 * 100.0)
70        }
71    }
72}
73
74/// Patch coverage for a single file.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct FilePatch {
77    /// Repo-relative head path.
78    pub path: String,
79    /// Covered/uncovered tally over this file's added lines.
80    pub patch: PatchCoverage,
81    /// New-side line numbers that were added but are uncovered.
82    pub uncovered_lines: Vec<u32>,
83}
84
85/// Per-file project coverage delta (requires a baseline report).
86#[derive(Debug, Clone, PartialEq)]
87pub struct FileDelta {
88    /// Repo-relative head path.
89    pub path: String,
90    /// Baseline coverage percentage (`None` for a file new to head).
91    pub before: Option<f64>,
92    /// Head coverage percentage (`None` when the file has no executable lines).
93    pub after: Option<f64>,
94}
95
96impl FileDelta {
97    /// Percentage-point change, or `None` when there is no baseline value.
98    pub fn delta(&self) -> Option<f64> {
99        match (self.before, self.after) {
100            (Some(b), Some(a)) => Some(a - b),
101            (Some(b), None) => Some(0.0 - b),
102            _ => None,
103        }
104    }
105}
106
107/// A line whose coverage status flipped without its content changing.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct IndirectChange {
110    /// Repo-relative head path.
111    pub path: String,
112    /// Base-side line number.
113    pub base_line: u32,
114    /// Head-side line number the base line maps to.
115    pub head_line: u32,
116    /// `true` if uncovered→covered, `false` if covered→uncovered.
117    pub became_covered: bool,
118}
119
120/// The full attribution result.
121#[derive(Debug, Clone, Default)]
122pub struct CoverageDiff {
123    /// Project-wide patch coverage.
124    pub patch: PatchCoverage,
125    /// Per-file patch coverage (only files with added, instrumented lines).
126    pub file_patches: Vec<FilePatch>,
127    /// Flattened actionable list of uncovered added lines.
128    pub uncovered_new_lines: Vec<(String, u32)>,
129    /// Whether a baseline report was supplied (enables the fields below).
130    pub has_baseline: bool,
131    /// Head project coverage percentage.
132    pub total_after: Option<f64>,
133    /// Baseline project coverage percentage (requires a baseline).
134    pub total_before: Option<f64>,
135    /// Per-file project deltas (requires a baseline). Under [`DiffScope::DiffOnly`]
136    /// this lists only files the diff touched.
137    pub file_deltas: Vec<FileDelta>,
138    /// Files the diff did *not* touch whose coverage nonetheless moved by at
139    /// least [`NOTABLE_UNCHANGED_LINES`] covered lines (requires a baseline; only
140    /// populated under [`DiffScope::DiffOnly`]). These are flagged separately as
141    /// not attributable to the PR, so a real cross-file regression still shows
142    /// while small measurement-noise flips stay hidden.
143    pub notable_unchanged: Vec<FileDelta>,
144    /// Indirect coverage flips on unchanged lines (requires a baseline). Under
145    /// [`DiffScope::DiffOnly`] this lists only flips within files the diff touched.
146    pub indirect: Vec<IndirectChange>,
147}
148
149impl CoverageDiff {
150    /// Indirect lines that became covered.
151    pub fn indirect_newly_covered(&self) -> usize {
152        self.indirect.iter().filter(|c| c.became_covered).count()
153    }
154
155    /// Indirect lines that became uncovered.
156    pub fn indirect_newly_uncovered(&self) -> usize {
157        self.indirect.iter().filter(|c| !c.became_covered).count()
158    }
159}
160
161/// Runs the full attribution at the given [`DiffScope`].
162pub fn analyze(
163    head: &CoverageReport,
164    diff: &DiffModel,
165    baseline: Option<&CoverageReport>,
166    scope: DiffScope,
167) -> CoverageDiff {
168    let mut result = CoverageDiff {
169        total_after: head.percent(),
170        has_baseline: baseline.is_some(),
171        ..Default::default()
172    };
173
174    patch_coverage(head, diff, &mut result);
175
176    if let Some(baseline) = baseline {
177        result.total_before = baseline.percent();
178        project_delta(head, baseline, diff, scope, &mut result);
179        indirect_changes(head, baseline, diff, scope, &mut result);
180    }
181
182    result
183}
184
185/// Computes patch coverage and the uncovered-new-line list.
186fn patch_coverage(head: &CoverageReport, diff: &DiffModel, result: &mut CoverageDiff) {
187    for file in diff.files.values() {
188        let mut patch = PatchCoverage::default();
189        let mut uncovered_lines = Vec::new();
190        for &line in &file.added {
191            match head.hits(&file.new_path, line) {
192                Some(h) if h > 0 => patch.covered += 1,
193                Some(_) => {
194                    patch.uncovered += 1;
195                    uncovered_lines.push(line);
196                }
197                // Not instrumented (blank/comment/non-executable): excluded.
198                None => {}
199            }
200        }
201        if patch.total() == 0 {
202            continue;
203        }
204        result.patch.covered += patch.covered;
205        result.patch.uncovered += patch.uncovered;
206        for &line in &uncovered_lines {
207            result
208                .uncovered_new_lines
209                .push((file.new_path.clone(), line));
210        }
211        result.file_patches.push(FilePatch {
212            path: file.new_path.clone(),
213            patch,
214            uncovered_lines,
215        });
216    }
217
218    result.file_patches.sort_by(|a, b| a.path.cmp(&b.path));
219    result
220        .uncovered_new_lines
221        .sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
222}
223
224/// Computes per-file project deltas against the baseline.
225///
226/// Under [`DiffScope::DiffOnly`], a file the diff did not touch goes to
227/// `file_deltas` only if its coverage moved by at least
228/// [`NOTABLE_UNCHANGED_LINES`] covered lines (→ `notable_unchanged`); smaller
229/// moves are dropped as measurement noise.
230fn project_delta(
231    head: &CoverageReport,
232    baseline: &CoverageReport,
233    diff: &DiffModel,
234    scope: DiffScope,
235    result: &mut CoverageDiff,
236) {
237    for (path, file) in &head.files {
238        let delta = FileDelta {
239            path: path.clone(),
240            before: baseline.files.get(path).and_then(FileCoverage::percent),
241            after: file.percent(),
242        };
243
244        if scope == DiffScope::All || diff.files.contains_key(path) {
245            result.file_deltas.push(delta);
246            continue;
247        }
248
249        // Untouched file under DiffOnly: surface only a substantial net move.
250        let covered_after = file.covered_lines();
251        let covered_before = baseline
252            .files
253            .get(path)
254            .map_or(0, FileCoverage::covered_lines);
255        let net = covered_after.abs_diff(covered_before);
256        if net >= NOTABLE_UNCHANGED_LINES {
257            result.notable_unchanged.push(delta);
258        }
259    }
260    result.file_deltas.sort_by(|a, b| a.path.cmp(&b.path));
261    result.notable_unchanged.sort_by(|a, b| a.path.cmp(&b.path));
262}
263
264/// Detects coverage flips on lines whose content did not change.
265///
266/// Changed files are aligned through their [`FileDiff`]. Under [`DiffScope::All`],
267/// entirely-unchanged files are also compared by identity alignment; under
268/// [`DiffScope::DiffOnly`] (the default) they are skipped, because a per-line
269/// flip in a file the PR never touched is cross-run measurement noise, not a
270/// real change (its file-level move, if substantial, is reported via
271/// `notable_unchanged` instead).
272fn indirect_changes(
273    head: &CoverageReport,
274    baseline: &CoverageReport,
275    diff: &DiffModel,
276    scope: DiffScope,
277    result: &mut CoverageDiff,
278) {
279    // Index changed files by their base-side path.
280    let by_old_path: BTreeMap<&str, &FileDiff> = diff
281        .files
282        .values()
283        .filter_map(|f| f.old_path.as_deref().map(|p| (p, f)))
284        .collect();
285
286    for (base_path, base_file) in &baseline.files {
287        // Determine the head path and the base→head line mapping.
288        let (new_path, map): (&str, BaseToHead<'_>) =
289            if let Some(fd) = by_old_path.get(base_path.as_str()) {
290                let fd = *fd;
291                (
292                    fd.new_path.as_str(),
293                    Box::new(move |l| fd.map_base_to_head(l)),
294                )
295            } else if scope == DiffScope::All
296                && head.files.contains_key(base_path)
297                && !diff.files.contains_key(base_path)
298            {
299                // File untouched by the diff: identity alignment. (A file added by
300                // the diff is excluded — its lines are direct, not indirect.)
301                // Only under `All` scope — otherwise these per-line flips are noise.
302                (base_path.as_str(), Box::new(Some))
303            } else {
304                // Deleted in head — nothing to compare.
305                continue;
306            };
307
308        for (&base_line, &base_hits) in &base_file.lines {
309            let Some(head_line) = map(base_line) else {
310                continue;
311            };
312            let Some(head_hits) = head.hits(new_path, head_line) else {
313                continue;
314            };
315            let covered_before = base_hits > 0;
316            let covered_after = head_hits > 0;
317            if covered_before != covered_after {
318                result.indirect.push(IndirectChange {
319                    path: new_path.to_string(),
320                    base_line,
321                    head_line,
322                    became_covered: covered_after,
323                });
324            }
325        }
326    }
327
328    result
329        .indirect
330        .sort_by(|a, b| a.path.cmp(&b.path).then(a.head_line.cmp(&b.head_line)));
331}
332
333#[cfg(test)]
334#[allow(clippy::unwrap_used, clippy::expect_used)]
335mod tests {
336    use super::*;
337    use crate::coverage::model::FileCoverage;
338    use std::collections::{BTreeMap, BTreeSet};
339
340    fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
341        let mut r = CoverageReport::new();
342        for (path, lines) in files {
343            let mut f = FileCoverage::new(*path);
344            for &(n, h) in *lines {
345                f.record(n, h);
346            }
347            r.insert(f);
348        }
349        r
350    }
351
352    /// Minimal diff with one added-line set on a (possibly new) file.
353    fn diff_added(path: &str, is_new: bool, added: &[u32]) -> DiffModel {
354        let old_path = if is_new { None } else { Some(path.to_string()) };
355        let fd = FileDiff::new(
356            path,
357            old_path,
358            is_new,
359            false,
360            added.iter().copied().collect::<BTreeSet<u32>>(),
361            BTreeSet::new(),
362        );
363        let mut files = BTreeMap::new();
364        files.insert(path.to_string(), fd);
365        DiffModel { files }
366    }
367
368    #[test]
369    fn patch_coverage_counts_added_lines_only() {
370        // File has lines 1..4; the diff added lines 2 and 3.
371        let head = report(&[("src/a.rs", &[(1, 1), (2, 1), (3, 0), (4, 1)])]);
372        let diff = diff_added("src/a.rs", false, &[2, 3]);
373        let out = analyze(&head, &diff, None, DiffScope::All);
374        assert_eq!(
375            out.patch,
376            PatchCoverage {
377                covered: 1,
378                uncovered: 1
379            }
380        );
381        assert_eq!(out.patch.percent(), Some(50.0));
382        assert_eq!(out.uncovered_new_lines, vec![("src/a.rs".to_string(), 3)]);
383    }
384
385    #[test]
386    fn added_non_executable_lines_excluded_from_denominator() {
387        // Added lines 2 (uncovered), 5 (not instrumented — absent from report).
388        let head = report(&[("src/a.rs", &[(1, 1), (2, 0)])]);
389        let diff = diff_added("src/a.rs", false, &[2, 5]);
390        let out = analyze(&head, &diff, None, DiffScope::All);
391        assert_eq!(
392            out.patch,
393            PatchCoverage {
394                covered: 0,
395                uncovered: 1
396            }
397        );
398    }
399
400    #[test]
401    fn new_file_patch_coverage() {
402        let head = report(&[("src/new.rs", &[(1, 1), (2, 0), (3, 1)])]);
403        let diff = diff_added("src/new.rs", true, &[1, 2, 3]);
404        let out = analyze(&head, &diff, None, DiffScope::All);
405        assert_eq!(
406            out.patch,
407            PatchCoverage {
408                covered: 2,
409                uncovered: 1
410            }
411        );
412        assert_eq!(out.file_patches.len(), 1);
413        assert_eq!(out.file_patches[0].uncovered_lines, vec![2]);
414    }
415
416    #[test]
417    fn project_delta_with_baseline() {
418        let baseline = report(&[("src/a.rs", &[(1, 1), (2, 0)])]); // 50%
419        let head = report(&[("src/a.rs", &[(1, 1), (2, 1)])]); // 100%
420        let diff = diff_added("src/a.rs", false, &[2]);
421        let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
422        assert!(out.has_baseline);
423        assert_eq!(out.total_before, Some(50.0));
424        assert_eq!(out.total_after, Some(100.0));
425        assert_eq!(out.file_deltas.len(), 1);
426        assert_eq!(out.file_deltas[0].delta(), Some(50.0));
427    }
428
429    #[test]
430    fn delta_for_new_file_is_after_minus_nothing() {
431        let baseline = report(&[]);
432        let head = report(&[("src/new.rs", &[(1, 1)])]);
433        let diff = diff_added("src/new.rs", true, &[1]);
434        let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
435        assert_eq!(out.file_deltas[0].before, None);
436        assert_eq!(out.file_deltas[0].after, Some(100.0));
437    }
438
439    #[test]
440    fn indirect_change_on_unchanged_file() {
441        // File src/b.rs is untouched by the diff but line 5 lost coverage.
442        let baseline = report(&[("src/b.rs", &[(5, 3)])]);
443        let head = report(&[("src/b.rs", &[(5, 0)])]);
444        let diff = diff_added("src/a.rs", true, &[1]); // unrelated change
445        let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
446        assert_eq!(out.indirect.len(), 1);
447        assert_eq!(out.indirect[0].path, "src/b.rs");
448        assert_eq!(out.indirect[0].base_line, 5);
449        assert!(!out.indirect[0].became_covered);
450        assert_eq!(out.indirect_newly_uncovered(), 1);
451    }
452
453    #[test]
454    fn patch_percent_none_when_empty() {
455        assert_eq!(PatchCoverage::default().percent(), None);
456        assert_eq!(PatchCoverage::default().total(), 0);
457    }
458
459    #[test]
460    fn file_delta_handles_all_combinations() {
461        let d = |before, after| FileDelta {
462            path: "x".to_string(),
463            before,
464            after,
465        };
466        assert_eq!(d(Some(80.0), Some(90.0)).delta(), Some(10.0));
467        assert_eq!(d(Some(50.0), None).delta(), Some(-50.0));
468        assert_eq!(d(None, Some(50.0)).delta(), None);
469    }
470
471    #[test]
472    fn indirect_change_newly_covered() {
473        let baseline = report(&[("src/b.rs", &[(5, 0)])]);
474        let head = report(&[("src/b.rs", &[(5, 3)])]);
475        let diff = diff_added("src/a.rs", true, &[1]);
476        let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
477        assert_eq!(out.indirect_newly_covered(), 1);
478        assert!(out.indirect[0].became_covered);
479    }
480
481    #[test]
482    fn added_lines_are_not_counted_as_indirect() {
483        // The added line 1 is direct (patch), not indirect, even with a baseline.
484        let baseline = report(&[("src/a.rs", &[(1, 1)])]);
485        let head = report(&[("src/a.rs", &[(1, 0)])]);
486        let diff = diff_added("src/a.rs", true, &[1]); // new file → no old_path
487        let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
488        // New file has no base mapping, so no indirect entries from it.
489        assert!(out.indirect.is_empty());
490    }
491
492    // ── DiffScope::DiffOnly (noise filter) ──
493
494    #[test]
495    fn diff_only_suppresses_untouched_file_indirect() {
496        // Same as indirect_change_on_unchanged_file, but DiffOnly drops the flip.
497        let baseline = report(&[("src/b.rs", &[(5, 3)])]);
498        let head = report(&[("src/b.rs", &[(5, 0)])]);
499        let diff = diff_added("src/a.rs", true, &[1]); // unrelated change
500        let out = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
501        assert!(
502            out.indirect.is_empty(),
503            "an untouched-file flip is cross-run noise under DiffOnly"
504        );
505        // A one-line move is below the notable threshold → not surfaced.
506        assert!(out.notable_unchanged.is_empty());
507    }
508
509    #[test]
510    fn diff_only_delta_table_scoped_to_changed_files() {
511        let baseline = report(&[
512            ("src/a.rs", &[(1, 1), (2, 0)]),
513            ("src/b.rs", &[(1, 1), (2, 1)]),
514        ]);
515        let head = report(&[
516            ("src/a.rs", &[(1, 1), (2, 1)]),
517            ("src/b.rs", &[(1, 1), (2, 0)]),
518        ]);
519        let diff = diff_added("src/a.rs", false, &[2]); // only a.rs is touched
520        let out = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
521        let paths: Vec<&str> = out.file_deltas.iter().map(|d| d.path.as_str()).collect();
522        assert_eq!(paths, vec!["src/a.rs"], "only the changed file appears");
523        assert!(out.notable_unchanged.is_empty(), "b.rs moved < threshold");
524    }
525
526    #[test]
527    fn diff_only_surfaces_substantial_unchanged_move() {
528        // An untouched file loses 12 covered lines (e.g. its only test was removed).
529        let before: Vec<(u32, u64)> = (1..=12).map(|n| (n, 1)).collect();
530        let after: Vec<(u32, u64)> = (1..=12).map(|n| (n, 0)).collect();
531        let baseline = report(&[("src/c.rs", &before)]);
532        let head = report(&[("src/c.rs", &after)]);
533        let diff = diff_added("src/a.rs", true, &[1]); // unrelated
534        let out = analyze(&head, &diff, Some(&baseline), DiffScope::DiffOnly);
535        assert!(out.file_deltas.is_empty(), "c.rs is not in the diff");
536        assert_eq!(
537            out.notable_unchanged.len(),
538            1,
539            "12-line drop exceeds threshold"
540        );
541        assert_eq!(out.notable_unchanged[0].path, "src/c.rs");
542        assert!(
543            out.indirect.is_empty(),
544            "per-line indirect still suppressed"
545        );
546    }
547}