diff-coverage 0.6.1

Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI.
pub mod clover;
pub mod cobertura;
pub mod store;

use std::io::Read;

use std::collections::BTreeSet;

use crate::diff::types::ChangedFile;
use crate::report::{CoverageReport, SkippedFile, UncoveredFile};
use crate::util::path::normalize_path;
use regex::Regex;
use store::CoverageStore;

pub trait CoverageParser {
    fn can_parse<R: Read>(&self, reader: R) -> std::io::Result<bool>;
    fn parse<R: Read>(&self, reader: R, sink: &mut dyn CoverageSink) -> std::io::Result<()>;
}

pub trait CoverageSink {
    fn on_file(&mut self, file_path: &str);
    fn on_line(&mut self, file_path: &str, line: u32, hits: u32);
}

pub fn analyze_changed_coverage(
    changed_files: &[ChangedFile],
    coverage: &CoverageStore,
    treat_missing_as_uncovered: bool,
    skip_paths: &[Regex],
) -> Result<CoverageReport, store::CoverageLookupError> {
    let mut uncovered_files = Vec::new();
    let mut skipped_files = Vec::new();
    let mut total_changed = 0usize;
    let mut total_covered = 0usize;

    for changed_file in changed_files {
        let normalized_path = normalize_path(&changed_file.path);
        let unique_lines: BTreeSet<u32> = changed_file.changed_lines.iter().copied().collect();
        if unique_lines.is_empty() {
            continue;
        }
        if let Some(matched) = skip_paths
            .iter()
            .find(|pattern| pattern.is_match(&normalized_path))
        {
            skipped_files.push(SkippedFile {
                path: normalized_path,
                pattern: matched.as_str().to_string(),
            });
            continue;
        }

        let file_coverage = coverage.file_coverage(&normalized_path)?;
        let mut uncovered_lines = Vec::new();
        let mut covered_count = 0usize;
        let mut changed_count = 0usize;

        if let Some(file_coverage) = file_coverage.as_deref() {
            for line in unique_lines {
                if !file_coverage.is_measured(line) {
                    continue;
                }
                if file_coverage.is_covered(line) {
                    covered_count += 1;
                } else {
                    uncovered_lines.push(line);
                }
            }

            changed_count = covered_count + uncovered_lines.len();
            total_changed += changed_count;
            total_covered += covered_count;
        } else if treat_missing_as_uncovered {
            uncovered_lines.extend(unique_lines.into_iter());
            changed_count = uncovered_lines.len();
            total_changed += changed_count;
        }

        if !uncovered_lines.is_empty() {
            uncovered_files.push(UncoveredFile {
                path: normalized_path,
                uncovered_lines,
                covered_lines: covered_count,
                changed_lines: changed_count,
            });
        }
    }

    Ok(CoverageReport {
        total_changed,
        total_covered,
        uncovered_files,
        skipped_files,
    })
}

#[cfg(test)]
mod tests {
    use super::analyze_changed_coverage;
    use crate::coverage::store::CoverageStore;
    use crate::coverage::CoverageSink;
    use crate::diff::types::ChangedFile;
    use regex::Regex;

    #[test]
    fn counts_unique_changed_lines_and_tracks_uncovered() {
        let changed_files = vec![ChangedFile {
            path: "src\\foo.rs".to_string(),
            changed_lines: vec![1, 1, 2, 3],
        }];

        let mut store = CoverageStore::default();
        store.on_line("src/foo.rs", 1, 2);
        store.on_line("src/foo.rs", 2, 0);

        let report = analyze_changed_coverage(&changed_files, &store, true, &[]).expect("report");

        assert_eq!(report.total_changed, 2);
        assert_eq!(report.total_covered, 1);
        assert_eq!(report.uncovered_files.len(), 1);
        assert!(report.skipped_files.is_empty());
        let uncovered = &report.uncovered_files[0];
        assert_eq!(uncovered.path, "src/foo.rs");
        assert_eq!(uncovered.uncovered_lines, vec![2]);
    }

    #[test]
    fn treats_missing_files_as_uncovered() {
        let changed_files = vec![ChangedFile {
            path: "src/foo.rs".to_string(),
            changed_lines: vec![1, 2, 2],
        }];

        let store = CoverageStore::default();
        let report = analyze_changed_coverage(&changed_files, &store, true, &[]).expect("report");

        assert_eq!(report.total_changed, 2);
        assert_eq!(report.total_covered, 0);
        assert_eq!(report.uncovered_files.len(), 1);
        assert!(report.skipped_files.is_empty());
        let uncovered = &report.uncovered_files[0];
        assert_eq!(uncovered.path, "src/foo.rs");
        assert_eq!(uncovered.uncovered_lines, vec![1, 2]);
    }

    #[test]
    fn ignores_missing_files_when_disabled() {
        let changed_files = vec![ChangedFile {
            path: "src/foo.rs".to_string(),
            changed_lines: vec![1, 2, 2],
        }];

        let store = CoverageStore::default();
        let report = analyze_changed_coverage(&changed_files, &store, false, &[]).expect("report");

        assert_eq!(report.total_changed, 0);
        assert_eq!(report.total_covered, 0);
        assert!(report.uncovered_files.is_empty());
        assert!(report.skipped_files.is_empty());
    }

    #[test]
    fn skips_files_matching_patterns() {
        let changed_files = vec![
            ChangedFile {
                path: "pkg/mongodb/client.go".to_string(),
                changed_lines: vec![1, 2],
            },
            ChangedFile {
                path: "src/main.go".to_string(),
                changed_lines: vec![10],
            },
        ];

        let mut store = CoverageStore::default();
        store.on_line("src/main.go", 10, 1);

        let patterns = [Regex::new(r"^pkg/mongodb").expect("regex")];
        let report =
            analyze_changed_coverage(&changed_files, &store, true, &patterns).expect("report");

        assert_eq!(report.total_changed, 1);
        assert_eq!(report.total_covered, 1);
        assert!(report.uncovered_files.is_empty());
        assert_eq!(report.skipped_files.len(), 1);
        let skipped = &report.skipped_files[0];
        assert_eq!(skipped.path, "pkg/mongodb/client.go");
        assert_eq!(skipped.pattern, r"^pkg/mongodb");
    }
}