#[derive(Clone)]
pub struct IncrementalCoverageFacade {
registry: Arc<ServiceRegistry>,
}
impl IncrementalCoverageFacade {
#[must_use]
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn new(registry: Arc<ServiceRegistry>) -> Self {
Self { registry }
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub async fn analyze_project(
&self,
request: IncrementalCoverageRequest,
) -> Result<IncrementalCoverageResult> {
if request.force_refresh {
let cache_path = request.cache_dir.clone().unwrap_or_else(|| {
std::env::temp_dir().join("pmat_coverage_cache")
});
crate::cli::cache_clearing::clear_cache_directory_reporting(
&cache_path,
"--force-refresh",
)?;
}
let changed_files = self
.get_changed_files(
&request.project_path,
&request.base_branch,
request.target_branch.as_deref(),
)
.await?;
let coverage_data = self
.analyze_coverage_changes(&request.project_path, &changed_files, &request)
.await?;
Ok(self.build_coverage_result(coverage_data, changed_files, &request))
}
async fn get_changed_files(
&self,
project_path: &Path,
base_branch: &str,
target_branch: Option<&str>,
) -> Result<Vec<(PathBuf, String)>> {
use crate::cli::coverage_helpers::get_changed_files_for_coverage;
get_changed_files_for_coverage(project_path, base_branch, target_branch).await
}
async fn analyze_coverage_changes(
&self,
project_path: &Path,
changed_files: &[(PathBuf, String)],
request: &IncrementalCoverageRequest,
) -> Result<Vec<ChangedFileCoverage>> {
let measured = Self::load_line_coverage(project_path);
if !request.changed_files_only {
Self::report_project_wide_coverage(measured.as_ref());
}
let mut coverage_data = Vec::new();
for (path, status) in changed_files {
if status != "M" && status != "A" {
continue;
}
coverage_data.push(Self::file_coverage(path, measured.as_ref()));
}
Ok(coverage_data)
}
pub(crate) fn report_project_wide_coverage(
measured: Option<
&std::collections::HashMap<String, std::collections::HashMap<usize, u64>>,
>,
) {
eprintln!("{}", Self::project_wide_coverage_line(measured));
}
pub(crate) fn project_wide_coverage_line(
measured: Option<
&std::collections::HashMap<String, std::collections::HashMap<usize, u64>>,
>,
) -> String {
let Some(files) = measured else {
return "๐ Project-wide coverage: not measured โ no coverage artifact found \
(run the project's coverage tool first); --changed-files-only skips this"
.to_string();
};
let (mut covered, mut total) = (0usize, 0usize);
for hits in files.values() {
total += hits.len();
covered += hits.values().filter(|count| **count > 0).count();
}
if total == 0 {
return format!(
"๐ Project-wide coverage: not measured โ the coverage artifact lists {} file(s) \
but no lines",
files.len()
);
}
#[allow(clippy::cast_precision_loss)]
let pct = (covered as f64 / total as f64) * 100.0;
format!(
"๐ Project-wide coverage: {pct:.1}% ({covered}/{total} lines across {} measured files)",
files.len()
)
}
fn load_line_coverage(
project_path: &Path,
) -> Option<std::collections::HashMap<String, std::collections::HashMap<usize, u64>>> {
crate::services::agent_context::query::discover_line_coverage(project_path)
}
fn file_coverage(
path: &Path,
measured: Option<
&std::collections::HashMap<String, std::collections::HashMap<usize, u64>>,
>,
) -> ChangedFileCoverage {
let key = path.to_string_lossy().to_string();
let lines = measured.and_then(|m| m.get(&key));
let (coverage_after, lines_covered, lines_total) = match lines {
Some(hits) if !hits.is_empty() => {
let total = hits.len();
let covered = hits.values().filter(|count| **count > 0).count();
#[allow(clippy::cast_precision_loss)]
let pct = (covered as f64 / total as f64) * 100.0;
(Some(pct), covered, total)
}
_ => (None, 0, 0),
};
ChangedFileCoverage {
file_path: key,
coverage_before: None,
coverage_after,
coverage_delta: None,
status: CoverageStatus::NotMeasured,
lines_covered,
lines_total,
}
}
fn build_coverage_result(
&self,
coverage_data: Vec<ChangedFileCoverage>,
changed_files: Vec<(PathBuf, String)>,
request: &IncrementalCoverageRequest,
) -> IncrementalCoverageResult {
let total_files = changed_files.len();
let measured: Vec<f64> = coverage_data
.iter()
.filter_map(|f| f.coverage_after)
.collect();
let covered_files = measured.iter().filter(|pct| **pct > 0.0).count();
let files_not_measured = total_files.saturating_sub(measured.len());
#[allow(clippy::cast_precision_loss)]
let coverage_percentage = if measured.is_empty() {
None
} else {
Some(measured.iter().sum::<f64>() / measured.len() as f64)
};
let files_above_threshold = measured
.iter()
.filter(|pct| **pct >= request.coverage_threshold)
.count();
let files_below_threshold = measured.len() - files_above_threshold;
let coverage_text = coverage_percentage
.map_or_else(|| "not measured".to_string(), |pct| format!("{pct:.1}%"));
let summary = format!(
"Analyzed {} changed files: {} covered (mean {}), {} above threshold ({:.1}%), \
{} below threshold, {} not measured",
total_files,
covered_files,
coverage_text,
files_above_threshold,
request.coverage_threshold,
files_below_threshold,
files_not_measured
);
let displayed = if request.top_files == 0 {
coverage_data
} else {
coverage_data.into_iter().take(request.top_files).collect()
};
IncrementalCoverageResult {
total_files,
covered_files,
coverage_percentage,
files_above_threshold,
files_below_threshold,
files_not_measured,
changed_files: displayed,
summary,
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn quick_analysis(
&self,
project_path: PathBuf,
base_branch: String,
) -> Result<IncrementalCoverageResult> {
let request = IncrementalCoverageRequest {
project_path,
base_branch,
target_branch: None,
coverage_threshold: 80.0,
changed_files_only: true,
detailed: false,
cache_dir: None,
force_refresh: false,
top_files: 10,
};
self.analyze_project(request).await
}
}
#[cfg(test)]
mod changed_files_only_tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_project_wide_line_states_absence_instead_of_zero() {
let line = IncrementalCoverageFacade::project_wide_coverage_line(None);
assert!(
line.contains("not measured"),
"no artifact must be reported as absent, not as 0%: {line}"
);
assert!(
!line.contains("0.0%"),
"an absent measurement must never render as a measured zero: {line}"
);
}
#[test]
fn test_project_wide_line_reports_measured_lines() {
let mut files: HashMap<String, HashMap<usize, u64>> = HashMap::new();
files.insert("src/a.rs".to_string(), HashMap::from([(1, 1), (2, 0)]));
files.insert("src/b.rs".to_string(), HashMap::from([(1, 3), (2, 4)]));
let line = IncrementalCoverageFacade::project_wide_coverage_line(Some(&files));
assert!(line.contains("75.0%"), "3 of 4 lines are covered: {line}");
assert!(line.contains("2 measured files"), "{line}");
}
#[test]
fn test_project_wide_line_distinguishes_empty_artifact() {
let files: HashMap<String, HashMap<usize, u64>> =
HashMap::from([("src/a.rs".to_string(), HashMap::new())]);
let line = IncrementalCoverageFacade::project_wide_coverage_line(Some(&files));
assert!(
line.contains("not measured") && line.contains("no lines"),
"an artifact with no lines is not 0% coverage: {line}"
);
}
}
#[cfg(test)]
mod top_files_accounting_tests {
use super::*;
use crate::services::service_registry::ServiceRegistry;
fn request(top_files: usize) -> IncrementalCoverageRequest {
IncrementalCoverageRequest {
project_path: PathBuf::from("."),
base_branch: "master".to_string(),
target_branch: None,
coverage_threshold: 80.0,
changed_files_only: true,
detailed: false,
cache_dir: None,
force_refresh: false,
top_files,
}
}
#[test]
fn test_top_files_truncates_the_display_list_not_the_counts() {
let facade = IncrementalCoverageFacade::new(Arc::new(ServiceRegistry::new()));
let changed: Vec<(PathBuf, String)> = (0..25)
.map(|i| (PathBuf::from(format!("src/f{i}.rs")), "M".to_string()))
.collect();
let coverage_data: Vec<ChangedFileCoverage> = changed
.iter()
.map(|(path, _)| IncrementalCoverageFacade::file_coverage(path, None))
.collect();
let result = facade.build_coverage_result(coverage_data, changed, &request(3));
assert_eq!(result.total_files, 25);
assert_eq!(
result.changed_files.len(),
3,
"--top-files 3 must cap the displayed list"
);
assert_eq!(
result.files_not_measured, 25,
"no coverage artifact โ all 25 changed files are unmeasured, not --top-files of them"
);
assert_eq!(
result.files_above_threshold + result.files_below_threshold + result.files_not_measured,
result.total_files,
"the summary must reconcile with total_files"
);
}
}