1use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use std::process::Command;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CoverageConfig {
15 pub min_line_coverage: f64,
17 pub min_function_coverage: f64,
19 pub min_branch_coverage: f64,
21 pub fail_on_low_coverage: bool,
23 pub exclude_files: Vec<String>,
25 pub exclude_dirs: Vec<String>,
27}
28
29impl Default for CoverageConfig {
30 fn default() -> Self {
31 Self {
32 min_line_coverage: 80.0,
33 min_function_coverage: 85.0,
34 min_branch_coverage: 75.0,
35 fail_on_low_coverage: true,
36 exclude_files: vec![
37 "main.rs".to_string(),
38 "lib.rs".to_string(),
39 "**/tests/**".to_string(),
40 "**/benches/**".to_string(),
41 ],
42 exclude_dirs: vec![
43 "target".to_string(),
44 "tests".to_string(),
45 "benches".to_string(),
46 "examples".to_string(),
47 ],
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CoverageReport {
55 pub line_coverage: f64,
57 pub function_coverage: f64,
59 pub branch_coverage: f64,
61 pub file_coverage: HashMap<String, FileCoverage>,
63 pub lines_tested: u32,
65 pub total_lines: u32,
67 pub functions_tested: u32,
69 pub total_functions: u32,
71 pub branches_tested: u32,
73 pub total_branches: u32,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FileCoverage {
80 pub file_path: String,
82 pub line_coverage: f64,
84 pub function_coverage: f64,
86 pub lines_tested: u32,
88 pub total_lines: u32,
90 pub functions_tested: u32,
92 pub total_functions: u32,
94}
95
96pub struct CoverageAnalyzer {
98 config: CoverageConfig,
100}
101
102impl CoverageAnalyzer {
103 pub fn new() -> Self {
105 Self {
106 config: CoverageConfig::default(),
107 }
108 }
109
110 pub fn with_config(config: CoverageConfig) -> Self {
112 Self { config }
113 }
114
115 pub fn check_tarpaulin_installed(&self) -> Result<bool> {
117 let output = Command::new("cargo")
118 .args(["tarpaulin", "--version"])
119 .output();
120
121 match output {
122 Ok(output) => Ok(output.status.success()),
123 Err(_) => Ok(false),
124 }
125 }
126
127 pub async fn install_tarpaulin(&self) -> Result<()> {
129 if self.check_tarpaulin_installed()? {
130 tracing::info!("cargo-tarpaulin already installed");
131 return Ok(());
132 }
133
134 tracing::info!("Installing cargo-tarpaulin...");
135
136 let output = Command::new("cargo")
137 .args(["install", "cargo-tarpaulin"])
138 .output()
139 .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
140
141 if !output.status.success() {
142 let stderr = String::from_utf8_lossy(&output.stderr);
143 return Err(Error::process(format!("Failed to install cargo-tarpaulin: {}", stderr)));
144 }
145
146 tracing::info!("cargo-tarpaulin installed successfully");
147 Ok(())
148 }
149
150 pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
152 if !self.check_tarpaulin_installed()? {
153 return Err(Error::validation(
154 "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first."
155 ));
156 }
157
158 tracing::info!("Running test coverage analysis...");
159
160 let exclude_files_str = self.config.exclude_files.join(",");
161 let mut args = vec![
162 "tarpaulin",
163 "--verbose",
164 "--timeout", "120",
165 "--out", "Json",
166 "--exclude-files", &exclude_files_str,
167 ];
168
169 for exclude_dir in &self.config.exclude_dirs {
171 args.extend_from_slice(&["--exclude-files", exclude_dir]);
172 }
173
174 let output = Command::new("cargo")
175 .args(&args)
176 .current_dir(project_path)
177 .output()
178 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
179
180 if !output.status.success() {
181 let stderr = String::from_utf8_lossy(&output.stderr);
182 return Err(Error::process(format!("cargo tarpaulin failed: {}", stderr)));
183 }
184
185 let stdout = String::from_utf8_lossy(&output.stdout);
186 self.parse_tarpaulin_output(&stdout)
187 }
188
189 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
191 #[derive(Deserialize)]
192 struct TarpaulinOutput {
193 #[serde(rename = "coverage")]
194 line_coverage: f64,
195 #[serde(rename = "linesCovered")]
196 lines_covered: u32,
197 #[serde(rename = "linesTotal")]
198 lines_total: u32,
199 #[serde(rename = "branchesCovered")]
200 branches_covered: Option<u32>,
201 #[serde(rename = "branchesTotal")]
202 branches_total: Option<u32>,
203 #[serde(rename = "files")]
204 files: HashMap<String, TarpaulinFile>,
205 }
206
207 #[derive(Deserialize)]
208 struct TarpaulinFile {
209 #[serde(rename = "coverage")]
210 line_coverage: f64,
211 #[serde(rename = "linesCovered")]
212 lines_covered: u32,
213 #[serde(rename = "linesTotal")]
214 lines_total: u32,
215 }
216
217 let tarpaulin_data: TarpaulinOutput = serde_json::from_str(output)
218 .map_err(|e| Error::validation(format!("Failed to parse coverage output: {}", e)))?;
219
220 let mut file_coverage = HashMap::new();
221 let mut total_functions_tested = 0;
222 let mut total_functions = 0;
223
224 for (file_path, file_data) in tarpaulin_data.files {
225 let estimated_functions = (file_data.lines_total / 10).max(1); let estimated_functions_tested = ((file_data.line_coverage / 100.0) * estimated_functions as f64) as u32;
228
229 total_functions += estimated_functions;
230 total_functions_tested += estimated_functions_tested;
231
232 file_coverage.insert(file_path.clone(), FileCoverage {
233 file_path,
234 line_coverage: file_data.line_coverage,
235 function_coverage: if estimated_functions > 0 {
236 (estimated_functions_tested as f64 / estimated_functions as f64) * 100.0
237 } else {
238 100.0
239 },
240 lines_tested: file_data.lines_covered,
241 total_lines: file_data.lines_total,
242 functions_tested: estimated_functions_tested,
243 total_functions: estimated_functions,
244 });
245 }
246
247 let function_coverage = if total_functions > 0 {
248 (total_functions_tested as f64 / total_functions as f64) * 100.0
249 } else {
250 100.0
251 };
252
253 let branch_coverage = if let (Some(covered), Some(total)) =
254 (tarpaulin_data.branches_covered, tarpaulin_data.branches_total) {
255 if total > 0 {
256 (covered as f64 / total as f64) * 100.0
257 } else {
258 100.0
259 }
260 } else {
261 tarpaulin_data.line_coverage };
263
264 Ok(CoverageReport {
265 line_coverage: tarpaulin_data.line_coverage,
266 function_coverage,
267 branch_coverage,
268 file_coverage,
269 lines_tested: tarpaulin_data.lines_covered,
270 total_lines: tarpaulin_data.lines_total,
271 functions_tested: total_functions_tested,
272 total_functions,
273 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
274 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
275 })
276 }
277
278 pub fn validate_coverage(&self, report: &CoverageReport) -> Result<()> {
280 let mut violations = Vec::new();
281
282 if report.line_coverage < self.config.min_line_coverage {
283 violations.push(format!(
284 "Line coverage {:.1}% is below minimum {:.1}%",
285 report.line_coverage, self.config.min_line_coverage
286 ));
287 }
288
289 if report.function_coverage < self.config.min_function_coverage {
290 violations.push(format!(
291 "Function coverage {:.1}% is below minimum {:.1}%",
292 report.function_coverage, self.config.min_function_coverage
293 ));
294 }
295
296 if report.branch_coverage < self.config.min_branch_coverage {
297 violations.push(format!(
298 "Branch coverage {:.1}% is below minimum {:.1}%",
299 report.branch_coverage, self.config.min_branch_coverage
300 ));
301 }
302
303 if !violations.is_empty() {
304 let message = format!("Coverage violations:\n โข {}", violations.join("\n โข "));
305
306 if self.config.fail_on_low_coverage {
307 return Err(Error::validation(message));
308 }
309 tracing::warn!("{}", message);
310 }
311
312 Ok(())
313 }
314
315 pub fn format_coverage_report(&self, report: &CoverageReport) -> String {
317 let mut output = String::new();
318
319 output.push_str("๐ Test Coverage Report\n");
320 output.push_str("โโโโโโโโโโโโโโโโโโโโโโโ\n\n");
321
322 output.push_str(&format!("๐ Overall Coverage:\n"));
323 output.push_str(&format!(" โข Lines: {:.1}% ({}/{})\n",
324 report.line_coverage, report.lines_tested, report.total_lines));
325 output.push_str(&format!(" โข Functions: {:.1}% ({}/{})\n",
326 report.function_coverage, report.functions_tested, report.total_functions));
327 output.push_str(&format!(" โข Branches: {:.1}% ({}/{})\n\n",
328 report.branch_coverage, report.branches_tested, report.total_branches));
329
330 let line_status = if report.line_coverage >= self.config.min_line_coverage { "โ
" } else { "โ" };
332 let func_status = if report.function_coverage >= self.config.min_function_coverage { "โ
" } else { "โ" };
333 let branch_status = if report.branch_coverage >= self.config.min_branch_coverage { "โ
" } else { "โ" };
334
335 output.push_str("๐ฏ Threshold Status:\n");
336 output.push_str(&format!(" {} Lines: {:.1}% (min: {:.1}%)\n",
337 line_status, report.line_coverage, self.config.min_line_coverage));
338 output.push_str(&format!(" {} Functions: {:.1}% (min: {:.1}%)\n",
339 func_status, report.function_coverage, self.config.min_function_coverage));
340 output.push_str(&format!(" {} Branches: {:.1}% (min: {:.1}%)\n\n",
341 branch_status, report.branch_coverage, self.config.min_branch_coverage));
342
343 let mut low_coverage_files: Vec<_> = report.file_coverage.values()
345 .filter(|file| file.line_coverage < self.config.min_line_coverage)
346 .collect();
347 low_coverage_files.sort_by(|a, b| a.line_coverage.partial_cmp(&b.line_coverage).unwrap());
348
349 if !low_coverage_files.is_empty() {
350 output.push_str("โ ๏ธ Files Below Threshold:\n");
351 for file in low_coverage_files.iter().take(5) {
352 output.push_str(&format!(" โข {}: {:.1}%\n", file.file_path, file.line_coverage));
353 }
354 if low_coverage_files.len() > 5 {
355 output.push_str(&format!(" ... and {} more files\n", low_coverage_files.len() - 5));
356 }
357 output.push('\n');
358 }
359
360 output.push_str("๐ก To improve coverage:\n");
361 output.push_str(" โข Add tests for uncovered code paths\n");
362 output.push_str(" โข Remove dead code\n");
363 output.push_str(" โข Test error conditions and edge cases\n");
364 output.push_str(" โข Use property-based testing\n");
365
366 output
367 }
368
369 pub async fn check_project_coverage(&self, project_path: &Path) -> Result<()> {
371 println!("๐งช Checking test coverage...");
372
373 let report = self.run_coverage(project_path).await?;
374
375 println!("{}", self.format_coverage_report(&report));
376
377 self.validate_coverage(&report)?;
378
379 println!("โ
Coverage check completed successfully");
380 Ok(())
381 }
382}
383
384impl Default for CoverageAnalyzer {
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn test_coverage_config_default() {
396 let config = CoverageConfig::default();
397 assert_eq!(config.min_line_coverage, 80.0);
398 assert_eq!(config.min_function_coverage, 85.0);
399 assert_eq!(config.min_branch_coverage, 75.0);
400 assert!(config.fail_on_low_coverage);
401 }
402
403 #[test]
404 fn test_coverage_analyzer_creation() {
405 let analyzer = CoverageAnalyzer::new();
406 assert_eq!(analyzer.config.min_line_coverage, 80.0);
407
408 let custom_config = CoverageConfig {
409 min_line_coverage: 90.0,
410 ..Default::default()
411 };
412 let custom_analyzer = CoverageAnalyzer::with_config(custom_config);
413 assert_eq!(custom_analyzer.config.min_line_coverage, 90.0);
414 }
415
416 #[test]
417 fn test_validate_coverage_success() {
418 let analyzer = CoverageAnalyzer::new();
419 let report = CoverageReport {
420 line_coverage: 85.0,
421 function_coverage: 90.0,
422 branch_coverage: 80.0,
423 file_coverage: HashMap::new(),
424 lines_tested: 85,
425 total_lines: 100,
426 functions_tested: 18,
427 total_functions: 20,
428 branches_tested: 8,
429 total_branches: 10,
430 };
431
432 assert!(analyzer.validate_coverage(&report).is_ok());
433 }
434
435 #[test]
436 fn test_validate_coverage_failure() {
437 let analyzer = CoverageAnalyzer::new();
438 let report = CoverageReport {
439 line_coverage: 70.0, function_coverage: 90.0,
441 branch_coverage: 80.0,
442 file_coverage: HashMap::new(),
443 lines_tested: 70,
444 total_lines: 100,
445 functions_tested: 18,
446 total_functions: 20,
447 branches_tested: 8,
448 total_branches: 10,
449 };
450
451 assert!(analyzer.validate_coverage(&report).is_err());
452 }
453
454 #[test]
455 fn test_format_coverage_report() {
456 let analyzer = CoverageAnalyzer::new();
457 let report = CoverageReport {
458 line_coverage: 85.0,
459 function_coverage: 90.0,
460 branch_coverage: 80.0,
461 file_coverage: HashMap::new(),
462 lines_tested: 85,
463 total_lines: 100,
464 functions_tested: 18,
465 total_functions: 20,
466 branches_tested: 8,
467 total_branches: 10,
468 };
469
470 let formatted = analyzer.format_coverage_report(&report);
471 assert!(formatted.contains("Test Coverage Report"));
472 assert!(formatted.contains("85.0%"));
473 assert!(formatted.contains("90.0%"));
474 assert!(formatted.contains("80.0%"));
475 }
476}