use crate::error::{Result, SklearsError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug)]
pub struct CoverageCollector {
config: CoverageConfig,
collected_data: Option<RawCoverageData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageConfig {
pub minimum_coverage: f64,
pub module_thresholds: HashMap<String, f64>,
pub output_formats: Vec<String>,
pub exclude_patterns: Vec<String>,
pub include_patterns: Vec<String>,
pub output_directory: PathBuf,
pub coverage_tool: CoverageTool,
pub fail_on_regression: bool,
pub baseline_coverage: Option<f64>,
}
impl Default for CoverageConfig {
fn default() -> Self {
Self {
minimum_coverage: 80.0,
module_thresholds: HashMap::new(),
output_formats: vec!["html".to_string(), "json".to_string()],
exclude_patterns: vec![
"tests/*".to_string(),
"benches/*".to_string(),
"examples/*".to_string(),
],
include_patterns: Vec::new(),
output_directory: PathBuf::from("target/coverage"),
coverage_tool: CoverageTool::LlvmCov,
fail_on_regression: true,
baseline_coverage: None,
}
}
}
impl CoverageConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_minimum_coverage(mut self, threshold: f64) -> Self {
self.minimum_coverage = threshold;
self
}
pub fn with_output_format(mut self, formats: Vec<&str>) -> Self {
self.output_formats = formats.into_iter().map(String::from).collect();
self
}
pub fn with_exclude_patterns(mut self, patterns: Vec<&str>) -> Self {
self.exclude_patterns = patterns.into_iter().map(String::from).collect();
self
}
pub fn with_module_threshold(mut self, module: &str, threshold: f64) -> Self {
self.module_thresholds.insert(module.to_string(), threshold);
self
}
pub fn with_baseline_coverage(mut self, baseline: f64) -> Self {
self.baseline_coverage = Some(baseline);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CoverageTool {
LlvmCov,
Tarpaulin,
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawCoverageData {
pub tool: String,
pub timestamp: u64,
pub files: Vec<FileCoverage>,
pub summary: CoverageSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileCoverage {
pub path: String,
pub functions: Vec<FunctionCoverage>,
pub lines: Vec<LineCoverage>,
pub branches: Vec<BranchCoverage>,
pub summary: CoverageSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCoverage {
pub name: String,
pub line_start: u32,
pub line_end: u32,
pub execution_count: u64,
pub covered: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineCoverage {
pub line_number: u32,
pub execution_count: u64,
pub covered: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchCoverage {
pub line_number: u32,
pub branch_id: u32,
pub taken_count: u64,
pub total_count: u64,
pub covered: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageSummary {
pub lines_covered: u32,
pub lines_total: u32,
pub functions_covered: u32,
pub functions_total: u32,
pub branches_covered: u32,
pub branches_total: u32,
}
impl CoverageSummary {
pub fn line_coverage(&self) -> f64 {
if self.lines_total == 0 {
100.0
} else {
(self.lines_covered as f64 / self.lines_total as f64) * 100.0
}
}
pub fn function_coverage(&self) -> f64 {
if self.functions_total == 0 {
100.0
} else {
(self.functions_covered as f64 / self.functions_total as f64) * 100.0
}
}
pub fn branch_coverage(&self) -> f64 {
if self.branches_total == 0 {
100.0
} else {
(self.branches_covered as f64 / self.branches_total as f64) * 100.0
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoverageReport {
pub timestamp: u64,
pub config: CoverageConfig,
pub overall_summary: CoverageSummary,
pub module_summaries: HashMap<String, CoverageSummary>,
pub quality_gates: QualityGatesResult,
pub recommendations: Vec<CoverageRecommendation>,
pub trends: Option<CoverageTrends>,
}
impl CoverageReport {
pub fn overall_coverage(&self) -> f64 {
self.overall_summary.line_coverage()
}
pub fn meets_quality_gates(&self) -> bool {
self.quality_gates.passed
}
pub fn summary(&self) -> String {
format!(
"Coverage Report Summary:\n\
- Overall coverage: {:.1}%\n\
- Lines covered: {}/{}\n\
- Functions covered: {}/{}\n\
- Branches covered: {}/{}\n\
- Quality gates: {}\n\
- Recommendations: {}",
self.overall_coverage(),
self.overall_summary.lines_covered,
self.overall_summary.lines_total,
self.overall_summary.functions_covered,
self.overall_summary.functions_total,
self.overall_summary.branches_covered,
self.overall_summary.branches_total,
if self.quality_gates.passed {
"PASSED"
} else {
"FAILED"
},
self.recommendations.len()
)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QualityGatesResult {
pub passed: bool,
pub failures: Vec<QualityGateFailure>,
pub warnings: Vec<QualityGateWarning>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QualityGateFailure {
pub rule: String,
pub expected: f64,
pub actual: f64,
pub module: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QualityGateWarning {
pub message: String,
pub severity: WarningSeverity,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum WarningSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoverageRecommendation {
pub priority: RecommendationPriority,
pub category: RecommendationCategory,
pub description: String,
pub affected_files: Vec<String>,
pub estimated_impact: f64, }
#[derive(Debug, Serialize, Deserialize, PartialOrd, Ord, PartialEq, Eq)]
pub enum RecommendationPriority {
Critical,
High,
Medium,
Low,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum RecommendationCategory {
UncoveredCriticalCode,
MissingBranchTests,
UncoveredErrorPaths,
LowFunctionCoverage,
TestGaps,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoverageTrends {
pub historical_data: Vec<HistoricalCoveragePoint>,
pub trend_direction: TrendDirection,
pub trend_strength: f64, }
#[derive(Debug, Serialize, Deserialize)]
pub struct HistoricalCoveragePoint {
pub timestamp: u64,
pub coverage: f64,
pub commit_hash: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum TrendDirection {
Improving,
Stable,
Declining,
}
impl CoverageCollector {
pub fn new(config: CoverageConfig) -> Self {
Self {
config,
collected_data: None,
}
}
pub fn collect_and_analyze(&mut self) -> Result<CoverageReport> {
self.collect_coverage_data()?;
let report = self.analyze_coverage()?;
self.generate_outputs(&report)?;
Ok(report)
}
fn collect_coverage_data(&mut self) -> Result<()> {
let raw_data = match self.config.coverage_tool {
CoverageTool::LlvmCov => self.collect_llvm_cov_data()?,
CoverageTool::Tarpaulin => self.collect_tarpaulin_data()?,
CoverageTool::Manual => self.collect_manual_data()?,
};
self.collected_data = Some(raw_data);
Ok(())
}
fn collect_llvm_cov_data(&self) -> Result<RawCoverageData> {
let output = Command::new("cargo")
.args([
"llvm-cov",
"--json",
"--output-path",
&format!("{}/llvm-cov.json", self.config.output_directory.display()),
])
.output()
.map_err(|e| SklearsError::InvalidOperation(format!("Failed to run llvm-cov: {e}")))?;
if !output.status.success() {
return Err(SklearsError::InvalidOperation(format!(
"llvm-cov failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(self.simulate_coverage_data("llvm-cov"))
}
fn collect_tarpaulin_data(&self) -> Result<RawCoverageData> {
let output = Command::new("cargo")
.args([
"tarpaulin",
"--out",
"Json",
"--output-dir",
&self.config.output_directory.to_string_lossy(),
])
.output()
.map_err(|e| SklearsError::InvalidOperation(format!("Failed to run tarpaulin: {e}")))?;
if !output.status.success() {
return Err(SklearsError::InvalidOperation(format!(
"tarpaulin failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(self.simulate_coverage_data("tarpaulin"))
}
fn collect_manual_data(&self) -> Result<RawCoverageData> {
Ok(self.simulate_coverage_data("manual"))
}
fn simulate_coverage_data(&self, tool: &str) -> RawCoverageData {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("expected valid value")
.as_secs();
RawCoverageData {
tool: tool.to_string(),
timestamp,
files: vec![FileCoverage {
path: "src/lib.rs".to_string(),
functions: vec![FunctionCoverage {
name: "example_function".to_string(),
line_start: 10,
line_end: 20,
execution_count: 5,
covered: true,
}],
lines: vec![
LineCoverage {
line_number: 15,
execution_count: 5,
covered: true,
},
LineCoverage {
line_number: 16,
execution_count: 0,
covered: false,
},
],
branches: vec![BranchCoverage {
line_number: 17,
branch_id: 1,
taken_count: 3,
total_count: 5,
covered: true,
}],
summary: CoverageSummary {
lines_covered: 45,
lines_total: 50,
functions_covered: 8,
functions_total: 10,
branches_covered: 12,
branches_total: 15,
},
}],
summary: CoverageSummary {
lines_covered: 850,
lines_total: 1000,
functions_covered: 75,
functions_total: 90,
branches_covered: 120,
branches_total: 150,
},
}
}
fn analyze_coverage(&self) -> Result<CoverageReport> {
let data = self.collected_data.as_ref().ok_or_else(|| {
SklearsError::InvalidOperation("No coverage data collected".to_string())
})?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("expected valid value")
.as_secs();
let quality_gates = self.evaluate_quality_gates(&data.summary);
let recommendations = self.generate_recommendations(data);
let mut module_summaries = HashMap::new();
for file in &data.files {
let module_name = self.extract_module_name(&file.path);
module_summaries.insert(module_name, file.summary.clone());
}
Ok(CoverageReport {
timestamp,
config: self.config.clone(),
overall_summary: data.summary.clone(),
module_summaries,
quality_gates,
recommendations,
trends: None, })
}
fn evaluate_quality_gates(&self, summary: &CoverageSummary) -> QualityGatesResult {
let mut failures = Vec::new();
let mut warnings = Vec::new();
let overall_coverage = summary.line_coverage();
if overall_coverage < self.config.minimum_coverage {
failures.push(QualityGateFailure {
rule: "Minimum overall coverage".to_string(),
expected: self.config.minimum_coverage,
actual: overall_coverage,
module: None,
});
}
if let Some(baseline) = self.config.baseline_coverage {
if overall_coverage < baseline - 1.0 {
failures.push(QualityGateFailure {
rule: "Coverage regression".to_string(),
expected: baseline,
actual: overall_coverage,
module: None,
});
}
}
let function_coverage = summary.function_coverage();
if function_coverage < 80.0 {
warnings.push(QualityGateWarning {
message: format!("Low function coverage: {function_coverage:.1}%"),
severity: WarningSeverity::Warning,
});
}
QualityGatesResult {
passed: failures.is_empty(),
failures,
warnings,
}
}
fn generate_recommendations(&self, data: &RawCoverageData) -> Vec<CoverageRecommendation> {
let mut recommendations = Vec::new();
for file in &data.files {
let uncovered_lines: Vec<_> = file.lines.iter().filter(|line| !line.covered).collect();
if !uncovered_lines.is_empty() {
recommendations.push(CoverageRecommendation {
priority: RecommendationPriority::Medium,
category: RecommendationCategory::TestGaps,
description: format!(
"Add tests for {} uncovered lines in {}",
uncovered_lines.len(),
file.path
),
affected_files: vec![file.path.clone()],
estimated_impact: (uncovered_lines.len() as f64 / file.lines.len() as f64)
* 100.0,
});
}
}
recommendations.sort_by(|a, b| {
a.priority.cmp(&b.priority).then_with(|| {
b.estimated_impact
.partial_cmp(&a.estimated_impact)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
recommendations
}
fn extract_module_name(&self, path: &str) -> String {
if let Some(pos) = path.rfind('/') {
if let Some(dot_pos) = path[pos..].find('.') {
return path[pos + 1..pos + dot_pos].to_string();
}
}
path.to_string()
}
fn generate_outputs(&self, report: &CoverageReport) -> Result<()> {
fs::create_dir_all(&self.config.output_directory).map_err(|e| {
SklearsError::InvalidOperation(format!("Failed to create output directory: {e}"))
})?;
for format in &self.config.output_formats {
match format.as_str() {
"json" => self.generate_json_output(report)?,
"html" => self.generate_html_output(report)?,
"xml" => self.generate_xml_output(report)?,
"text" => self.generate_text_output(report)?,
_ => {
eprintln!("Warning: Unknown output format '{format}'");
}
}
}
Ok(())
}
fn generate_json_output(&self, report: &CoverageReport) -> Result<()> {
let json = serde_json::to_string_pretty(report).map_err(|e| {
SklearsError::InvalidOperation(format!("Failed to serialize JSON: {e}"))
})?;
let path = self.config.output_directory.join("coverage.json");
fs::write(path, json).map_err(|e| {
SklearsError::InvalidOperation(format!("Failed to write JSON output: {e}"))
})
}
fn generate_html_output(&self, report: &CoverageReport) -> Result<()> {
let html = self.generate_html_content(report);
let path = self.config.output_directory.join("coverage.html");
fs::write(path, html).map_err(|e| {
SklearsError::InvalidOperation(format!("Failed to write HTML output: {e}"))
})
}
fn generate_xml_output(&self, report: &CoverageReport) -> Result<()> {
let xml = self.generate_xml_content(report);
let path = self.config.output_directory.join("coverage.xml");
fs::write(path, xml)
.map_err(|e| SklearsError::InvalidOperation(format!("Failed to write XML output: {e}")))
}
fn generate_text_output(&self, report: &CoverageReport) -> Result<()> {
let text = report.summary();
let path = self.config.output_directory.join("coverage.txt");
fs::write(path, text).map_err(|e| {
SklearsError::InvalidOperation(format!("Failed to write text output: {e}"))
})
}
fn generate_html_content(&self, report: &CoverageReport) -> String {
format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Code Coverage Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
.summary {{ background: #f5f5f5; padding: 20px; border-radius: 5px; }}
.metric {{ margin: 10px 0; }}
.pass {{ color: green; }}
.fail {{ color: red; }}
.warning {{ color: orange; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 20px; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
.coverage-bar {{
width: 100px;
height: 20px;
background: #ddd;
border-radius: 10px;
overflow: hidden;
}}
.coverage-fill {{
height: 100%;
background: linear-gradient(to right, #ff4444, #ffaa00, #44ff44);
}}
</style>
</head>
<body>
<h1>Code Coverage Report</h1>
<div class="summary">
<h2>Summary</h2>
<div class="metric">Overall Coverage: <strong>{:.1}%</strong></div>
<div class="metric">Lines: {}/{} ({:.1}%)</div>
<div class="metric">Functions: {}/{} ({:.1}%)</div>
<div class="metric">Branches: {}/{} ({:.1}%)</div>
<div class="metric">Quality Gates: <span class="{}">{}</span></div>
</div>
<h2>Quality Gates</h2>
{}
<h2>Recommendations</h2>
{}
<p><em>Generated at: {}</em></p>
</body>
</html>"#,
report.overall_coverage(),
report.overall_summary.lines_covered,
report.overall_summary.lines_total,
report.overall_summary.line_coverage(),
report.overall_summary.functions_covered,
report.overall_summary.functions_total,
report.overall_summary.function_coverage(),
report.overall_summary.branches_covered,
report.overall_summary.branches_total,
report.overall_summary.branch_coverage(),
if report.quality_gates.passed {
"pass"
} else {
"fail"
},
if report.quality_gates.passed {
"PASSED"
} else {
"FAILED"
},
if report.quality_gates.failures.is_empty() {
"<p class=\"pass\">All quality gates passed!</p>".to_string()
} else {
format!(
"<ul>{}</ul>",
report
.quality_gates
.failures
.iter()
.map(|f| format!(
"<li class=\"fail\">{}: Expected {:.1}%, got {:.1}%</li>",
f.rule, f.expected, f.actual
))
.collect::<Vec<_>>()
.join("")
)
},
if report.recommendations.is_empty() {
"<p>No recommendations at this time.</p>".to_string()
} else {
format!(
"<ul>{}</ul>",
report
.recommendations
.iter()
.map(|r| format!("<li>{}</li>", r.description))
.collect::<Vec<_>>()
.join("")
)
},
chrono::DateTime::from_timestamp(report.timestamp as i64, 0)
.unwrap_or_default()
.format("%Y-%m-%d %H:%M:%S UTC")
)
}
fn generate_xml_content(&self, report: &CoverageReport) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<coverage timestamp="{}" lines-covered="{}" lines-valid="{}" line-rate="{:.4}">
<packages>
<package name="sklears" line-rate="{:.4}" branch-rate="{:.4}">
<classes>
{}
</classes>
</package>
</packages>
</coverage>"#,
report.timestamp,
report.overall_summary.lines_covered,
report.overall_summary.lines_total,
report.overall_summary.line_coverage() / 100.0,
report.overall_summary.line_coverage() / 100.0,
report.overall_summary.branch_coverage() / 100.0,
report
.module_summaries
.iter()
.map(|(name, summary)| {
format!(
r#"<class name="{}" line-rate="{:.4}" branch-rate="{:.4}"></class>"#,
name,
summary.line_coverage() / 100.0,
summary.branch_coverage() / 100.0
)
})
.collect::<Vec<_>>()
.join("\n ")
)
}
}
#[derive(Debug)]
pub struct CoverageCI {
config: CIDConfig,
}
#[derive(Debug, Clone)]
pub struct CIDConfig {
pub pr_coverage_threshold: f64,
pub diff_coverage_threshold: f64,
pub failure_on_regression: bool,
pub post_results_to_pr: bool,
pub badge_generation: bool,
}
impl Default for CIDConfig {
fn default() -> Self {
Self {
pr_coverage_threshold: 80.0,
diff_coverage_threshold: 90.0,
failure_on_regression: true,
post_results_to_pr: false,
badge_generation: true,
}
}
}
impl CIDConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_pr_coverage_threshold(mut self, threshold: f64) -> Self {
self.pr_coverage_threshold = threshold;
self
}
pub fn with_diff_coverage_threshold(mut self, threshold: f64) -> Self {
self.diff_coverage_threshold = threshold;
self
}
pub fn with_failure_on_regression(mut self, enabled: bool) -> Self {
self.failure_on_regression = enabled;
self
}
}
#[derive(Debug)]
pub struct CICoverageResult {
pub coverage: f64,
pub diff_coverage: Option<f64>,
pub passed: bool,
pub failures: Vec<String>,
}
impl CoverageCI {
pub fn new(config: CIDConfig) -> Self {
Self { config }
}
pub fn run_coverage_check(&self) -> std::result::Result<CICoverageResult, Vec<String>> {
let mut failures = Vec::new();
let coverage_config =
CoverageConfig::new().with_minimum_coverage(self.config.pr_coverage_threshold);
let mut collector = CoverageCollector::new(coverage_config);
let report = match collector.collect_and_analyze() {
Ok(report) => report,
Err(e) => {
failures.push(format!("Failed to collect coverage: {e}"));
return Err(failures);
}
};
let coverage = report.overall_coverage();
if coverage < self.config.pr_coverage_threshold {
failures.push(format!(
"Coverage {:.1}% below PR threshold {:.1}%",
coverage, self.config.pr_coverage_threshold
));
}
if !report.meets_quality_gates() {
for failure in &report.quality_gates.failures {
failures.push(format!(
"{}: {:.1}% < {:.1}%",
failure.rule, failure.actual, failure.expected
));
}
}
let result = CICoverageResult {
coverage,
diff_coverage: None, passed: failures.is_empty(),
failures: failures.clone(),
};
if failures.is_empty() {
Ok(result)
} else {
Err(failures)
}
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_coverage_config_creation() {
let config = CoverageConfig::new()
.with_minimum_coverage(85.0)
.with_output_format(vec!["json", "html"])
.with_exclude_patterns(vec!["tests/*"]);
assert_eq!(config.minimum_coverage, 85.0);
assert_eq!(config.output_formats, vec!["json", "html"]);
assert_eq!(config.exclude_patterns, vec!["tests/*"]);
}
#[test]
fn test_coverage_summary_calculations() {
let summary = CoverageSummary {
lines_covered: 80,
lines_total: 100,
functions_covered: 9,
functions_total: 10,
branches_covered: 15,
branches_total: 20,
};
assert_eq!(summary.line_coverage(), 80.0);
assert_eq!(summary.function_coverage(), 90.0);
assert_eq!(summary.branch_coverage(), 75.0);
}
#[test]
fn test_quality_gates_evaluation() {
let config = CoverageConfig::new().with_minimum_coverage(85.0);
let collector = CoverageCollector::new(config);
let summary = CoverageSummary {
lines_covered: 80,
lines_total: 100,
functions_covered: 8,
functions_total: 10,
branches_covered: 12,
branches_total: 15,
};
let result = collector.evaluate_quality_gates(&summary);
assert!(!result.passed);
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0].rule, "Minimum overall coverage");
}
#[test]
fn test_coverage_collector_creation() {
let config = CoverageConfig::new();
let collector = CoverageCollector::new(config);
assert!(collector.collected_data.is_none());
}
#[test]
fn test_ci_config_creation() {
let config = CIDConfig::new()
.with_pr_coverage_threshold(85.0)
.with_diff_coverage_threshold(95.0)
.with_failure_on_regression(false);
assert_eq!(config.pr_coverage_threshold, 85.0);
assert_eq!(config.diff_coverage_threshold, 95.0);
assert!(!config.failure_on_regression);
}
#[test]
fn test_coverage_ci_creation() {
let config = CIDConfig::new();
let ci = CoverageCI::new(config);
assert_eq!(ci.config.pr_coverage_threshold, 80.0);
}
#[test]
fn test_recommendation_priority_ordering() {
let critical = RecommendationPriority::Critical;
let high = RecommendationPriority::High;
let medium = RecommendationPriority::Medium;
let low = RecommendationPriority::Low;
assert!(critical < high);
assert!(high < medium);
assert!(medium < low);
}
}