heal-cli 0.4.0

Hook-driven Evaluation & Autonomous Loop — code-health harness CLI for AI coding agents
Documentation
//! Coverage observer (`[features.test.coverage]`).
//!
//! Probes [`TestCoverageConfig::lcov_paths`] in order, picks the first
//! existing lcov file, parses it, and emits one Finding per source
//! file with line coverage below 100 %. The Severity is classified
//! against `[calibration.coverage_pct]`; the calibration values are
//! stored as **inverted** coverage (`100 - coverage_pct`) so the
//! existing "value >= p95 → Critical" cascade in
//! [`MetricCalibration::classify`] continues to mean "worst →
//! Critical" without bespoke logic.
//!
//! HEAL never executes tests. The lcov.info file is the user's
//! contract — generated by `cargo llvm-cov`, `pytest --cov`, `nyc`, or
//! `scoverage` (or any other reporter that writes lcov).

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::core::calibration::MetricCalibration;
use crate::core::config::Config;
use crate::core::finding::{Finding, IntoFindings, Location};
use crate::feature::{decorate, Family, Feature, FeatureKind, FeatureMeta, HotspotIndex};
use crate::observer::test::lcov::{normalise_lcov_path, LcovReport};

/// Fallback calibration when `[calibration.coverage_pct]` hasn't been
/// populated (fresh project, or `heal calibrate` not run since the
/// feature was enabled). Values are inverted coverage (`100 -
/// coverage_pct`): `floor_critical = 95` ↔ ≤ 5 % coverage Critical;
/// `floor_ok = 25` ↔ > 75 % coverage graduates to Ok. Spread between
/// `p50` and `p95` is wider than `(floor_critical - floor_ok) / 2 =
/// 35` so [`MetricCalibration::classify`]'s spread gate stays open.
const FALLBACK_CALIBRATION: MetricCalibration = MetricCalibration {
    p50: 30.0,
    p75: 50.0,
    p90: 70.0,
    p95: 85.0,
    floor_critical: Some(95.0),
    floor_ok: Some(25.0),
};

/// Stateless observer. Construction reads the relevant config switches
/// and the resolved lcov-path candidate list; the `scan` invocation
/// does the I/O.
#[derive(Debug, Clone, Default)]
pub struct CoverageObserver {
    pub enabled: bool,
    pub lcov_paths: Vec<String>,
}

impl CoverageObserver {
    #[must_use]
    pub fn from_config(cfg: &Config) -> Self {
        Self {
            enabled: cfg.features.test.enabled && cfg.features.test.coverage.enabled,
            lcov_paths: cfg.features.test.coverage.lcov_paths.clone(),
        }
    }

