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}