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!("Failed to install cargo-tarpaulin: {}", stderr)));
144        }
145
146        tracing::info!("cargo-tarpaulin installed successfully");
147        Ok(())
148    }
149
150    /// Run test coverage analysis
151    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        // Add exclude directories
170        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    /// Parse cargo-tarpaulin JSON output
190    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            // Estimate function coverage (tarpaulin doesn't provide this directly)
226            let estimated_functions = (file_data.lines_total / 10).max(1); // Rough estimate
227            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 // Fallback to line coverage
262        };
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    /// Validate coverage meets minimum thresholds
279    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    /// Generate a human-readable coverage report
316    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        // Coverage status
331        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        // Top files with low coverage
344        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    /// Check coverage for a project
370    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, // Below 80% minimum
440            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}