cargo-crap 0.2.0

Change Risk Anti-Patterns (CRAP) metric for Rust projects
Documentation
//! Parse LCOV coverage reports into a per-file, per-line hit map.
//!
//! LCOV is the common output format for `cargo llvm-cov --lcov` and
//! `cargo tarpaulin --out Lcov`. A minimal record looks like:
//!
//! ```text
//! SF:src/foo.rs          ← source file
//! FN:42,foo::bar         ← function at line 42
//! FNDA:3,foo::bar        ← function hit count
//! DA:43,7                ← line 43 was executed 7 times
//! DA:44,0                ← line 44 was reachable but never executed
//! end_of_record
//! ```
//!
//! We only consume `SF`, `DA`, and `end_of_record`. Function-level records
//! (`FN`/`FNDA`) are tempting but unreliable: they tell us where a function
//! *starts* but not where it *ends*, so we can't compute coverage of the
//! function's body from them. Instead, we intersect the line-level `DA`
//! records with spans we already have from the AST.

use anyhow::{Context, Result};
use lcov::{Reader, Record};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};

/// Per-file coverage, indexed by line number.
///
/// Only lines that appear in a `DA` record are tracked — these are the
/// "executable" lines per LLVM's coverage mapping. Blank lines, comments,
/// and purely declarative lines (use statements, struct definitions) do
/// not appear here, and we treat them as "not applicable" rather than
/// "uncovered".
#[derive(Debug, Default, Clone)]
pub struct FileCoverage {
    /// Line number (1-indexed) → hit count.
    pub lines: BTreeMap<u32, u64>,
}

impl FileCoverage {
    /// Percentage of executable lines in `[start..=end]` that were hit at
    /// least once.
    ///
    /// Returns 100.0 if no executable lines fall inside the span. A function
    /// composed entirely of declarative code (`fn sig() -> Type;`, unreachable
    /// macro expansions, etc.) genuinely has nothing to cover and should not
    /// be penalized.
    #[must_use]
    pub fn coverage_in_span(
        &self,
        start: usize,
        end: usize,
    ) -> f64 {
        let start = start as u32;
        let end = end as u32;
        let executable: Vec<_> = self.lines.range(start..=end).collect();
        if executable.is_empty() {
            return 100.0;
        }
        let covered = executable.iter().filter(|(_, hits)| **hits > 0).count();
        (covered as f64 / executable.len() as f64) * 100.0
    }
}

/// Parse an LCOV file into a map keyed by the source paths it declares.
///
/// **Path normalization is deliberately NOT done here.** Paths in LCOV may
/// be absolute, relative to the CWD at the time coverage was generated, or
/// relative to the workspace root. The caller is responsible for matching
/// them against the paths [`crate::complexity`] produces — see
/// [`crate::merge`].
pub fn parse_lcov(path: &Path) -> Result<HashMap<PathBuf, FileCoverage>> {
    let reader =
        Reader::open_file(path).with_context(|| format!("opening LCOV file {}", path.display()))?;

    let mut files: HashMap<PathBuf, FileCoverage> = HashMap::new();
    let mut current_path: Option<PathBuf> = None;

    for record in reader {
        let record = record.with_context(|| format!("parsing record in {}", path.display()))?;
        match record {
            Record::SourceFile { path: sf_path } => {
                current_path = Some(sf_path.clone());
                files.entry(sf_path).or_default();
            },
            Record::LineData { line, count, .. } => {
                if let Some(ref p) = current_path
                    && let Some(fc) = files.get_mut(p)
                {
                    // LCOV files can legitimately repeat a line in
                    // different branches; sum the hits.
                    *fc.lines.entry(line).or_insert(0) += count;
                }
            },
            Record::EndOfRecord => {
                current_path = None;
            },
            _ => {},
        }
    }

    Ok(files)
}

#[cfg(test)]
#[expect(
    clippy::float_cmp,
    reason = "coverage % is computed from integer line counts; exact equality is the right comparison"
)]
mod tests {
    use super::*;
    use std::io::Write;
    use std::path::Path;

    fn write_lcov(content: &str) -> tempfile::NamedTempFile {
        let mut f = tempfile::NamedTempFile::new().expect("tempfile");
        f.write_all(content.as_bytes()).expect("write");
        f
    }

