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};
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),
};
#[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(),
}
}
#[must_use]
pub fn scan(&self, root: &Path) -> CoverageReport {
if !self.enabled {
return CoverageReport::default();
}
for rel in &self.lcov_paths {
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 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<PathBuf>,
pub entries: Vec<CoverageEntry>,
}
impl CoverageReport {
#[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)
}
#[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
}
#[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 {}
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,
);
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()
.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;
assert_eq!(FALLBACK_CALIBRATION.classify(100.0), Severity::Critical);
assert_eq!(FALLBACK_CALIBRATION.classify(20.0), Severity::Ok);
assert_eq!(FALLBACK_CALIBRATION.classify(70.0), Severity::High);
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());
}
}