1use std::collections::BTreeMap;
14
15use super::diff::{DiffModel, FileDiff};
16use super::model::{CoverageReport, FileCoverage};
17
18type BaseToHead<'a> = Box<dyn Fn(u32) -> Option<u32> + 'a>;
20
21const NOTABLE_UNCHANGED_LINES: u64 = 10;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum DiffScope {
39 #[default]
42 DiffOnly,
43 All,
46}
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct PatchCoverage {
51 pub covered: u64,
53 pub uncovered: u64,
55}
56
57impl PatchCoverage {
58 pub fn total(&self) -> u64 {
60 self.covered + self.uncovered
61 }
62
63 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#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct FilePatch {
77 pub path: String,
79 pub patch: PatchCoverage,
81 pub uncovered_lines: Vec<u32>,
83}
84
85#[derive(Debug, Clone, PartialEq)]
87pub struct FileDelta {
88 pub path: String,
90 pub before: Option<f64>,
92 pub after: Option<f64>,
94}
95
96impl FileDelta {
97 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#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct IndirectChange {
110 pub path: String,
112 pub base_line: u32,
114 pub head_line: u32,
116 pub became_covered: bool,
118}
119
120#[derive(Debug, Clone, Default)]
122pub struct CoverageDiff {
123 pub patch: PatchCoverage,
125 pub file_patches: Vec<FilePatch>,
127 pub uncovered_new_lines: Vec<(String, u32)>,
129 pub has_baseline: bool,
131 pub total_after: Option<f64>,
133 pub total_before: Option<f64>,
135 pub file_deltas: Vec<FileDelta>,
138 pub notable_unchanged: Vec<FileDelta>,
144 pub indirect: Vec<IndirectChange>,
147}
148
149impl CoverageDiff {
150 pub fn indirect_newly_covered(&self) -> usize {
152 self.indirect.iter().filter(|c| c.became_covered).count()
153 }
154
155 pub fn indirect_newly_uncovered(&self) -> usize {
157 self.indirect.iter().filter(|c| !c.became_covered).count()
158 }
159}
160
161pub 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
185fn 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 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
224fn 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 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
264fn indirect_changes(
273 head: &CoverageReport,
274 baseline: &CoverageReport,
275 diff: &DiffModel,
276 scope: DiffScope,
277 result: &mut CoverageDiff,
278) {
279 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 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 (base_path.as_str(), Box::new(Some))
303 } else {
304 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 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 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 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)])]); let head = report(&[("src/a.rs", &[(1, 1), (2, 1)])]); 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 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]); 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 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]); let out = analyze(&head, &diff, Some(&baseline), DiffScope::All);
488 assert!(out.indirect.is_empty());
490 }
491
492 #[test]
495 fn diff_only_suppresses_untouched_file_indirect() {
496 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]); 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 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]); 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 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]); 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}