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> {
34 let output = Command::new("cargo")
35 .args(["tarpaulin", "--version"])
36 .output();
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()? {
52 tracing::info!("cargo-tarpaulin already installed");
53 return Ok(());
54 }
55
56 tracing::info!("Installing cargo-tarpaulin...");
57
58 let output = Command::new("cargo")
59 .args(["install", "cargo-tarpaulin"])
60 .output()
61 .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
62
63 if !output.status.success() {
64 let stderr = String::from_utf8_lossy(&output.stderr);
65 return Err(Error::process(format!(
66 "Failed to install cargo-tarpaulin: {}",
67 stderr
68 )));
69 }
70
71 tracing::info!("cargo-tarpaulin installed successfully");
72 Ok(())
73 }
74
75 pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
82 if !self.check_tarpaulin_installed()? {
83 return Err(Error::validation(
84 "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
85 ));
86 }
87
88 tracing::info!("Running test coverage analysis...");
89
90 let mut args = vec![
91 "tarpaulin".to_string(),
92 "--verbose".to_string(),
93 "--timeout".to_string(),
94 "120".to_string(),
95 "--out".to_string(),
96 "Json".to_string(),
97 ];
98
99 for exclude_file in &self.config.exclude_files {
101 args.push("--exclude-files".to_string());
102 args.push(exclude_file.clone());
103 }
104
105 for exclude_dir in &self.config.exclude_dirs {
107 args.push("--exclude-files".to_string());
108 args.push(exclude_dir.clone());
109 }
110
111 let output = Command::new("cargo")
112 .args(&args)
113 .current_dir(project_path)
114 .output()
115 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
116
117 if !output.status.success() {
118 let stderr = String::from_utf8_lossy(&output.stderr);
119 return Err(Error::process(format!(
120 "cargo tarpaulin failed: {}",
121 stderr
122 )));
123 }
124
125 let stdout = String::from_utf8_lossy(&output.stdout);
126 self.parse_tarpaulin_output(&stdout)
127 }
128
129 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
131 let tarpaulin_data = parse_tarpaulin_json(output)?;
132 let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
133 let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
134
135 Ok(CoverageReport {
136 line_coverage: tarpaulin_data.line_coverage,
137 function_coverage: function_stats.coverage,
138 branch_coverage,
139 file_coverage,
140 lines_tested: tarpaulin_data.lines_covered,
141 total_lines: tarpaulin_data.lines_total,
142 functions_tested: function_stats.tested,
143 total_functions: function_stats.total,
144 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
145 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
146 })
147 }
148
149 pub fn config(&self) -> &CoverageConfig {
151 &self.config
152 }
153
154 pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
164 self.run_coverage(project_path).await
165 }
166
167 pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
176 self.parse_tarpaulin_output(tarpaulin_output)
177 }
178
179 pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
187 if report.line_coverage < threshold {
188 return Err(Error::validation(format!(
189 "Test coverage {:.1}% is below minimum threshold of {:.1}%",
190 report.line_coverage, threshold
191 )));
192 }
193 Ok(())
194 }
195
196 pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
200 let coverage = report.line_coverage;
201 let color = match coverage {
202 c if c >= 80.0 => "#4c1", c if c >= 60.0 => "#dfb317", c if c >= 40.0 => "#fe7d37", _ => "#e05d44", };
207
208 format!(
209 r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
210 <linearGradient id="a" x2="0" y2="100%">
211 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
212 <stop offset="1" stop-opacity=".1"/>
213 </linearGradient>
214 <rect rx="3" width="114" height="20" fill="#555"/>
215 <rect rx="3" x="63" width="51" height="20" fill="{}"/>
216 <path fill="{}" d="M63 0h4v20h-4z"/>
217 <rect rx="3" width="114" height="20" fill="url(#a)"/>
218 <g fill="#fff" text-anchor="middle"
219 font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
220 <text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
221 <text x="32" y="14">coverage</text>
222 <text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
223 <text x="87" y="14">{:.1}%</text>
224 </g>
225 </svg>"##,
226 color, color, coverage, coverage
227 )
228 }
229}
230
231impl Default for CoverageAnalyzer {
232 fn default() -> Self {
233 Self::new()
234 }
235}