ferrous_forge/test_coverage/
analyzer.rs1use super::types::{CoverageConfig, CoverageReport, FileCoverage};
4use crate::{Error, Result};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::path::Path;
8use std::process::Command;
9
10pub struct CoverageAnalyzer {
12 config: CoverageConfig,
14}
15
16impl CoverageAnalyzer {
17 pub fn new() -> Self {
19 Self {
20 config: CoverageConfig::default(),
21 }
22 }
23
24 pub fn with_config(config: CoverageConfig) -> Self {
26 Self { config }
27 }
28
29 pub fn check_tarpaulin_installed(&self) -> Result<bool> {
31 let output = Command::new("cargo")
32 .args(["tarpaulin", "--version"])
33 .output();
34
35 match output {
36 Ok(output) => Ok(output.status.success()),
37 Err(_) => Ok(false),
38 }
39 }
40
41 pub async fn install_tarpaulin(&self) -> Result<()> {
43 if self.check_tarpaulin_installed()? {
44 tracing::info!("cargo-tarpaulin already installed");
45 return Ok(());
46 }
47
48 tracing::info!("Installing cargo-tarpaulin...");
49
50 let output = Command::new("cargo")
51 .args(["install", "cargo-tarpaulin"])
52 .output()
53 .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
54
55 if !output.status.success() {
56 let stderr = String::from_utf8_lossy(&output.stderr);
57 return Err(Error::process(format!(
58 "Failed to install cargo-tarpaulin: {}",
59 stderr
60 )));
61 }
62
63 tracing::info!("cargo-tarpaulin installed successfully");
64 Ok(())
65 }
66
67 pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
69 if !self.check_tarpaulin_installed()? {
70 return Err(Error::validation(
71 "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
72 ));
73 }
74
75 tracing::info!("Running test coverage analysis...");
76
77 let exclude_files_str = self.config.exclude_files.join(",");
78 let mut args = vec![
79 "tarpaulin",
80 "--verbose",
81 "--timeout",
82 "120",
83 "--out",
84 "Json",
85 "--exclude-files",
86 &exclude_files_str,
87 ];
88
89 for exclude_dir in &self.config.exclude_dirs {
91 args.extend_from_slice(&["--exclude-files", exclude_dir]);
92 }
93
94 let output = Command::new("cargo")
95 .args(&args)
96 .current_dir(project_path)
97 .output()
98 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
99
100 if !output.status.success() {
101 let stderr = String::from_utf8_lossy(&output.stderr);
102 return Err(Error::process(format!(
103 "cargo tarpaulin failed: {}",
104 stderr
105 )));
106 }
107
108 let stdout = String::from_utf8_lossy(&output.stdout);
109 self.parse_tarpaulin_output(&stdout)
110 }
111
112 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
114 let tarpaulin_data = parse_tarpaulin_json(output)?;
115 let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
116 let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
117
118 Ok(CoverageReport {
119 line_coverage: tarpaulin_data.line_coverage,
120 function_coverage: function_stats.coverage,
121 branch_coverage,
122 file_coverage,
123 lines_tested: tarpaulin_data.lines_covered,
124 total_lines: tarpaulin_data.lines_total,
125 functions_tested: function_stats.tested,
126 total_functions: function_stats.total,
127 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
128 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
129 })
130 }
131
132 pub fn config(&self) -> &CoverageConfig {
134 &self.config
135 }
136}
137
138impl Default for CoverageAnalyzer {
139 fn default() -> Self {
140 Self::new()
141 }
142}
143
144#[derive(Deserialize)]
146struct TarpaulinOutput {
147 #[serde(rename = "coverage")]
148 line_coverage: f64,
149 #[serde(rename = "linesCovered")]
150 lines_covered: u32,
151 #[serde(rename = "linesTotal")]
152 lines_total: u32,
153 #[serde(rename = "branchesCovered")]
154 branches_covered: Option<u32>,
155 #[serde(rename = "branchesTotal")]
156 branches_total: Option<u32>,
157 #[serde(rename = "files")]
158 files: HashMap<String, TarpaulinFile>,
159}
160
161#[derive(Deserialize)]
163struct TarpaulinFile {
164 #[serde(rename = "coverage")]
165 line_coverage: f64,
166 #[serde(rename = "linesCovered")]
167 lines_covered: u32,
168 #[serde(rename = "linesTotal")]
169 lines_total: u32,
170}
171
172struct FunctionStats {
174 coverage: f64,
175 tested: u32,
176 total: u32,
177}
178
179fn parse_tarpaulin_json(output: &str) -> Result<TarpaulinOutput> {
181 serde_json::from_str(output)
182 .map_err(|e| Error::validation(format!("Failed to parse coverage output: {}", e)))
183}
184
185fn process_file_coverage(
187 files: &HashMap<String, TarpaulinFile>,
188) -> (HashMap<String, FileCoverage>, FunctionStats) {
189 let mut file_coverage = HashMap::new();
190 let mut total_functions_tested = 0;
191 let mut total_functions = 0;
192
193 for (file_path, file_data) in files {
194 let (estimated_functions, estimated_functions_tested) =
195 estimate_function_coverage(file_data);
196
197 total_functions += estimated_functions;
198 total_functions_tested += estimated_functions_tested;
199
200 let coverage = create_file_coverage(
201 file_path,
202 file_data,
203 estimated_functions,
204 estimated_functions_tested,
205 );
206 file_coverage.insert(file_path.clone(), coverage);
207 }
208
209 let function_coverage =
210 calculate_function_coverage_percentage(total_functions_tested, total_functions);
211
212 (
213 file_coverage,
214 FunctionStats {
215 coverage: function_coverage,
216 tested: total_functions_tested,
217 total: total_functions,
218 },
219 )
220}
221
222fn estimate_function_coverage(file_data: &TarpaulinFile) -> (u32, u32) {
224 let estimated_functions = (file_data.lines_total / 10).max(1);
225 let estimated_functions_tested =
226 ((file_data.line_coverage / 100.0) * estimated_functions as f64) as u32;
227 (estimated_functions, estimated_functions_tested)
228}
229
230fn create_file_coverage(
232 file_path: &str,
233 file_data: &TarpaulinFile,
234 estimated_functions: u32,
235 estimated_functions_tested: u32,
236) -> FileCoverage {
237 FileCoverage {
238 file_path: file_path.to_string(),
239 line_coverage: file_data.line_coverage,
240 function_coverage: calculate_function_coverage_percentage(
241 estimated_functions_tested,
242 estimated_functions,
243 ),
244 lines_tested: file_data.lines_covered,
245 total_lines: file_data.lines_total,
246 functions_tested: estimated_functions_tested,
247 total_functions: estimated_functions,
248 }
249}
250
251fn calculate_function_coverage_percentage(tested: u32, total: u32) -> f64 {
253 if total > 0 {
254 (tested as f64 / total as f64) * 100.0
255 } else {
256 100.0
257 }
258}
259
260fn calculate_branch_coverage(tarpaulin_data: &TarpaulinOutput) -> f64 {
262 if let (Some(covered), Some(total)) = (
263 tarpaulin_data.branches_covered,
264 tarpaulin_data.branches_total,
265 ) {
266 if total > 0 {
267 (covered as f64 / total as f64) * 100.0
268 } else {
269 100.0
270 }
271 } else {
272 tarpaulin_data.line_coverage }
274}