    // --- parse_lcov tests (kill missed mutants) ---

    #[test]
    fn parse_lcov_reads_correct_file_and_hit_counts() {
        // Kills: replace parse_lcov with dummy Ok(...), delete LineData arm, += with *=
        let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,3\nDA:11,0\nend_of_record\n");
        let result = parse_lcov(f.path()).expect("parse_lcov");

        let cov = result
            .get(Path::new("src/foo.rs"))
            .expect("src/foo.rs must be in result");
        assert_eq!(cov.lines[&10], 3, "line 10 should have 3 hits");
        assert_eq!(cov.lines[&11], 0, "line 11 should have 0 hits");
    }

    #[test]
    fn parse_lcov_accumulates_duplicate_line_entries() {
        // LCOV can repeat a DA line for different branches on the same line.
        // Hits must be summed, not overwritten. Kills: += with *=.
        let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,2\nDA:10,3\nend_of_record\n");
        let result = parse_lcov(f.path()).expect("parse_lcov");
        assert_eq!(
            result[Path::new("src/foo.rs")].lines[&10],
            5,
            "duplicate DA entries must be summed"
        );
    }

    #[test]
    fn parse_lcov_isolates_multiple_source_files() {
        // Kills: delete EndOfRecord arm (if current_path leaks, lines can bleed).
        // Lines from src/a.rs must never appear under src/b.rs.
        let f = write_lcov(
            "TN:\nSF:src/a.rs\nDA:1,1\nend_of_record\nSF:src/b.rs\nDA:2,4\nend_of_record\n",
        );
        let result = parse_lcov(f.path()).expect("parse_lcov");

        let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
        let b = result.get(Path::new("src/b.rs")).expect("b.rs in result");

        assert_eq!(a.lines[&1], 1);
        assert_eq!(b.lines[&2], 4);
        assert!(
            !b.lines.contains_key(&1),
            "line 1 from a.rs must not bleed into b.rs"
        );
        assert!(
            !a.lines.contains_key(&2),
            "line 2 from b.rs must not bleed into a.rs"
        );
    }

    #[test]
    fn stray_da_after_end_of_record_is_not_attributed_to_previous_file() {
        // Kills: delete match arm Record::EndOfRecord in parse_lcov.
        // If EndOfRecord does not clear current_path, a stray DA line between
        // records would be attributed to the previous file.
        let f = write_lcov(concat!(
            "TN:\n",
            "SF:src/a.rs\n",
            "DA:1,1\n",
            "end_of_record\n",
            "DA:99,99\n", // stray line — must be silently dropped
            "SF:src/b.rs\n",
            "DA:2,4\n",
            "end_of_record\n",
        ));
        let result = parse_lcov(f.path()).expect("parse_lcov");

        let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
        assert!(
            !a.lines.contains_key(&99),
            "stray DA:99,99 must not bleed into src/a.rs (EndOfRecord not resetting current_path)"
        );
    }

    fn fc_from(lines: &[(u32, u64)]) -> FileCoverage {
        FileCoverage {
            lines: lines.iter().copied().collect(),
        }
    }

    #[test]
    fn empty_span_yields_full_coverage() {
        // If the AST says "function is at lines 10..=20" but LCOV has no
        // executable lines in that range, it's a declarative function —
        // not 0% covered, it's "nothing to cover".
        let fc = fc_from(&[(5, 1), (25, 1)]);
        assert_eq!(fc.coverage_in_span(10, 20), 100.0);
    }

    #[test]
    fn all_executable_lines_hit_is_100_percent() {
        let fc = fc_from(&[(10, 3), (11, 3), (12, 1)]);
        assert_eq!(fc.coverage_in_span(10, 12), 100.0);
    }

    #[test]
    fn half_hit_is_50_percent() {
        let fc = fc_from(&[(10, 5), (11, 0), (12, 1), (13, 0)]);
        assert_eq!(fc.coverage_in_span(10, 13), 50.0);
    }

    #[test]
    fn span_is_inclusive_on_both_ends() {
        let fc = fc_from(&[(5, 1), (10, 1), (15, 1)]);
        // Only line 10 is inside [10..=10].
        assert_eq!(fc.coverage_in_span(10, 10), 100.0);
    }
}