    /// Walk [`Self::lcov_paths`] in order, parse the first that reads,
    /// and return the per-file coverage entries. Missing files are
    /// silent — projects that haven't wired up a reporter yet are
    /// expected to stay empty until they do.
    #[must_use]
    pub fn scan(&self, root: &Path) -> CoverageReport {
        if !self.enabled {
            return CoverageReport::default();
        }
        for rel in &self.lcov_paths {
            // Skip the explicit `exists()` precheck — `LcovReport::read`
            // returns `Err(NotFound)` for missing files, which we treat
            // identically to "no record" without paying a stat() per
            // candidate.
            let Ok(parsed) = LcovReport::read(&root.join(rel)) else {
                continue;
            };
            let entries = parsed
                .files
                .into_iter()
                .map(|f| {
                    let normalised = normalise_lcov_path(root, &f.path);
                    let pct = f.line_coverage_pct();
                    CoverageEntry {
                        path: normalised,
                        lines_found: f.lines_found,
                        lines_hit: f.lines_hit,
                        branches_found: f.branches_found,
                        branches_hit: f.branches_hit,
                        line_coverage_pct: pct,
                    }
                })
                .collect();
            return CoverageReport {
                source: Some(PathBuf::from(rel)),
                entries,
            };
        }
        CoverageReport::default()
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct CoverageReport {
    /// Project-relative path of the lcov file the observer read, or
    /// `None` when no reporter output was found. Surfaced so the post-
    /// commit nudge and `heal metrics` can name the source the user
    /// configured.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<PathBuf>,
    pub entries: Vec<CoverageEntry>,
}

impl CoverageReport {
    /// Look up coverage for `path`. `None` when the lcov file didn't
    /// mention it.
    #[must_use]
    pub fn ratio_for(&self, path: &Path) -> Option<f64> {
        self.entries
            .iter()
            .find(|e| e.path == path)
            .map(|e| e.line_coverage_pct / 100.0)
    }

    /// Top-N least-covered files (ascending by `line_coverage_pct`).
    /// Fully-covered files are excluded — they aren't actionable.
    #[must_use]
    pub fn worst_n(&self, n: usize) -> Vec<&CoverageEntry> {
        let mut top: Vec<&CoverageEntry> = self
            .entries
            .iter()
            .filter(|e| e.line_coverage_pct < 100.0)
            .collect();
        top.sort_by(|a, b| {
            a.line_coverage_pct
                .partial_cmp(&b.line_coverage_pct)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        top.truncate(n);
        top
    }

    /// Number of files with sub-100% coverage.
    #[must_use]
    pub fn uncovered_count(&self) -> usize {
        self.entries
            .iter()
            .filter(|e| e.line_coverage_pct < 100.0)
            .count()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CoverageEntry {
    pub path: PathBuf,
    pub lines_found: u32,
    pub lines_hit: u32,
    pub branches_found: u32,
    pub branches_hit: u32,
    pub line_coverage_pct: f64,
}

impl Eq for CoverageEntry {}

/// Build the Finding for one uncovered entry. Shared between the
/// observer's `IntoFindings` projection and `CoverageFeature::lower`
/// so the summary / seed / location triple stays in one place.
fn entry_finding(entry: &CoverageEntry) -> Finding {
    let primary = Location::file(entry.path.clone());
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    let pct_int = entry.line_coverage_pct.round() as u32;
    let summary = format!(
        "Coverage={pct_int}% ({}/{} lines)",
        entry.lines_hit, entry.lines_found,
    );
    // Seed on the integer percentage so reformatting that doesn't
    // change the rounded value keeps the id stable (invariants R4 —
    // structural seeds beat byte seeds).
    let seed = format!("coverage_pct:{pct_int}");
    Finding::new(Finding::METRIC_COVERAGE_PCT, primary, summary, &seed)
}

impl IntoFindings for CoverageReport {
    fn into_findings(&self) -> Vec<Finding> {
        self.entries
            .iter()
            // Fully-covered files aren't signal — emitting them would
            // dwarf the actual gaps. `ratio_for` still surfaces them
            // for downstream consumers that need the raw ratio.
            .filter(|e| e.line_coverage_pct < 100.0)
            .map(entry_finding)
            .collect()
    }
}

pub struct CoverageFeature;

impl Feature for CoverageFeature {
    fn meta(&self) -> FeatureMeta {
        FeatureMeta {
            name: "coverage_pct",
            version: 1,
            kind: FeatureKind::CoverageReader,
        }
    }
    fn enabled(&self, cfg: &Config) -> bool {
        cfg.features.test.enabled && cfg.features.test.coverage.enabled
    }
    fn family(&self) -> Family {
        Family::Test
    }
    fn lower(
        &self,
        reports: &crate::observers::ObserverReports,
        _cfg: &Config,
        cal: &crate::core::calibration::Calibration,
        hotspot: &HotspotIndex,
    ) -> Vec<Finding> {
        let Some(report) = reports.coverage.as_ref() else {
            return Vec::new();
        };
        let calibration = cal
            .calibration
            .coverage_pct
            .as_ref()
            .unwrap_or(&FALLBACK_CALIBRATION);
        report
            .entries
            .iter()
            .filter(|e| e.line_coverage_pct < 100.0)
            .map(|entry| {
                let inverted = 100.0 - entry.line_coverage_pct;
                decorate(
                    entry_finding(entry),
                    calibration.classify(inverted),
                    hotspot,
                )
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::config::TestCoverageConfig;
    use std::fs;
    use tempfile::TempDir;

    fn fixture(tmp: &TempDir, lcov_body: &str) {
        fs::write(tmp.path().join("lcov.info"), lcov_body).unwrap();
    }

    fn cfg_enabled() -> Config {
        let mut cfg = Config::default();
        cfg.features.test.enabled = true;
        cfg.features.test.coverage = TestCoverageConfig {
            enabled: true,
            lcov_paths: vec!["lcov.info".to_owned()],
            post_commit_refresh: None,
        };
        cfg
    }

    #[test]
    fn disabled_observer_emits_empty_report() {
        let tmp = TempDir::new().unwrap();
        fixture(&tmp, "SF:src/lib.rs\nLF:1\nLH:0\nend_of_record\n");
        let cfg = Config::default();
        let report = CoverageObserver::from_config(&cfg).scan(tmp.path());
        assert!(report.entries.is_empty());
        assert!(report.source.is_none());
    }

    #[test]
    fn parses_lcov_when_enabled() {
        let tmp = TempDir::new().unwrap();
        fixture(
            &tmp,
            "SF:src/lib.rs\nDA:1,0\nDA:2,0\nDA:3,0\nLF:3\nLH:0\nend_of_record\n",
        );
        let cfg = cfg_enabled();
        let report = CoverageObserver::from_config(&cfg).scan(tmp.path());
        assert_eq!(report.entries.len(), 1);
        assert_eq!(report.entries[0].lines_hit, 0);
        assert!((report.entries[0].line_coverage_pct - 0.0).abs() < 1e-9);
        assert_eq!(report.source.as_deref(), Some(Path::new("lcov.info")));
    }

    #[test]
    fn emits_findings_only_for_uncovered_files() {
        let tmp = TempDir::new().unwrap();
        fixture(
            &tmp,
            "\
SF:src/full.rs
LF:5
LH:5
end_of_record
SF:src/half.rs
LF:4
LH:2
end_of_record
",
        );
        let cfg = cfg_enabled();
        let report = CoverageObserver::from_config(&cfg).scan(tmp.path());
        let findings = report.into_findings();
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].location.file, PathBuf::from("src/half.rs"),);
        assert!(findings[0].summary.starts_with("Coverage=50%"));
    }

    #[test]
    fn fallback_calibration_anchors_at_literature_defaults() {
        use crate::core::severity::Severity;
        // Coverage 0 % → inverted 100 → Critical via floor_critical.
        assert_eq!(FALLBACK_CALIBRATION.classify(100.0), Severity::Critical);
        // Coverage 80 % → inverted 20 → Ok via floor_ok.
        assert_eq!(FALLBACK_CALIBRATION.classify(20.0), Severity::Ok);
        // Coverage 30 % → inverted 70 → High via p90.
        assert_eq!(FALLBACK_CALIBRATION.classify(70.0), Severity::High);
        // Coverage 45 % → inverted 55 → Medium via p75.
        assert_eq!(FALLBACK_CALIBRATION.classify(55.0), Severity::Medium);
    }

    #[test]
    fn ratio_for_returns_none_for_unknown_path() {
        let report = CoverageReport {
            source: None,
            entries: vec![CoverageEntry {
                path: PathBuf::from("src/lib.rs"),
                lines_found: 10,
                lines_hit: 7,
                branches_found: 0,
                branches_hit: 0,
                line_coverage_pct: 70.0,
            }],
        };
        assert!((report.ratio_for(Path::new("src/lib.rs")).unwrap() - 0.7).abs() < 1e-9);
        assert!(report.ratio_for(Path::new("src/other.rs")).is_none());
    }
}