Skip to main content

ferrous_forge/test_coverage/
analyzer.rs

1//! Test coverage analyzer implementation
2
3use super::types::{CoverageConfig, CoverageReport};
4use super::utils::{calculate_branch_coverage, parse_tarpaulin_json, process_file_coverage};
5use crate::{Error, Result};
6use std::path::Path;
7use std::process::Command;
8
9/// Test coverage analyzer
10pub struct CoverageAnalyzer {
11    /// Coverage configuration
12    config: CoverageConfig,
13}
14
15impl CoverageAnalyzer {
16    /// Create a new coverage analyzer with default configuration
17    pub fn new() -> Self {
18        Self {
19            config: CoverageConfig::default(),
20        }
21    }
22
23    /// Create a new coverage analyzer with custom configuration
24    pub fn with_config(config: CoverageConfig) -> Self {
25        Self { config }
26    }
27
28    /// Check if cargo-tarpaulin is installed
29    pub fn check_tarpaulin_installed(&self) -> Result<bool> {
30        let output = Command::new("cargo")
31            .args(["tarpaulin", "--version"])
32            .output();
33
34        match output {
35            Ok(output) => Ok(output.status.success()),
36            Err(_) => Ok(false),
37        }
38    }
39
40    /// Install cargo-tarpaulin if not already installed
41    pub async fn install_tarpaulin(&self) -> Result<()> {
42        if self.check_tarpaulin_installed()? {
43            tracing::info!("cargo-tarpaulin already installed");
44            return Ok(());
45        }
46
47        tracing::info!("Installing cargo-tarpaulin...");
48
49        let output = Command::new("cargo")
50            .args(["install", "cargo-tarpaulin"])
51            .output()
52            .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
53
54        if !output.status.success() {
55            let stderr = String::from_utf8_lossy(&output.stderr);
56            return Err(Error::process(format!(
57                "Failed to install cargo-tarpaulin: {}",
58                stderr
59            )));
60        }
61
62        tracing::info!("cargo-tarpaulin installed successfully");
63        Ok(())
64    }
65
66    /// Run test coverage analysis
67    pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
68        if !self.check_tarpaulin_installed()? {
69            return Err(Error::validation(
70                "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
71            ));
72        }
73
74        tracing::info!("Running test coverage analysis...");
75
76        let exclude_files_str = self.config.exclude_files.join(",");
77        let mut args = vec![
78            "tarpaulin",
79            "--verbose",
80            "--timeout",
81            "120",
82            "--out",
83            "Json",
84            "--exclude-files",
85            &exclude_files_str,
86        ];
87
88        // Add exclude directories
89        for exclude_dir in &self.config.exclude_dirs {
90            args.extend_from_slice(&["--exclude-files", exclude_dir]);
91        }
92
93        let output = Command::new("cargo")
94            .args(&args)
95            .current_dir(project_path)
96            .output()
97            .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
98
99        if !output.status.success() {
100            let stderr = String::from_utf8_lossy(&output.stderr);
101            return Err(Error::process(format!(
102                "cargo tarpaulin failed: {}",
103                stderr
104            )));
105        }
106
107        let stdout = String::from_utf8_lossy(&output.stdout);
108        self.parse_tarpaulin_output(&stdout)
109    }
110
111    /// Parse cargo-tarpaulin JSON output
112    fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
113        let tarpaulin_data = parse_tarpaulin_json(output)?;
114        let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
115        let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
116
117        Ok(CoverageReport {
118            line_coverage: tarpaulin_data.line_coverage,
119            function_coverage: function_stats.coverage,
120            branch_coverage,
121            file_coverage,
122            lines_tested: tarpaulin_data.lines_covered,
123            total_lines: tarpaulin_data.lines_total,
124            functions_tested: function_stats.tested,
125            total_functions: function_stats.total,
126            branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
127            total_branches: tarpaulin_data.branches_total.unwrap_or(0),
128        })
129    }
130
131    /// Get config reference
132    pub fn config(&self) -> &CoverageConfig {
133        &self.config
134    }
135
136    /// Run tarpaulin and get coverage report
137    ///
138    /// This is a convenience wrapper around run_coverage that's more explicit
139    /// about running tarpaulin
140    pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
141        self.run_coverage(project_path).await
142    }
143
144    /// Parse a coverage report from tarpaulin output
145    ///
146    /// Parses the JSON output from cargo-tarpaulin and converts it to our
147    /// CoverageReport format
148    pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
149        self.parse_tarpaulin_output(tarpaulin_output)
150    }
151
152    /// Enforce minimum coverage threshold
153    ///
154    /// Returns an error if the coverage is below the specified threshold
155    pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
156        if report.line_coverage < threshold {
157            return Err(Error::validation(format!(
158                "Test coverage {:.1}% is below minimum threshold of {:.1}%",
159                report.line_coverage, threshold
160            )));
161        }
162        Ok(())
163    }
164
165    /// Generate a coverage badge SVG
166    ///
167    /// Creates an SVG badge showing the current test coverage percentage
168    pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
169        let coverage = report.line_coverage;
170        let color = match coverage {
171            c if c >= 80.0 => "#4c1",    // Green
172            c if c >= 60.0 => "#dfb317", // Yellow
173            c if c >= 40.0 => "#fe7d37", // Orange
174            _ => "#e05d44",              // Red
175        };
176
177        format!(
178            r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
179                <linearGradient id="a" x2="0" y2="100%">
180                    <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
181                    <stop offset="1" stop-opacity=".1"/>
182                </linearGradient>
183                <rect rx="3" width="114" height="20" fill="#555"/>
184                <rect rx="3" x="63" width="51" height="20" fill="{}"/>
185                <path fill="{}" d="M63 0h4v20h-4z"/>
186                <rect rx="3" width="114" height="20" fill="url(#a)"/>
187                <g fill="#fff" text-anchor="middle" 
188                   font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
189                    <text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
190                    <text x="32" y="14">coverage</text>
191                    <text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
192                    <text x="87" y="14">{:.1}%</text>
193                </g>
194            </svg>"##,
195            color, color, coverage, coverage
196        )
197    }
198}
199
200impl Default for CoverageAnalyzer {
201    fn default() -> Self {
202        Self::new()
203    }
204}