use super::types::{CoverageConfig, CoverageReport};
use super::utils::{calculate_branch_coverage, parse_tarpaulin_json, process_file_coverage};
use crate::{Error, Result};
use std::path::Path;
pub struct CoverageAnalyzer {
config: CoverageConfig,
}
impl CoverageAnalyzer {
pub fn new() -> Self {
Self {
config: CoverageConfig::default(),
}
}
pub fn with_config(config: CoverageConfig) -> Self {
Self { config }
}
pub async fn check_tarpaulin_installed(&self) -> Result<bool> {
let output = tokio::process::Command::new("cargo")
.args(["tarpaulin", "--version"])
.output()
.await;
match output {
Ok(output) => Ok(output.status.success()),
Err(_) => Ok(false),
}
}
pub async fn install_tarpaulin(&self) -> Result<()> {
if self.check_tarpaulin_installed().await? {
tracing::info!("cargo-tarpaulin already installed");
return Ok(());
}
tracing::info!("Installing cargo-tarpaulin...");
let output = tokio::process::Command::new("cargo")
.args(["install", "cargo-tarpaulin"])
.output()
.await
.map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::process(format!(
"Failed to install cargo-tarpaulin: {}",
stderr
)));
}
tracing::info!("cargo-tarpaulin installed successfully");
Ok(())
}
pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
if !self.check_tarpaulin_installed().await? {
return Err(Error::validation(
"cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
));
}
tracing::info!("Running test coverage analysis...");
let report_dir = project_path.join("target").join("tarpaulin");
tokio::fs::create_dir_all(&report_dir)
.await
.map_err(|e| Error::process(format!("Failed to create tarpaulin output dir: {e}")))?;
let mut args = vec![
"tarpaulin".to_string(),
"--timeout".to_string(),
"120".to_string(),
"--skip-clean".to_string(),
"--out".to_string(),
"Json".to_string(),
"--output-dir".to_string(),
report_dir.display().to_string(),
];
for exclude_file in &self.config.exclude_files {
args.push("--exclude-files".to_string());
args.push(exclude_file.clone());
}
for exclude_dir in &self.config.exclude_dirs {
args.push("--exclude-files".to_string());
args.push(exclude_dir.clone());
}
let output = tokio::process::Command::new("cargo")
.args(&args)
.current_dir(project_path)
.output()
.await
.map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {e}")))?;
let exit_code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
let report_file = report_dir.join("tarpaulin-report.json");
let json_content = match tokio::fs::read_to_string(&report_file).await {
Ok(content) => content,
Err(read_err) => {
return Err(Error::process(format!(
"Tarpaulin produced no JSON report (exit code {exit_code}). \
File error: {read_err}. Tarpaulin stderr: {stderr}"
)));
}
};
match self.parse_tarpaulin_output(&json_content) {
Ok(report) => {
if !output.status.success() {
tracing::warn!(
"Tarpaulin exited with code {exit_code} but produced a valid report. \
stderr: {stderr}"
);
}
Ok(report)
}
Err(parse_err) => Err(Error::process(format!(
"Failed to parse tarpaulin JSON (exit code {exit_code}): {parse_err}. \
Tarpaulin stderr: {stderr}"
))),
}
}
fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
let tarpaulin_data = parse_tarpaulin_json(output)?;
let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
Ok(CoverageReport {
line_coverage: tarpaulin_data.line_coverage,
function_coverage: function_stats.coverage,
branch_coverage,
file_coverage,
lines_tested: tarpaulin_data.lines_covered,
total_lines: tarpaulin_data.lines_total,
functions_tested: function_stats.tested,
total_functions: function_stats.total,
branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
total_branches: tarpaulin_data.branches_total.unwrap_or(0),
})
}
pub fn config(&self) -> &CoverageConfig {
&self.config
}
pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
self.run_coverage(project_path).await
}
pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
self.parse_tarpaulin_output(tarpaulin_output)
}
pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
if report.line_coverage < threshold {
return Err(Error::validation(format!(
"Test coverage {:.1}% is below minimum threshold of {:.1}%",
report.line_coverage, threshold
)));
}
Ok(())
}
pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
let coverage = report.line_coverage;
let color = match coverage {
c if c >= 80.0 => "#4c1", c if c >= 60.0 => "#dfb317", c if c >= 40.0 => "#fe7d37", _ => "#e05d44", };
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
<linearGradient id="a" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<rect rx="3" width="114" height="20" fill="#555"/>
<rect rx="3" x="63" width="51" height="20" fill="{}"/>
<path fill="{}" d="M63 0h4v20h-4z"/>
<rect rx="3" width="114" height="20" fill="url(#a)"/>
<g fill="#fff" text-anchor="middle"
font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
<text x="32" y="14">coverage</text>
<text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
<text x="87" y="14">{:.1}%</text>
</g>
</svg>"##,
color, color, coverage, coverage
)
}
}
impl Default for CoverageAnalyzer {
fn default() -> Self {
Self::new()
}
}