diff-coverage 0.6.1

Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI.
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;

use super::CoverageSink;

#[derive(Default)]
pub struct CoverageStore {
    pub files: HashMap<String, FileCoverage>,
    normalized_ready: bool,
}

#[derive(Default, Clone, Debug)]
pub struct FileCoverage {
    pub measured_lines: Vec<u32>,
    pub covered_lines: Vec<u32>,
    dirty: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverageLookupError {
    pub path: String,
    pub matches: Vec<String>,
}

impl fmt::Display for CoverageLookupError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "Ambiguous coverage match for '{}': {}",
            self.path,
            self.matches.join(", ")
        )
    }
}

impl std::error::Error for CoverageLookupError {}

impl CoverageStore {
    pub fn prepare_lookup(&mut self) {
        for coverage in self.files.values_mut() {
            coverage.normalize_in_place();
        }
        self.normalized_ready = true;
    }

    pub fn file_coverage<'a>(
        &'a self,
        path: &str,
    ) -> Result<Option<Cow<'a, FileCoverage>>, CoverageLookupError> {
        if let Some(coverage) = self.coverage_for(path) {
            return Ok(Some(coverage));
        }

        let mut matches = Vec::new();
        for key in self.files.keys() {
            if key.ends_with(path) || path.ends_with(key) {
                matches.push(key);
            }
        }

        match matches.len() {
            0 => Ok(None),
            1 => Ok(self.coverage_for(matches[0]).map(Some).unwrap_or(None)),
            _ => {
                let mut merged = FileCoverage::default();
                for key in matches {
                    if let Some(coverage) = self.coverage_for(key) {
                        merged
                            .measured_lines
                            .extend(coverage.measured_lines.iter().copied());
                        merged
                            .covered_lines
                            .extend(coverage.covered_lines.iter().copied());
                    }
                }
                merged.dirty = true;
                merged.normalize_in_place();
                Ok(Some(Cow::Owned(merged)))
            }
        }
    }

    fn coverage_for<'a>(&'a self, key: &str) -> Option<Cow<'a, FileCoverage>> {
        if self.normalized_ready {
            return self.files.get(key).map(Cow::Borrowed);
        }
        self.files
            .get(key)
            .map(|coverage| Cow::Owned(coverage.clone().normalized()))
    }
}

impl CoverageSink for CoverageStore {
    fn on_file(&mut self, file_path: &str) {
        self.normalized_ready = false;
        self.files.entry(file_path.to_string()).or_default();
    }

    fn on_line(&mut self, file_path: &str, line: u32, hits: u32) {
        self.normalized_ready = false;
        let entry = self.files.entry(file_path.to_string()).or_default();
        entry.record_line(line, hits);
    }
}

impl FileCoverage {
    pub fn record_line(&mut self, line: u32, hits: u32) {
        self.measured_lines.push(line);
        if hits > 0 {
            self.covered_lines.push(line);
        }
        self.dirty = true;
    }

    pub fn is_measured(&self, line: u32) -> bool {
        if self.dirty {
            return self.measured_lines.iter().any(|value| *value == line);
        }
        self.measured_lines.binary_search(&line).is_ok()
    }

    pub fn is_covered(&self, line: u32) -> bool {
        if self.dirty {
            return self.covered_lines.iter().any(|value| *value == line);
        }
        self.covered_lines.binary_search(&line).is_ok()
    }

    pub fn normalized(mut self) -> Self {
        self.normalize_in_place();
        self
    }

    fn normalize_in_place(&mut self) {
        if !self.dirty {
            return;
        }
        sort_and_dedup(&mut self.measured_lines);
        sort_and_dedup(&mut self.covered_lines);
        self.dirty = false;
    }
}

fn sort_and_dedup(values: &mut Vec<u32>) {
    values.sort_unstable();
    values.dedup();
}

#[cfg(test)]
mod tests {
    use super::{CoverageStore, FileCoverage};
    use crate::coverage::CoverageSink;

    #[test]
    fn merges_ambiguous_path_matches() {
        let mut store = CoverageStore::default();
        store.on_line("src/foo.rs", 12, 0);
        store.on_line("src/foo.rs", 10, 2);
        store.on_line("lib/foo.rs", 14, 1);
        store.on_line("lib/foo.rs", 12, 1);

        let merged = store
            .file_coverage("foo.rs")
            .expect("lookup")
            .expect("merged coverage");
        assert!(merged.is_measured(10));
        assert!(merged.is_measured(12));
        assert!(merged.is_measured(14));
        assert!(merged.is_covered(10));
        assert!(merged.is_covered(12));
        assert!(merged.is_covered(14));
    }

    #[test]
    fn prefers_exact_match_before_suffix_merge() {
        let mut store = CoverageStore::default();
        store.on_line("src/foo.rs", 12, 0);
        store.on_line("src/foo.rs", 10, 1);
        store.on_line("lib/foo.rs", 12, 1);

        let exact = store
            .file_coverage("src/foo.rs")
            .expect("lookup")
            .expect("exact coverage");
        assert_eq!(exact.measured_lines, vec![10, 12]);
        assert_eq!(exact.covered_lines, vec![10]);
    }

    #[test]
    fn normalize_sorts_and_dedups_lines() {
        let mut coverage = FileCoverage::default();
        coverage.record_line(4, 1);
        coverage.record_line(2, 1);
        coverage.record_line(4, 1);
        coverage.record_line(3, 0);

        let normalized = coverage.normalized();
        assert_eq!(normalized.measured_lines, vec![2, 3, 4]);
        assert_eq!(normalized.covered_lines, vec![2, 4]);
    }
}