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 mut args = vec![
92 "tarpaulin".to_string(),
93 "--verbose".to_string(),
94 "--timeout".to_string(),
95 "120".to_string(),
96 "--out".to_string(),
97 "Json".to_string(),
98 ];
99
100 for exclude_file in &self.config.exclude_files {
102 args.push("--exclude-files".to_string());
103 args.push(exclude_file.clone());
104 }
105
106 for exclude_dir in &self.config.exclude_dirs {
108 args.push("--exclude-files".to_string());
109 args.push(exclude_dir.clone());
110 }
111
112 let output = tokio::process::Command::new("cargo")
113 .args(&args)
114 .current_dir(project_path)
115 .output()
116 .await
117 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
118
119 if !output.status.success() {
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 return Err(Error::process(format!(
122 "cargo tarpaulin failed: {}",
123 stderr
124 )));
125 }
126
127 let stdout = String::from_utf8_lossy(&output.stdout);
128 self.parse_tarpaulin_output(&stdout)
129 }
130
131 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
133 let tarpaulin_data = parse_tarpaulin_json(output)?;
134 let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
135 let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
136
137 Ok(CoverageReport {
138 line_coverage: tarpaulin_data.line_coverage,
139 function_coverage: function_stats.coverage,
140 branch_coverage,
141 file_coverage,
142 lines_tested: tarpaulin_data.lines_covered,
143 total_lines: tarpaulin_data.lines_total,
144 functions_tested: function_stats.tested,
145 total_functions: function_stats.total,
146 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
147 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
148 })
149 }
150
151 pub fn config(&self) -> &CoverageConfig {
153 &self.config
154 }
155
156 pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
166 self.run_coverage(project_path).await
167 }
168
169 pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
178 self.parse_tarpaulin_output(tarpaulin_output)
179 }
180
181 pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
189 if report.line_coverage < threshold {
190 return Err(Error::validation(format!(
191 "Test coverage {:.1}% is below minimum threshold of {:.1}%",
192 report.line_coverage, threshold
193 )));
194 }
195 Ok(())
196 }
197
198 pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
202 let coverage = report.line_coverage;
203 let color = match coverage {
204 c if c >= 80.0 => "#4c1", c if c >= 60.0 => "#dfb317", c if c >= 40.0 => "#fe7d37", _ => "#e05d44", };
209
210 format!(
211 r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
212 <linearGradient id="a" x2="0" y2="100%">
213 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
214 <stop offset="1" stop-opacity=".1"/>
215 </linearGradient>
216 <rect rx="3" width="114" height="20" fill="#555"/>
217 <rect rx="3" x="63" width="51" height="20" fill="{}"/>
218 <path fill="{}" d="M63 0h4v20h-4z"/>
219 <rect rx="3" width="114" height="20" fill="url(#a)"/>
220 <g fill="#fff" text-anchor="middle"
221 font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
222 <text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
223 <text x="32" y="14">coverage</text>
224 <text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
225 <text x="87" y="14">{:.1}%</text>
226 </g>
227 </svg>"##,
228 color, color, coverage, coverage
229 )
230 }
231}
232
233impl Default for CoverageAnalyzer {
234 fn default() -> Self {
235 Self::new()
236 }
237}