ferrous_forge/test_coverage/
analyzer.rs

1//! Test coverage analyzer implementation
2
3use super::types::{CoverageConfig, CoverageReport, FileCoverage};
4use crate::{Error, Result};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::path::Path;
8use std::process::Command;
9
10/// Test coverage analyzer
11pub struct CoverageAnalyzer {
12    /// Coverage configuration
13    config: CoverageConfig,
14}
15
16impl CoverageAnalyzer {
17    /// Create a new coverage analyzer with default configuration
18    pub fn new() -> Self {
19        Self {
20            config: CoverageConfig::default(),
21        }
22    }
23
24    /// Create a new coverage analyzer with custom configuration
25    pub fn with_config(config: CoverageConfig) -> Self {
26        Self { config }
27    }
28
29    /// Check if cargo-tarpaulin is installed
30    pub fn check_tarpaulin_installed(&self) -> Result<bool> {
31        let output = Command::new("cargo")
32            .args(["tarpaulin", "--version"])
33            .output();
34
35        match output {
36            Ok(output) => Ok(output.status.success()),
37            Err(_) => Ok(false),
38        }
39    }
40
41    /// Install cargo-tarpaulin if not already installed
42    pub async fn install_tarpaulin(&self) -> Result<()> {
43        if self.check_tarpaulin_installed()? {
44            tracing::info!("cargo-tarpaulin already installed");
45            return Ok(());
46        }
47
48        tracing::info!("Installing cargo-tarpaulin...");
49
50        let output = Command::new("cargo")
51            .args(["install", "cargo-tarpaulin"])
52            .output()
53            .map_err(|e| Error::process(format!("Failed to run cargo install: {}", e)))?;
54
55        if !output.status.success() {
56            let stderr = String::from_utf8_lossy(&output.stderr);
57            return Err(Error::process(format!(
58                "Failed to install cargo-tarpaulin: {}",
59                stderr
60            )));
61        }
62
63        tracing::info!("cargo-tarpaulin installed successfully");
64        Ok(())
65    }
66
67    /// Run test coverage analysis
68    pub async fn run_coverage(&self, project_path: &Path) -> Result<CoverageReport> {
69        if !self.check_tarpaulin_installed()? {
70            return Err(Error::validation(
71                "cargo-tarpaulin not installed. Run 'cargo install cargo-tarpaulin' first.",
72            ));
73        }
74
75        tracing::info!("Running test coverage analysis...");
76
77        let exclude_files_str = self.config.exclude_files.join(",");
78        let mut args = vec![
79            "tarpaulin",
80            "--verbose",
81            "--timeout",
82            "120",
83            "--out",
84            "Json",
85            "--exclude-files",
86            &exclude_files_str,
87        ];
88
89        // Add exclude directories
90        for exclude_dir in &self.config.exclude_dirs {
91            args.extend_from_slice(&["--exclude-files", exclude_dir]);
92        }
93
94        let output = Command::new("cargo")
95            .args(&args)
96            .current_dir(project_path)
97            .output()
98            .map_err(|e| Error::process(format!("Failed to run cargo tarpaulin: {}", e)))?;
99
100        if !output.status.success() {
101            let stderr = String::from_utf8_lossy(&output.stderr);
102            return Err(Error::process(format!(
103                "cargo tarpaulin failed: {}",
104                stderr
105            )));
106        }
107
108        let stdout = String::from_utf8_lossy(&output.stdout);
109        self.parse_tarpaulin_output(&stdout)
110    }
111
112    /// Parse cargo-tarpaulin JSON output
113    fn parse_tarpaulin_output(&self, output: &str) -> Result<CoverageReport> {
114        let tarpaulin_data = parse_tarpaulin_json(output)?;
115        let (file_coverage, function_stats) = process_file_coverage(&tarpaulin_data.files);
116        let branch_coverage = calculate_branch_coverage(&tarpaulin_data);
117
118        Ok(CoverageReport {
119            line_coverage: tarpaulin_data.line_coverage,
120            function_coverage: function_stats.coverage,
121            branch_coverage,
122            file_coverage,
123            lines_tested: tarpaulin_data.lines_covered,
124            total_lines: tarpaulin_data.lines_total,
125            functions_tested: function_stats.tested,
126            total_functions: function_stats.total,
127            branches_tested: tarpaulin_data.branches_covered.unwrap_or(0),
128            total_branches: tarpaulin_data.branches_total.unwrap_or(0),
129        })
130    }
131
132    /// Get config reference
133    pub fn config(&self) -> &CoverageConfig {
134        &self.config
135    }
136}
137
138impl Default for CoverageAnalyzer {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144/// Tarpaulin JSON output structure
145#[derive(Deserialize)]
146struct TarpaulinOutput {
147    #[serde(rename = "coverage")]
148    line_coverage: f64,
149    #[serde(rename = "linesCovered")]
150    lines_covered: u32,
151    #[serde(rename = "linesTotal")]
152    lines_total: u32,
153    #[serde(rename = "branchesCovered")]
154    branches_covered: Option<u32>,
155    #[serde(rename = "branchesTotal")]
156    branches_total: Option<u32>,
157    #[serde(rename = "files")]
158    files: HashMap<String, TarpaulinFile>,
159}
160
161/// Tarpaulin file coverage data
162#[derive(Deserialize)]
163struct TarpaulinFile {
164    #[serde(rename = "coverage")]
165    line_coverage: f64,
166    #[serde(rename = "linesCovered")]
167    lines_covered: u32,
168    #[serde(rename = "linesTotal")]
169    lines_total: u32,
170}
171
172/// Function coverage statistics
173struct FunctionStats {
174    coverage: f64,
175    tested: u32,
176    total: u32,
177}
178
179/// Parse tarpaulin JSON output
180fn parse_tarpaulin_json(output: &str) -> Result<TarpaulinOutput> {
181    serde_json::from_str(output)
182        .map_err(|e| Error::validation(format!("Failed to parse coverage output: {}", e)))
183}
184
185/// Process file coverage data and calculate function statistics
186fn process_file_coverage(
187    files: &HashMap<String, TarpaulinFile>,
188) -> (HashMap<String, FileCoverage>, FunctionStats) {
189    let mut file_coverage = HashMap::new();
190    let mut total_functions_tested = 0;
191    let mut total_functions = 0;
192
193    for (file_path, file_data) in files {
194        let (estimated_functions, estimated_functions_tested) =
195            estimate_function_coverage(file_data);
196
197        total_functions += estimated_functions;
198        total_functions_tested += estimated_functions_tested;
199
200        let coverage = create_file_coverage(
201            file_path,
202            file_data,
203            estimated_functions,
204            estimated_functions_tested,
205        );
206        file_coverage.insert(file_path.clone(), coverage);
207    }
208
209    let function_coverage =
210        calculate_function_coverage_percentage(total_functions_tested, total_functions);
211
212    (
213        file_coverage,
214        FunctionStats {
215            coverage: function_coverage,
216            tested: total_functions_tested,
217            total: total_functions,
218        },
219    )
220}
221
222/// Estimate function coverage from line coverage data
223fn estimate_function_coverage(file_data: &TarpaulinFile) -> (u32, u32) {
224    let estimated_functions = (file_data.lines_total / 10).max(1);
225    let estimated_functions_tested =
226        ((file_data.line_coverage / 100.0) * estimated_functions as f64) as u32;
227    (estimated_functions, estimated_functions_tested)
228}
229
230/// Create file coverage object
231fn create_file_coverage(
232    file_path: &str,
233    file_data: &TarpaulinFile,
234    estimated_functions: u32,
235    estimated_functions_tested: u32,
236) -> FileCoverage {
237    FileCoverage {
238        file_path: file_path.to_string(),
239        line_coverage: file_data.line_coverage,
240        function_coverage: calculate_function_coverage_percentage(
241            estimated_functions_tested,
242            estimated_functions,
243        ),
244        lines_tested: file_data.lines_covered,
245        total_lines: file_data.lines_total,
246        functions_tested: estimated_functions_tested,
247        total_functions: estimated_functions,
248    }
249}
250
251/// Calculate function coverage percentage
252fn calculate_function_coverage_percentage(tested: u32, total: u32) -> f64 {
253    if total > 0 {
254        (tested as f64 / total as f64) * 100.0
255    } else {
256        100.0
257    }
258}
259
260/// Calculate branch coverage from tarpaulin data
261fn calculate_branch_coverage(tarpaulin_data: &TarpaulinOutput) -> f64 {
262    if let (Some(covered), Some(total)) = (
263        tarpaulin_data.branches_covered,
264        tarpaulin_data.branches_total,
265    ) {
266        if total > 0 {
267            (covered as f64 / total as f64) * 100.0
268        } else {
269            100.0
270        }
271    } else {
272        tarpaulin_data.line_coverage // Fallback to line coverage
273    }
274}