ferrous_forge/test_coverage/
analyzer.rs1use super::types::{CoverageConfig, CoverageReport};
4use super::utils::{calculate_branch_coverage, parse_tarpaulin_json, process_file_coverage};
5use crate::{Error, Result};
6use std::path::Path;
7use std::process::Command;
8
9pub struct CoverageAnalyzer {
11 config: CoverageConfig,
13}
14
15impl CoverageAnalyzer {
16 pub fn new() -> Self {
18 Self {
19 config: CoverageConfig::default(),
20 }
21 }
22
23 pub fn with_config(config: CoverageConfig) -> Self {
25 Self { config }
26 }
27
28 pub fn check_tarpaulin_installed(&self) -> Result<bool> {
30 let output = Command::new("cargo")
31 .args(["tarpaulin", "--version"])
32 .output();
33
34 match output {
35 Ok(output) => Ok(output.status.success()),
36 Err(_) => Ok(false),
37 }
38 }
39
40 pub async fn install_tarpaulin(&self) -> Result<()> {
42 if self.check_tarpaulin_installed()? {
43 tracing::info!("cargo-tarpaulin already installed");
44 return Ok(());
45 }
46
47 tracing::info!("Installing cargo-tarpaulin...");
48
49 let output = Command::new("cargo")
50 .args(["install", "cargo-tarpaulin"])
51 .output()
52 .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
53
54 if !output.status.success() {
55 let stderr = String::from_utf8_lossy(&output.stderr);
56 return Err(Error::process(format!(
57 "Failed to install cargo-tarpaulin: {}",
58 stderr
59 )));
60 }
61
62 tracing::info!("cargo-tarpaulin installed successfully");
63 Ok(())
64 }
65
66 pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
68 if !self.check_tarpaulin_installed()? {
69 return Err(Error::validation(
70 "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
71 ));
72 }
73
74 tracing::info!("Running test coverage analysis...");
75
76 let exclude_files_str = self.config.exclude_files.join(",");
77 let mut args = vec![
78 "tarpaulin",
79 "--verbose",
80 "--timeout",
81 "120",
82 "--out",
83 "Json",
84 "--exclude-files",
85 &exclude_files_str,
86 ];
87
88 for exclude_dir in &self.config.exclude_dirs {
90 args.extend_from_slice(&["--exclude-files", exclude_dir]);
91 }
92
93 let output = Command::new("cargo")
94 .args(&args)
95 .current_dir(project_path)
96 .output()
97 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
98
99 if !output.status.success() {
100 let stderr = String::from_utf8_lossy(&output.stderr);
101 return Err(Error::process(format!(
102 "cargo tarpaulin failed: {}",
103 stderr
104 )));
105 }
106
107 let stdout = String::from_utf8_lossy(&output.stdout);
108 self.parse_tarpaulin_output(&stdout)
109 }
110
111 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
113 let tarpaulin_data = parse_tarpaulin_json(output)?;
114 let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
115 let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
116
117 Ok(CoverageReport {
118 line_coverage: tarpaulin_data.line_coverage,
119 function_coverage: function_stats.coverage,
120 branch_coverage,
121 file_coverage,
122 lines_tested: tarpaulin_data.lines_covered,
123 total_lines: tarpaulin_data.lines_total,
124 functions_tested: function_stats.tested,
125 total_functions: function_stats.total,
126 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
127 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
128 })
129 }
130
131 pub fn config(&self) -> &CoverageConfig {
133 &self.config
134 }
135
136 pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
141 self.run_coverage(project_path).await
142 }
143
144 pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
149 self.parse_tarpaulin_output(tarpaulin_output)
150 }
151
152 pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
156 if report.line_coverage < threshold {
157 return Err(Error::validation(format!(
158 "Test coverage {:.1}% is below minimum threshold of {:.1}%",
159 report.line_coverage, threshold
160 )));
161 }
162 Ok(())
163 }
164
165 pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
169 let coverage = report.line_coverage;
170 let color = match coverage {
171 c if c >= 80.0 => "#4c1", c if c >= 60.0 => "#dfb317", c if c >= 40.0 => "#fe7d37", _ => "#e05d44", };
176
177 format!(
178 r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
179 <linearGradient id="a" x2="0" y2="100%">
180 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
181 <stop offset="1" stop-opacity=".1"/>
182 </linearGradient>
183 <rect rx="3" width="114" height="20" fill="#555"/>
184 <rect rx="3" x="63" width="51" height="20" fill="{}"/>
185 <path fill="{}" d="M63 0h4v20h-4z"/>
186 <rect rx="3" width="114" height="20" fill="url(#a)"/>
187 <g fill="#fff" text-anchor="middle"
188 font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
189 <text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
190 <text x="32" y="14">coverage</text>
191 <text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
192 <text x="87" y="14">{:.1}%</text>
193 </g>
194 </svg>"##,
195 color, color, coverage, coverage
196 )
197 }
198}
199
200impl Default for CoverageAnalyzer {
201 fn default() -> Self {
202 Self::new()
203 }
204}