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