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!(
144 "Failed to install cargo-tarpaulin: {}",
145 stderr
146 )));
147 }
148
149 tracing::info!("cargo-tarpaulin installed successfully");
150 Ok(())
151 }
152
153 pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
155 if !self.check_tarpaulin_installed()? {
156 return Err(Error::validation(
157 "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
158 ));
159 }
160
161 tracing::info!("Running test coverage analysis...");
162
163 let exclude_files_str = self.config.exclude_files.join(",");
164 let mut args = vec![
165 "tarpaulin",
166 "--verbose",
167 "--timeout",
168 "120",
169 "--out",
170 "Json",
171 "--exclude-files",
172 &exclude_files_str,
173 ];
174
175 for exclude_dir in &self.config.exclude_dirs {
177 args.extend_from_slice(&["--exclude-files", exclude_dir]);
178 }
179
180 let output = Command::new("cargo")
181 .args(&args)
182 .current_dir(project_path)
183 .output()
184 .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
185
186 if !output.status.success() {
187 let stderr = String::from_utf8_lossy(&output.stderr);
188 return Err(Error::process(format!(
189 "cargo tarpaulin failed: {}",
190 stderr
191 )));
192 }
193
194 let stdout = String::from_utf8_lossy(&output.stdout);
195 self.parse_tarpaulin_output(&stdout)
196 }
197
198 fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
200 #[derive(Deserialize)]
201 struct TarpaulinOutput {
202 #[serde(rename = "coverage")]
203 line_coverage: f64,
204 #[serde(rename = "linesCovered")]
205 lines_covered: u32,
206 #[serde(rename = "linesTotal")]
207 lines_total: u32,
208 #[serde(rename = "branchesCovered")]
209 branches_covered: Option<u32>,
210 #[serde(rename = "branchesTotal")]
211 branches_total: Option<u32>,
212 #[serde(rename = "files")]
213 files: HashMap<String, TarpaulinFile>,
214 }
215
216 #[derive(Deserialize)]
217 struct TarpaulinFile {
218 #[serde(rename = "coverage")]
219 line_coverage: f64,
220 #[serde(rename = "linesCovered")]
221 lines_covered: u32,
222 #[serde(rename = "linesTotal")]
223 lines_total: u32,
224 }
225
226 let tarpaulin_data: TarpaulinOutput = serde_json::from_str(output)
227 .map_err(|e| Error::validation(format!("Failed to parse coverage output: {}", e)))?;
228
229 let mut file_coverage = HashMap::new();
230 let mut total_functions_tested = 0;
231 let mut total_functions = 0;
232
233 for (file_path, file_data) in tarpaulin_data.files {
234 let estimated_functions = (file_data.lines_total / 10).max(1); let estimated_functions_tested =
237 ((file_data.line_coverage / 100.0) * estimated_functions as f64) as u32;
238
239 total_functions += estimated_functions;
240 total_functions_tested += estimated_functions_tested;
241
242 file_coverage.insert(
243 file_path.clone(),
244 FileCoverage {
245 file_path,
246 line_coverage: file_data.line_coverage,
247 function_coverage: if estimated_functions > 0 {
248 (estimated_functions_tested as f64 / estimated_functions as f64) * 100.0
249 } else {
250 100.0
251 },
252 lines_tested: file_data.lines_covered,
253 total_lines: file_data.lines_total,
254 functions_tested: estimated_functions_tested,
255 total_functions: estimated_functions,
256 },
257 );
258 }
259
260 let function_coverage = if total_functions > 0 {
261 (total_functions_tested as f64 / total_functions as f64) * 100.0
262 } else {
263 100.0
264 };
265
266 let branch_coverage = if let (Some(covered), Some(total)) = (
267 tarpaulin_data.branches_covered,
268 tarpaulin_data.branches_total,
269 ) {
270 if total > 0 {
271 (covered as f64 / total as f64) * 100.0
272 } else {
273 100.0
274 }
275 } else {
276 tarpaulin_data.line_coverage };
278
279 Ok(CoverageReport {
280 line_coverage: tarpaulin_data.line_coverage,
281 function_coverage,
282 branch_coverage,
283 file_coverage,
284 lines_tested: tarpaulin_data.lines_covered,
285 total_lines: tarpaulin_data.lines_total,
286 functions_tested: total_functions_tested,
287 total_functions,
288 branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
289 total_branches: tarpaulin_data.branches_total.unwrap_or(0),
290 })
291 }
292
293 pub fn validate_coverage(&self, report: &CoverageReport) -> Result<()> {
295 let mut violations = Vec::new();
296
297 if report.line_coverage < self.config.min_line_coverage {
298 violations.push(format!(
299 "Line coverage {:.1}% is below minimum {:.1}%",
300 report.line_coverage, self.config.min_line_coverage
301 ));
302 }
303
304 if report.function_coverage < self.config.min_function_coverage {
305 violations.push(format!(
306 "Function coverage {:.1}% is below minimum {:.1}%",
307 report.function_coverage, self.config.min_function_coverage
308 ));
309 }
310
311 if report.branch_coverage < self.config.min_branch_coverage {
312 violations.push(format!(
313 "Branch coverage {:.1}% is below minimum {:.1}%",
314 report.branch_coverage, self.config.min_branch_coverage
315 ));
316 }
317
318 if !violations.is_empty() {
319 let message = format!("Coverage violations:\n โข {}", violations.join("\n โข "));
320
321 if self.config.fail_on_low_coverage {
322 return Err(Error::validation(message));
323 }
324 tracing::warn!("{}", message);
325 }
326
327 Ok(())
328 }
329
330 pub fn format_coverage_report(&self, report: &CoverageReport) -> String {
332 let mut output = String::new();
333
334 output.push_str("๐ Test Coverage Report\n");
335 output.push_str("โโโโโโโโโโโโโโโโโโโโโโโ\n\n");
336
337 output.push_str(&format!("๐ Overall Coverage:\n"));
338 output.push_str(&format!(
339 " โข Lines: {:.1}% ({}/{})\n",
340 report.line_coverage, report.lines_tested, report.total_lines
341 ));
342 output.push_str(&format!(
343 " โข Functions: {:.1}% ({}/{})\n",
344 report.function_coverage, report.functions_tested, report.total_functions
345 ));
346 output.push_str(&format!(
347 " โข Branches: {:.1}% ({}/{})\n\n",
348 report.branch_coverage, report.branches_tested, report.total_branches
349 ));
350
351 let line_status = if report.line_coverage >= self.config.min_line_coverage {
353 "โ
"
354 } else {
355 "โ"
356 };
357 let func_status = if report.function_coverage >= self.config.min_function_coverage {
358 "โ
"
359 } else {
360 "โ"
361 };
362 let branch_status = if report.branch_coverage >= self.config.min_branch_coverage {
363 "โ
"
364 } else {
365 "โ"
366 };
367
368 output.push_str("๐ฏ Threshold Status:\n");
369 output.push_str(&format!(
370 " {} Lines: {:.1}% (min: {:.1}%)\n",
371 line_status, report.line_coverage, self.config.min_line_coverage
372 ));
373 output.push_str(&format!(
374 " {} Functions: {:.1}% (min: {:.1}%)\n",
375 func_status, report.function_coverage, self.config.min_function_coverage
376 ));
377 output.push_str(&format!(
378 " {} Branches: {:.1}% (min: {:.1}%)\n\n",
379 branch_status, report.branch_coverage, self.config.min_branch_coverage
380 ));
381
382 let mut low_coverage_files: Vec<_> = report
384 .file_coverage
385 .values()
386 .filter(|file| file.line_coverage < self.config.min_line_coverage)
387 .collect();
388 low_coverage_files.sort_by(|a, b| {
389 a.line_coverage
390 .partial_cmp(&b.line_coverage)
391 .unwrap_or(std::cmp::Ordering::Equal)
392 });
393
394 if !low_coverage_files.is_empty() {
395 output.push_str("โ ๏ธ Files Below Threshold:\n");
396 for file in low_coverage_files.iter().take(5) {
397 output.push_str(&format!(
398 " โข {}: {:.1}%\n",
399 file.file_path, file.line_coverage
400 ));
401 }
402 if low_coverage_files.len() > 5 {
403 output.push_str(&format!(
404 " ... and {} more files\n",
405 low_coverage_files.len() - 5
406 ));
407 }
408 output.push('\n');
409 }
410
411 output.push_str("๐ก To improve coverage:\n");
412 output.push_str(" โข Add tests for uncovered code paths\n");
413 output.push_str(" โข Remove dead code\n");
414 output.push_str(" โข Test error conditions and edge cases\n");
415 output.push_str(" โข Use property-based testing\n");
416
417 output
418 }
419
420 pub async fn check_project_coverage(&self, project_path: &Path) -> Result<()> {
422 println!("๐งช Checking test coverage...");
423
424 let report = self.run_coverage(project_path).await?;
425
426 println!("{}", self.format_coverage_report(&report));
427
428 self.validate_coverage(&report)?;
429
430 println!("โ
Coverage check completed successfully");
431 Ok(())
432 }
433}
434
435impl Default for CoverageAnalyzer {
436 fn default() -> Self {
437 Self::new()
438 }
439}
440
441#[cfg(test)]
442#[allow(clippy::expect_used, clippy::unwrap_used)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn test_coverage_config_default() {
448 let config = CoverageConfig::default();
449 assert_eq!(config.min_line_coverage, 80.0);
450 assert_eq!(config.min_function_coverage, 85.0);
451 assert_eq!(config.min_branch_coverage, 75.0);
452 assert!(config.fail_on_low_coverage);
453 }
454
455 #[test]
456 fn test_coverage_analyzer_creation() {
457 let analyzer = CoverageAnalyzer::new();
458 assert_eq!(analyzer.config.min_line_coverage, 80.0);
459
460 let custom_config = CoverageConfig {
461 min_line_coverage: 90.0,
462 ..Default::default()
463 };
464 let custom_analyzer = CoverageAnalyzer::with_config(custom_config);
465 assert_eq!(custom_analyzer.config.min_line_coverage, 90.0);
466 }
467
468 #[test]
469 fn test_validate_coverage_success() {
470 let analyzer = CoverageAnalyzer::new();
471 let report = CoverageReport {
472 line_coverage: 85.0,
473 function_coverage: 90.0,
474 branch_coverage: 80.0,
475 file_coverage: HashMap::new(),
476 lines_tested: 85,
477 total_lines: 100,
478 functions_tested: 18,
479 total_functions: 20,
480 branches_tested: 8,
481 total_branches: 10,
482 };
483
484 assert!(analyzer.validate_coverage(&report).is_ok());
485 }
486
487 #[test]
488 fn test_validate_coverage_failure() {
489 let analyzer = CoverageAnalyzer::new();
490 let report = CoverageReport {
491 line_coverage: 70.0, function_coverage: 90.0,
493 branch_coverage: 80.0,
494 file_coverage: HashMap::new(),
495 lines_tested: 70,
496 total_lines: 100,
497 functions_tested: 18,
498 total_functions: 20,
499 branches_tested: 8,
500 total_branches: 10,
501 };
502
503 assert!(analyzer.validate_coverage(&report).is_err());
504 }
505
506 #[test]
507 fn test_format_coverage_report() {
508 let analyzer = CoverageAnalyzer::new();
509 let report = CoverageReport {
510 line_coverage: 85.0,
511 function_coverage: 90.0,
512 branch_coverage: 80.0,
513 file_coverage: HashMap::new(),
514 lines_tested: 85,
515 total_lines: 100,
516 functions_tested: 18,
517 total_functions: 20,
518 branches_tested: 8,
519 total_branches: 10,
520 };
521
522 let formatted = analyzer.format_coverage_report(&report);
523 assert!(formatted.contains("Test Coverage Report"));
524 assert!(formatted.contains("85.0%"));
525 assert!(formatted.contains("90.0%"));
526 assert!(formatted.contains("80.0%"));
527 }
528}