Skip to main content

cargo_mate/tools/
coverage_guard.rs

1use super::{Tool, Result, ToolError, common_options, parse_output_format, OutputFormat};
2use clap::{Arg, ArgMatches, Command};
3use colored::*;
4use std::path::Path;
5use std::process::Command as ProcessCommand;
6use std::fs;
7use regex::Regex;
8#[derive(Debug, Clone)]
9pub struct CoverageGuardTool;
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11struct CoverageReport {
12    current_coverage: f64,
13    minimum_threshold: f64,
14    threshold_met: bool,
15    coverage_diff: Option<f64>,
16    branch: Option<String>,
17    commit: Option<String>,
18    timestamp: String,
19    details: CoverageDetails,
20}
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22struct CoverageDetails {
23    lines_covered: usize,
24    lines_total: usize,
25    functions_covered: usize,
26    functions_total: usize,
27    branches_covered: usize,
28    branches_total: usize,
29    files_analyzed: usize,
30}
31impl CoverageGuardTool {
32    pub fn new() -> Self {
33        Self
34    }
35    fn run_coverage_analysis(&self) -> Result<CoverageReport> {
36        if self.is_tool_available("grcov") {
37            self.run_grcov()
38        } else if self.is_tool_available("tarpaulin") {
39            self.run_tarpaulin()
40        } else if self.is_tool_available("cargo-llvm-cov") {
41            self.run_llvm_cov()
42        } else {
43            Err(
44                ToolError::ExecutionFailed(
45                    "No coverage tool found. Install grcov, tarpaulin, or cargo-llvm-cov"
46                        .to_string(),
47                ),
48            )
49        }
50    }
51    fn is_tool_available(&self, tool: &str) -> bool {
52        ProcessCommand::new(tool)
53            .arg("--version")
54            .output()
55            .map(|output| output.status.success())
56            .unwrap_or(false)
57    }
58    fn run_grcov(&self) -> Result<CoverageReport> {
59        let output = ProcessCommand::new("grcov")
60            .args(&[".", "--output-type", "lcov", "--output-path", "/tmp/coverage.lcov"])
61            .output()
62            .map_err(|e| ToolError::ExecutionFailed(
63                format!("Failed to run grcov: {}", e),
64            ))?;
65        if !output.status.success() {
66            return Err(ToolError::ExecutionFailed("grcov command failed".to_string()));
67        }
68        self.parse_lcov_file("/tmp/coverage.lcov")
69    }
70    fn run_tarpaulin(&self) -> Result<CoverageReport> {
71        let output = ProcessCommand::new("cargo")
72            .args(&["tarpaulin", "--out", "Json", "--output-dir", "/tmp"])
73            .output()
74            .map_err(|e| ToolError::ExecutionFailed(
75                format!("Failed to run tarpaulin: {}", e),
76            ))?;
77        if !output.status.success() {
78            return Err(
79                ToolError::ExecutionFailed("tarpaulin command failed".to_string()),
80            );
81        }
82        self.parse_tarpaulin_json("/tmp/tarpaulin-report.json")
83    }
84    fn run_llvm_cov(&self) -> Result<CoverageReport> {
85        let output = ProcessCommand::new("cargo")
86            .args(
87                &["llvm-cov", "report", "--json", "--output-path", "/tmp/coverage.json"],
88            )
89            .output()
90            .map_err(|e| ToolError::ExecutionFailed(
91                format!("Failed to run llvm-cov: {}", e),
92            ))?;
93        if !output.status.success() {
94            return Err(
95                ToolError::ExecutionFailed("llvm-cov command failed".to_string()),
96            );
97        }
98        self.parse_llvm_cov_json("/tmp/coverage.json")
99    }
100    fn parse_lcov_file(&self, file_path: &str) -> Result<CoverageReport> {
101        let content = fs::read_to_string(file_path).map_err(|e| ToolError::IoError(e))?;
102        let mut lines_total = 0;
103        let mut lines_covered = 0;
104        let mut functions_total = 0;
105        let mut functions_covered = 0;
106        let mut branches_total = 0;
107        let mut branches_covered = 0;
108        let mut files_count = 0;
109        let lines_regex = Regex::new(r"LF:(\d+)").unwrap();
110        let lines_hit_regex = Regex::new(r"LH:(\d+)").unwrap();
111        let functions_regex = Regex::new(r"FNF:(\d+)").unwrap();
112        let functions_hit_regex = Regex::new(r"FNH:(\d+)").unwrap();
113        let branches_regex = Regex::new(r"BRF:(\d+)").unwrap();
114        let branches_hit_regex = Regex::new(r"BRH:(\d+)").unwrap();
115        for line in content.lines() {
116            if line.starts_with("SF:") {
117                files_count += 1;
118            } else if let Some(captures) = lines_regex.captures(line) {
119                lines_total += captures[1].parse::<usize>().unwrap_or(0);
120            } else if let Some(captures) = lines_hit_regex.captures(line) {
121                lines_covered += captures[1].parse::<usize>().unwrap_or(0);
122            } else if let Some(captures) = functions_regex.captures(line) {
123                functions_total += captures[1].parse::<usize>().unwrap_or(0);
124            } else if let Some(captures) = functions_hit_regex.captures(line) {
125                functions_covered += captures[1].parse::<usize>().unwrap_or(0);
126            } else if let Some(captures) = branches_regex.captures(line) {
127                branches_total += captures[1].parse::<usize>().unwrap_or(0);
128            } else if let Some(captures) = branches_hit_regex.captures(line) {
129                branches_covered += captures[1].parse::<usize>().unwrap_or(0);
130            }
131        }
132        let coverage_percentage = if lines_total > 0 {
133            (lines_covered as f64 / lines_total as f64) * 100.0
134        } else {
135            0.0
136        };
137        let details = CoverageDetails {
138            lines_covered,
139            lines_total,
140            functions_covered,
141            functions_total,
142            branches_covered,
143            branches_total,
144            files_analyzed: files_count,
145        };
146        Ok(CoverageReport {
147            current_coverage: coverage_percentage,
148            minimum_threshold: 80.0,
149            threshold_met: coverage_percentage >= 80.0,
150            coverage_diff: None,
151            branch: self.get_current_branch(),
152            commit: self.get_current_commit(),
153            timestamp: chrono::Utc::now().to_rfc3339(),
154            details,
155        })
156    }
157    fn parse_tarpaulin_json(&self, file_path: &str) -> Result<CoverageReport> {
158        let content = fs::read_to_string(file_path).map_err(|e| ToolError::IoError(e))?;
159        let json: serde_json::Value = serde_json::from_str(&content)
160            .map_err(|e| ToolError::ExecutionFailed(
161                format!("Failed to parse JSON: {}", e),
162            ))?;
163        let coverage_percentage = json["coverage_percentage"].as_f64().unwrap_or(0.0);
164        let lines_covered = json["covered_lines"].as_u64().unwrap_or(0) as usize;
165        let lines_total = json["total_lines"].as_u64().unwrap_or(0) as usize;
166        let details = CoverageDetails {
167            lines_covered,
168            lines_total,
169            functions_covered: 0,
170            functions_total: 0,
171            branches_covered: 0,
172            branches_total: 0,
173            files_analyzed: 1,
174        };
175        Ok(CoverageReport {
176            current_coverage: coverage_percentage,
177            minimum_threshold: 80.0,
178            threshold_met: coverage_percentage >= 80.0,
179            coverage_diff: None,
180            branch: self.get_current_branch(),
181            commit: self.get_current_commit(),
182            timestamp: chrono::Utc::now().to_rfc3339(),
183            details,
184        })
185    }
186    fn parse_llvm_cov_json(&self, file_path: &str) -> Result<CoverageReport> {
187        let content = fs::read_to_string(file_path).map_err(|e| ToolError::IoError(e))?;
188        let json: serde_json::Value = serde_json::from_str(&content)
189            .map_err(|e| ToolError::ExecutionFailed(
190                format!("Failed to parse JSON: {}", e),
191            ))?;
192        let data = json["data"][0]["totals"].clone();
193        let lines_covered = data["lines"]["covered"].as_u64().unwrap_or(0) as usize;
194        let lines_total = data["lines"]["count"].as_u64().unwrap_or(0) as usize;
195        let functions_covered = data["functions"]["covered"].as_u64().unwrap_or(0)
196            as usize;
197        let functions_total = data["functions"]["count"].as_u64().unwrap_or(0) as usize;
198        let branches_covered = data["branches"]["covered"].as_u64().unwrap_or(0)
199            as usize;
200        let branches_total = data["branches"]["count"].as_u64().unwrap_or(0) as usize;
201        let coverage_percentage = if lines_total > 0 {
202            (lines_covered as f64 / lines_total as f64) * 100.0
203        } else {
204            0.0
205        };
206        let details = CoverageDetails {
207            lines_covered,
208            lines_total,
209            functions_covered,
210            functions_total,
211            branches_covered,
212            branches_total,
213            files_analyzed: 1,
214        };
215        Ok(CoverageReport {
216            current_coverage: coverage_percentage,
217            minimum_threshold: 80.0,
218            threshold_met: coverage_percentage >= 80.0,
219            coverage_diff: None,
220            branch: self.get_current_branch(),
221            commit: self.get_current_commit(),
222            timestamp: chrono::Utc::now().to_rfc3339(),
223            details,
224        })
225    }
226    fn get_current_branch(&self) -> Option<String> {
227        ProcessCommand::new("git")
228            .args(&["rev-parse", "--abbrev-ref", "HEAD"])
229            .output()
230            .ok()
231            .filter(|output| output.status.success())
232            .and_then(|output| {
233                String::from_utf8_lossy(&output.stdout).trim().to_string().into()
234            })
235    }
236    fn get_current_commit(&self) -> Option<String> {
237        ProcessCommand::new("git")
238            .args(&["rev-parse", "HEAD"])
239            .output()
240            .ok()
241            .filter(|output| output.status.success())
242            .and_then(|output| {
243                String::from_utf8_lossy(&output.stdout).trim()[..8].to_string().into()
244            })
245    }
246    fn get_baseline_coverage(&self, baseline: &str) -> Result<f64> {
247        let output = ProcessCommand::new("git")
248            .args(&["show", &format!("{}:coverage.json", baseline)])
249            .output()
250            .map_err(|e| ToolError::ExecutionFailed(
251                format!("Failed to get baseline: {}", e),
252            ))?;
253        if !output.status.success() {
254            return Err(
255                ToolError::ExecutionFailed(
256                    format!(
257                        "Baseline commit {} not found or missing coverage data", baseline
258                    ),
259                ),
260            );
261        }
262        let content = String::from_utf8_lossy(&output.stdout);
263        let json: serde_json::Value = serde_json::from_str(&content)
264            .map_err(|e| ToolError::ExecutionFailed(
265                format!("Failed to parse baseline JSON: {}", e),
266            ))?;
267        Ok(json["current_coverage"].as_f64().unwrap_or(0.0))
268    }
269    fn display_report(
270        &self,
271        report: &CoverageReport,
272        output_format: OutputFormat,
273        verbose: bool,
274    ) {
275        match output_format {
276            OutputFormat::Human => {
277                println!("\n{}", "šŸ“Š Coverage Guard Report".bold().blue());
278                println!("{}", "═".repeat(50).blue());
279                println!("\nšŸ“ˆ Current Coverage: {:.2}%", report.current_coverage);
280                if let Some(threshold) = report.minimum_threshold.into() {
281                    println!("šŸŽÆ Minimum Threshold: {:.2}%", threshold);
282                }
283                let status = if report.threshold_met {
284                    "āœ… PASSED".green()
285                } else {
286                    "āŒ FAILED".red()
287                };
288                println!("šŸ“‹ Status: {}", status);
289                if let Some(branch) = &report.branch {
290                    println!("🌿 Branch: {}", branch);
291                }
292                if let Some(commit) = &report.commit {
293                    println!("šŸ”— Commit: {}", commit);
294                }
295                if verbose {
296                    println!("\nšŸ“Š Detailed Metrics:");
297                    println!(
298                        "  • Lines: {}/{} ({:.2}%)", report.details.lines_covered,
299                        report.details.lines_total, if report.details.lines_total > 0 {
300                        (report.details.lines_covered as f64 / report.details.lines_total
301                        as f64) * 100.0 } else { 0.0 }
302                    );
303                    if report.details.functions_total > 0 {
304                        println!(
305                            "  • Functions: {}/{} ({:.2}%)", report.details
306                            .functions_covered, report.details.functions_total, (report
307                            .details.functions_covered as f64 / report.details
308                            .functions_total as f64) * 100.0
309                        );
310                    }
311                    if report.details.branches_total > 0 {
312                        println!(
313                            "  • Branches: {}/{} ({:.2}%)", report.details
314                            .branches_covered, report.details.branches_total, (report
315                            .details.branches_covered as f64 / report.details
316                            .branches_total as f64) * 100.0
317                        );
318                    }
319                    println!("  • Files Analyzed: {}", report.details.files_analyzed);
320                }
321                if let Some(diff) = report.coverage_diff {
322                    let diff_status = if diff >= 0.0 {
323                        format!("+{:.2}%", diff).green()
324                    } else {
325                        format!("{:.2}%", diff).red()
326                    };
327                    println!("šŸ“Š Coverage Change: {}", diff_status);
328                }
329                if !report.threshold_met {
330                    println!(
331                        "\n{}", "āš ļø  Coverage threshold not met!".yellow().bold()
332                    );
333                    println!("šŸ’” To fix this:");
334                    println!("   1. Add more tests to increase coverage");
335                    println!("   2. Use --threshold to set a custom minimum");
336                    println!("   3. Use --baseline to compare against previous commit");
337                }
338            }
339            OutputFormat::Json => {
340                let json = serde_json::to_string_pretty(report)
341                    .unwrap_or_else(|_| "{}".to_string());
342                println!("{}", json);
343            }
344            OutputFormat::Table => {
345                println!(
346                    "{:<20} {:<15} {:<15} {:<10}", "Metric", "Covered", "Total",
347                    "Percentage"
348                );
349                println!("{}", "─".repeat(70));
350                println!(
351                    "{:<20} {:<15} {:<15} {:.2}%", "Lines", report.details.lines_covered
352                    .to_string(), report.details.lines_total.to_string(), if report
353                    .details.lines_total > 0 { (report.details.lines_covered as f64 /
354                    report.details.lines_total as f64) * 100.0 } else { 0.0 }
355                );
356                if report.details.functions_total > 0 {
357                    println!(
358                        "{:<20} {:<15} {:<15} {:.2}%", "Functions", report.details
359                        .functions_covered.to_string(), report.details.functions_total
360                        .to_string(), (report.details.functions_covered as f64 / report
361                        .details.functions_total as f64) * 100.0
362                    );
363                }
364                if report.details.branches_total > 0 {
365                    println!(
366                        "{:<20} {:<15} {:<15} {:.2}%", "Branches", report.details
367                        .branches_covered.to_string(), report.details.branches_total
368                        .to_string(), (report.details.branches_covered as f64 / report
369                        .details.branches_total as f64) * 100.0
370                    );
371                }
372            }
373        }
374    }
375}
376impl Tool for CoverageGuardTool {
377    fn name(&self) -> &'static str {
378        "coverage-guard"
379    }
380    fn description(&self) -> &'static str {
381        "Block commits/PRs if coverage drops below threshold"
382    }
383    fn command(&self) -> Command {
384        Command::new(self.name())
385            .about(self.description())
386            .long_about(
387                "Monitor code coverage and prevent commits when coverage drops below \
388                        acceptable thresholds. Supports multiple coverage tools and CI/CD integration.
389
390EXAMPLES:
391    cm tool coverage-guard --threshold 85.0
392    cm tool coverage-guard --baseline main --fail-on-drop
393    cm tool coverage-guard --tool grcov --output-format json",
394            )
395            .args(
396                &[
397                    Arg::new("threshold")
398                        .long("threshold")
399                        .short('t')
400                        .help("Minimum coverage threshold (percentage)")
401                        .default_value("80.0"),
402                    Arg::new("baseline")
403                        .long("baseline")
404                        .short('b')
405                        .help("Baseline commit/branch for comparison"),
406                    Arg::new("tool")
407                        .long("tool")
408                        .help("Coverage tool to use (grcov, tarpaulin, llvm-cov)")
409                        .default_value("auto"),
410                    Arg::new("fail-on-drop")
411                        .long("fail-on-drop")
412                        .help("Fail if coverage drops from baseline")
413                        .action(clap::ArgAction::SetTrue),
414                    Arg::new("store-baseline")
415                        .long("store-baseline")
416                        .help("Store current coverage as baseline")
417                        .action(clap::ArgAction::SetTrue),
418                    Arg::new("ci-mode")
419                        .long("ci-mode")
420                        .help("CI-friendly output format")
421                        .action(clap::ArgAction::SetTrue),
422                ],
423            )
424            .args(&common_options())
425    }
426    fn execute(&self, matches: &ArgMatches) -> Result<()> {
427        let threshold = matches
428            .get_one::<String>("threshold")
429            .unwrap()
430            .parse::<f64>()
431            .map_err(|_| ToolError::InvalidArguments(
432                "Invalid threshold value".to_string(),
433            ))?;
434        let baseline = matches.get_one::<String>("baseline");
435        let tool = matches.get_one::<String>("tool").unwrap();
436        let fail_on_drop = matches.get_flag("fail-on-drop");
437        let store_baseline = matches.get_flag("store-baseline");
438        let ci_mode = matches.get_flag("ci-mode");
439        let output_format = parse_output_format(matches);
440        let verbose = matches.get_flag("verbose");
441        let mut report = self.run_coverage_analysis()?;
442        report.minimum_threshold = threshold;
443        report.threshold_met = report.current_coverage >= threshold;
444        if let Some(baseline_commit) = baseline {
445            match self.get_baseline_coverage(baseline_commit) {
446                Ok(baseline_cov) => {
447                    report.coverage_diff = Some(report.current_coverage - baseline_cov);
448                    if fail_on_drop && report.coverage_diff.unwrap() < 0.0 {
449                        report.threshold_met = false;
450                    }
451                }
452                Err(e) => {
453                    if verbose {
454                        println!("āš ļø  Could not get baseline coverage: {}", e);
455                    }
456                }
457            }
458        }
459        if store_baseline {
460            let baseline_data = serde_json::to_string_pretty(&report)
461                .map_err(|e| ToolError::ExecutionFailed(
462                    format!("Failed to serialize: {}", e),
463                ))?;
464            fs::write("coverage.json", baseline_data)
465                .map_err(|e| ToolError::IoError(e))?;
466            println!("āœ… Baseline coverage stored in coverage.json");
467        }
468        self.display_report(&report, output_format, verbose);
469        if ci_mode {
470            if report.threshold_met {
471                println!("::set-output name=coverage-passed::true");
472                println!(
473                    "::set-output name=coverage-percentage::{:.2}", report
474                    .current_coverage
475                );
476            } else {
477                println!("::set-output name=coverage-passed::false");
478                println!(
479                    "::set-output name=coverage-percentage::{:.2}", report
480                    .current_coverage
481                );
482                println!(
483                    "::error title=Coverage Check Failed::Coverage {:.2}% is below threshold {:.2}%",
484                    report.current_coverage, threshold
485                );
486            }
487        }
488        if !report.threshold_met {
489            std::process::exit(1);
490        }
491        Ok(())
492    }
493}
494impl Default for CoverageGuardTool {
495    fn default() -> Self {
496        Self::new()
497    }
498}