ferrous_forge/
test_coverage.rs

1//! Test coverage integration with cargo-tarpaulin
2//!
3//! This module provides functionality to measure test coverage using cargo-tarpaulin
4//! and enforce minimum coverage thresholds as part of Ferrous Forge standards.
5
6use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use std::process::Command;
11
12/// Test coverage configuration
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CoverageConfig {
15    /// Minimum line coverage percentage (0-100)
16    pub min_line_coverage: f64,
17    /// Minimum function coverage percentage (0-100)
18    pub min_function_coverage: f64,
19    /// Minimum branch coverage percentage (0-100)
20    pub min_branch_coverage: f64,
21    /// Whether to fail builds on coverage below threshold
22    pub fail_on_low_coverage: bool,
23    /// Files to exclude from coverage analysis
24    pub exclude_files: Vec<String>,
25    /// Directories to exclude from coverage analysis
26    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/// Test coverage results
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CoverageReport {
55    /// Overall line coverage percentage
56    pub line_coverage: f64,
57    /// Overall function coverage percentage  
58    pub function_coverage: f64,
59    /// Overall branch coverage percentage
60    pub branch_coverage: f64,
61    /// Per-file coverage breakdown
62    pub file_coverage: HashMap<String, FileCoverage>,
63    /// Total lines tested
64    pub lines_tested: u32,
65    /// Total lines in codebase
66    pub total_lines: u32,
67    /// Functions tested
68    pub functions_tested: u32,
69    /// Total functions
70    pub total_functions: u32,
71    /// Branches tested
72    pub branches_tested: u32,
73    /// Total branches
74    pub total_branches: u32,
75}
76
77/// Coverage information for a single file
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FileCoverage {
80    /// File path relative to project root
81    pub file_path: String,
82    /// Line coverage percentage for this file
83    pub line_coverage: f64,
84    /// Function coverage percentage for this file
85    pub function_coverage: f64,
86    /// Lines tested in this file
87    pub lines_tested: u32,
88    /// Total lines in this file
89    pub total_lines: u32,
90    /// Functions tested in this file
91    pub functions_tested: u32,
92    /// Total functions in this file
93    pub total_functions: u32,
94}
95
96/// Test coverage analyzer
97pub struct CoverageAnalyzer {
98    /// Coverage configuration
99    config: CoverageConfig,
100}
101
102impl CoverageAnalyzer {
103    /// Create a new coverage analyzer with default configuration
104    pub fn new() -> Self {
105        Self {
106            config: CoverageConfig::default(),
107        }
108    }
109
110    /// Create a new coverage analyzer with custom configuration
111    pub fn with_config(config: CoverageConfig) -> Self {
112        Self { config }
113    }
114
115    /// Check if cargo-tarpaulin is installed
116    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    /// Install cargo-tarpaulin if not already installed
128    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    /// Run test coverage analysis
154    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        // Add exclude directories
176        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    /// Parse cargo-tarpaulin JSON output
199    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            // Estimate function coverage (tarpaulin doesn't provide this directly)
235            let estimated_functions = (file_data.lines_total / 10).max(1); // Rough estimate
236            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 // Fallback to line coverage
277        };
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    /// Validate coverage meets minimum thresholds
294    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    /// Generate a human-readable coverage report
331    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        // Coverage status
352        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        // Top files with low coverage
383        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    /// Check coverage for a project
421    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, // Below 80% minimum
492            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}