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