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