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        // Use a known output directory so we can reliably read the JSON report file.
92        let report_dir = project_path.join("target").join("tarpaulin");
93        tokio::fs::create_dir_all(&report_dir)
94            .await
95            .map_err(|e| Error::process(format!("Failed to create tarpaulin output dir: {e}")))?;
96
97        let mut args = vec![
98            "tarpaulin".to_string(),
99            "--timeout".to_string(),
100            "120".to_string(),
101            "--skip-clean".to_string(),
102            "--out".to_string(),
103            "Json".to_string(),
104            "--output-dir".to_string(),
105            report_dir.display().to_string(),
106        ];
107
108        // Each exclude file must be its own --exclude-files argument (tarpaulin doesn't accept comma-separated)
109        for exclude_file in &self.config.exclude_files {
110            args.push("--exclude-files".to_string());
111            args.push(exclude_file.clone());
112        }
113
114        // Add exclude directories
115        for exclude_dir in &self.config.exclude_dirs {
116            args.push("--exclude-files".to_string());
117            args.push(exclude_dir.clone());
118        }
119
120        let output = tokio::process::Command::new("cargo")
121            .args(&args)
122            .current_dir(project_path)
123            .output()
124            .await
125            .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {e}")))?;
126
127        let exit_code = output.status.code().unwrap_or(-1);
128        let stderr = String::from_utf8_lossy(&output.stderr);
129
130        // `--out Json` writes to a file, not stdout.  Read the report file.
131        let report_file = report_dir.join("tarpaulin-report.json");
132        let json_content = match tokio::fs::read_to_string(&report_file).await {
133            Ok(content) => content,
134            Err(read_err) => {
135                // No JSON file produced — surface both the read error and tarpaulin's stderr
136                return Err(Error::process(format!(
137                    "Tarpaulin produced no JSON report (exit code {exit_code}). \
138                     File error: {read_err}. Tarpaulin stderr: {stderr}"
139                )));
140            }
141        };
142
143        // Attempt to parse even on non-zero exit — tarpaulin sometimes exits
144        // non-zero on certain rustc versions despite all tests passing.
145        match self.parse_tarpaulin_output(&json_content) {
146            Ok(report) => {
147                if !output.status.success() {
148                    tracing::warn!(
149                        "Tarpaulin exited with code {exit_code} but produced a valid report. \
150                         stderr: {stderr}"
151                    );
152                }
153                Ok(report)
154            }
155            Err(parse_err) => Err(Error::process(format!(
156                "Failed to parse tarpaulin JSON (exit code {exit_code}): {parse_err}. \
157                 Tarpaulin stderr: {stderr}"
158            ))),
159        }
160    }
161
162    /// Parse cargo-tarpaulin JSON output
163    fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
164        let tarpaulin_data = parse_tarpaulin_json(output)?;
165        let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
166        let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
167
168        Ok(CoverageReport {
169            line_coverage: tarpaulin_data.line_coverage,
170            function_coverage: function_stats.coverage,
171            branch_coverage,
172            file_coverage,
173            lines_tested: tarpaulin_data.lines_covered,
174            total_lines: tarpaulin_data.lines_total,
175            functions_tested: function_stats.tested,
176            total_functions: function_stats.total,
177            branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
178            total_branches: tarpaulin_data.branches_total.unwrap_or(0),
179        })
180    }
181
182    /// Get config reference
183    pub fn config(&self) -> &CoverageConfig {
184        &self.config
185    }
186
187    /// Run tarpaulin and get coverage report
188    ///
189    /// This is a convenience wrapper around `run_coverage` that's more explicit
190    /// about running tarpaulin
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if `cargo-tarpaulin` is not installed or the coverage
195    /// run fails.
196    pub async fn run_tarpaulin(&self, project_path: &Path) -> Result<CoverageReport> {
197        self.run_coverage(project_path).await
198    }
199
200    /// Parse a coverage report from tarpaulin output
201    ///
202    /// Parses the JSON output from `cargo-tarpaulin` and converts it to our
203    /// `CoverageReport` format
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the JSON output cannot be parsed.
208    pub fn parse_coverage_report(&self, tarpaulin_output: &str) -> Result<CoverageReport> {
209        self.parse_tarpaulin_output(tarpaulin_output)
210    }
211
212    /// Enforce minimum coverage threshold
213    ///
214    /// Returns an error if the coverage is below the specified threshold.
215    ///
216    /// # Errors
217    ///
218    /// Returns a validation error if `report.line_coverage` is below `threshold`.
219    pub fn enforce_minimum_coverage(&self, report: &CoverageReport, threshold: f64) -> Result<()> {
220        if report.line_coverage < threshold {
221            return Err(Error::validation(format!(
222                "Test coverage {:.1}% is below minimum threshold of {:.1}%",
223                report.line_coverage, threshold
224            )));
225        }
226        Ok(())
227    }
228
229    /// Generate a coverage badge SVG
230    ///
231    /// Creates an SVG badge showing the current test coverage percentage
232    pub fn generate_coverage_badge(&self, report: &CoverageReport) -> String {
233        let coverage = report.line_coverage;
234        let color = match coverage {
235            c if c >= 80.0 => "#4c1",    // Green
236            c if c >= 60.0 => "#dfb317", // Yellow
237            c if c >= 40.0 => "#fe7d37", // Orange
238            _ => "#e05d44",              // Red
239        };
240
241        format!(
242            r##"<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20">
243                <linearGradient id="a" x2="0" y2="100%">
244                    <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
245                    <stop offset="1" stop-opacity=".1"/>
246                </linearGradient>
247                <rect rx="3" width="114" height="20" fill="#555"/>
248                <rect rx="3" x="63" width="51" height="20" fill="{}"/>
249                <path fill="{}" d="M63 0h4v20h-4z"/>
250                <rect rx="3" width="114" height="20" fill="url(#a)"/>
251                <g fill="#fff" text-anchor="middle" 
252                   font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
253                    <text x="32" y="15" fill="#010101" fill-opacity=".3">coverage</text>
254                    <text x="32" y="14">coverage</text>
255                    <text x="87" y="15" fill="#010101" fill-opacity=".3">{:.1}%</text>
256                    <text x="87" y="14">{:.1}%</text>
257                </g>
258            </svg>"##,
259            color, color, coverage, coverage
260        )
261    }
262}
263
264impl Default for CoverageAnalyzer {
265    fn default() -> Self {
266        Self::new()
267    }
268}