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;
7
8pub struct CoverageAnalyzer {
10 config: CoverageConfig,
12}
13
14impl CoverageAnalyzer {
15 pub fn new() -> Self {
17 Self {
18 config: CoverageConfig::default(),
19 }
20 }
21
22 pub fn with_config(config: CoverageConfig) -> Self {
24 Self { config }
25 }
26
27 pub async fn check_tarpaulin_installed(&self) -> Result<bool> {
33 let output = tokio::process::Command::new("cargo")
34 .args(["tarpaulin", "--version"])
35 .output()
36 .await;
37
38 match output {
39 Ok(output) => Ok(output.status.success()),
40 Err(_) => Ok(false),
41 }
42 }
43
44 pub async fn install_tarpaulin(&self) -> Result<()> {
51 if self.check_tarpaulin_installed().await? {
52 tracing::info!("cargo-tarpaulin already installed");
53 return Ok(());
54 }
55
56 tracing::info!("Installing cargo-tarpaulin...");
57
58 let output = tokio::process::Command::new("cargo")
59 .args(["install", "cargo-tarpaulin"])
60 .output()
61 .await
62 .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
63
64 if !output.status.success() {
65 let stderr = String::from_utf8_lossy(&output.stderr);
66 return Err(Error::process(format!(
67 "Failed to install cargo-tarpaulin: {}",
68 stderr
69 )));
70 }
71
72 tracing::info!("cargo-tarpaulin installed successfully");
73 Ok(())
74 }
75
76 pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
83 if !self.check_tarpaulin_installed().await? {
84 return Err(Error::validation(
85 "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
86 ));
87 }
88
89 tracing::info!("Running test coverage analysis...");
90
91 let report_dir = project_path.join("target").join("tarpaulin");
93 tokio::fs::create_dir_all(&report_dir)
94 .await
95 .map_err(|e| Error::process(format!("Failed to create tarpaulin output dir: {e}")))?;
96
97 let mut args = vec![
98 "tarpaulin".to_string(),
99 "--timeout".to_string(),
100 "120".to_string(),
101 "--skip-clean".to_string(),
102 "--out".to_string(),
103 "Json".to_string(),
104 "--output-dir".to_string(),
105 report_dir.display().to_string(),
106 ];
107
108 for exclude_file in &self.config.exclude_files {
110 args.push("--exclude-files".to_string());
111 args.push(exclude_file.clone());
112 }
113
114 for exclude_dir in &self.config.exclude_dirs {
116 args.push("--exclude-files".to_string());
117 args.push(exclude_dir.clone());
118 }
119
120 let output = tokio::process::Command::new("cargo")
121 .args(&args)
122 .current_dir(project_path)
123 .output()
124 .await
125 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {e}")))?;
126
127 let exit_code = output.status.code().unwrap_or(-1);
128 let stderr = String::from_utf8_lossy(&output.stderr);
129
130 let report_file = report_dir.join("tarpaulin-report.json");
132 let json_content = match tokio::fs::read_to_string(&report_file).await {
133 Ok(content) => content,
134 Err(read_err) => {
135 return Err(Error::process(format!(
137 "Tarpaulin produced no JSON report (exit code {exit_code}). \
138 File error: {read_err}. Tarpaulin stderr: {stderr}"
139 )));
140 }
141 };
142
143 match self.parse_tarpaulin_output(&json_content) {
146 Ok(report) => {
147 if !output.status.success() {
148 tracing::warn!(
149 "Tarpaulin exited with code {exit_code} but produced a valid report. \
150 stderr: {stderr}"
151 );
152 }
153 Ok(report)
154 }
155 Err(parse_err) => Err(Error::process(format!(
156 "Failed to parse tarpaulin JSON (exit code {exit_code}): {parse_err}. \
157 Tarpaulin stderr: {stderr}"
158 ))),
159 }
160 }
161
162 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
164 let tarpaulin_data = parse_tarpaulin_json(output)?;
165 let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
166 let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
167
168 Ok(CoverageReport {
169 line_coverage: tarpaulin_data.line_coverage,
170 function_coverage: function_stats.coverage,
171 branch_coverage,
172 file_coverage,
173 lines_tested: tarpaulin_data.lines_covered,
174 total_lines: tarpaulin_data.lines_total,
175 functions_tested: function_stats.tested,
176 total_functions: function_stats.total,
177 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
178 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
179 })
180 }
181
182 pub fn config(&self) -> &CoverageConfig {
184 &self.config
185 }
186
187 pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
197 self.run_coverage(project_path).await
198 }
199
200 pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
209 self.parse_tarpaulin_output(tarpaulin_output)
210 }
211
212 pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
220 if report.line_coverage < threshold {
221 return Err(Error::validation(format!(
222 "Test coverage {:.1}% is below minimum threshold of {:.1}%",
223 report.line_coverage, threshold
224 )));
225 }
226 Ok(())
227 }
228
229 pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
233 let coverage = report.line_coverage;
234 let color = match coverage {
235 c if c >= 80.0 => "#4c1", c if c >= 60.0 => "#dfb317", c if c >= 40.0 => "#fe7d37", _ => "#e05d44", };
240
241 format!(
242 r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
243 <linearGradient id="a" x2="0" y2="100%">
244 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
245 <stop offset="1" stop-opacity=".1"/>
246 </linearGradient>
247 <rect rx="3" width="114" height="20" fill="#555"/>
248 <rect rx="3" x="63" width="51" height="20" fill="{}"/>
249 <path fill="{}" d="M63 0h4v20h-4z"/>
250 <rect rx="3" width="114" height="20" fill="url(#a)"/>
251 <g fill="#fff" text-anchor="middle"
252 font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
253 <text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
254 <text x="32" y="14">coverage</text>
255 <text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
256 <text x="87" y="14">{:.1}%</text>
257 </g>
258 </svg>"##,
259 color, color, coverage, coverage
260 )
261 }
262}
263
264impl Default for CoverageAnalyzer {
265 fn default() -> Self {
266 Self::new()
267 }
268}