features_cli/
coverage_parser.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::models::Feature;
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct FileCoverageStats {
10    pub lines_total: usize,
11    pub lines_covered: usize,
12    pub lines_missed: usize,
13    pub line_coverage_percent: f64,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub branches_total: Option<usize>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub branches_covered: Option<usize>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub branch_coverage_percent: Option<f64>,
20}
21
22impl Default for FileCoverageStats {
23    fn default() -> Self {
24        Self {
25            lines_total: 0,
26            lines_covered: 0,
27            lines_missed: 0,
28            line_coverage_percent: 0.0,
29            branches_total: None,
30            branches_covered: None,
31            branch_coverage_percent: None,
32        }
33    }
34}
35
36impl FileCoverageStats {
37    pub fn new() -> Self {
38        Self {
39            lines_total: 0,
40            lines_covered: 0,
41            lines_missed: 0,
42            line_coverage_percent: 0.0,
43            branches_total: None,
44            branches_covered: None,
45            branch_coverage_percent: None,
46        }
47    }
48
49    pub fn calculate_percentages(&mut self) {
50        if self.lines_total > 0 {
51            self.line_coverage_percent =
52                (self.lines_covered as f64 / self.lines_total as f64) * 100.0;
53        }
54
55        if let (Some(total), Some(covered)) = (self.branches_total, self.branches_covered)
56            && total > 0
57        {
58            self.branch_coverage_percent = Some((covered as f64 / total as f64) * 100.0);
59        }
60    }
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct CoverageStats {
65    pub lines_total: usize,
66    pub lines_covered: usize,
67    pub lines_missed: usize,
68    pub line_coverage_percent: f64,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub branches_total: Option<usize>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub branches_covered: Option<usize>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub branch_coverage_percent: Option<f64>,
75    #[serde(skip_serializing_if = "HashMap::is_empty")]
76    pub files: HashMap<String, FileCoverageStats>,
77}
78
79impl Default for CoverageStats {
80    fn default() -> Self {
81        Self {
82            lines_total: 0,
83            lines_covered: 0,
84            lines_missed: 0,
85            line_coverage_percent: 0.0,
86            branches_total: None,
87            branches_covered: None,
88            branch_coverage_percent: None,
89            files: HashMap::new(),
90        }
91    }
92}
93
94impl CoverageStats {
95    // used by tests
96    #[allow(dead_code)]
97    pub fn new() -> Self {
98        Self {
99            lines_total: 0,
100            lines_covered: 0,
101            lines_missed: 0,
102            line_coverage_percent: 0.0,
103            branches_total: None,
104            branches_covered: None,
105            branch_coverage_percent: None,
106            files: HashMap::new(),
107        }
108    }
109
110    pub fn calculate_percentages(&mut self) {
111        if self.lines_total > 0 {
112            self.line_coverage_percent =
113                (self.lines_covered as f64 / self.lines_total as f64) * 100.0;
114        }
115
116        if let (Some(total), Some(covered)) = (self.branches_total, self.branches_covered)
117            && total > 0
118        {
119            self.branch_coverage_percent = Some((covered as f64 / total as f64) * 100.0);
120        }
121    }
122
123    pub fn merge(&mut self, other: &CoverageStats) {
124        self.lines_total += other.lines_total;
125        self.lines_covered += other.lines_covered;
126        // Recalculate lines_missed based on merged totals
127        self.lines_missed = self.lines_total.saturating_sub(self.lines_covered);
128
129        if let Some(other_branches_total) = other.branches_total {
130            self.branches_total = Some(self.branches_total.unwrap_or(0) + other_branches_total);
131        }
132
133        if let Some(other_branches_covered) = other.branches_covered {
134            self.branches_covered =
135                Some(self.branches_covered.unwrap_or(0) + other_branches_covered);
136        }
137
138        // Merge file-level coverage
139        for (file_path, file_stats) in &other.files {
140            self.files.insert(file_path.clone(), file_stats.clone());
141        }
142
143        self.calculate_percentages();
144    }
145}
146
147#[derive(Debug)]
148struct FileCoverage {
149    path: PathBuf,
150    lines_total: usize,
151    lines_covered: usize,
152    branches_total: usize,
153    branches_covered: usize,
154}
155
156/// Detects and parses coverage reports from the .coverage directory
157pub fn parse_coverage_reports(coverage_dir: &Path) -> Result<HashMap<String, CoverageStats>> {
158    let mut coverage_map: HashMap<String, CoverageStats> = HashMap::new();
159
160    if !coverage_dir.exists() {
161        return Ok(coverage_map);
162    }
163
164    // Find all coverage files in the directory
165    let entries = fs::read_dir(coverage_dir).context("Failed to read coverage directory")?;
166
167    for entry in entries {
168        let entry = entry?;
169        let path = entry.path();
170
171        if path.is_file() {
172            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
173
174            // Detect file type and parse accordingly
175            if (file_name.ends_with(".xml") || file_name.contains("cobertura"))
176                && let Ok(file_coverage) = parse_cobertura_xml(&path)
177            {
178                merge_file_coverage(&mut coverage_map, file_coverage);
179            } else if (file_name.ends_with(".info") || file_name.contains("lcov"))
180                && let Ok(file_coverage) = parse_lcov(&path)
181            {
182                merge_file_coverage(&mut coverage_map, file_coverage);
183            }
184        }
185    }
186
187    Ok(coverage_map)
188}
189
190/// Merge file coverage data into the coverage map
191fn merge_file_coverage(
192    coverage_map: &mut HashMap<String, CoverageStats>,
193    file_coverage: Vec<FileCoverage>,
194) {
195    for fc in file_coverage {
196        let path_str = fc.path.to_string_lossy().to_string();
197
198        let stats = coverage_map.entry(path_str.clone()).or_default();
199
200        // Create individual file stats
201        let mut file_stats = FileCoverageStats::new();
202        file_stats.lines_total = fc.lines_total;
203        file_stats.lines_covered = fc.lines_covered;
204        file_stats.lines_missed = fc.lines_total.saturating_sub(fc.lines_covered);
205
206        if fc.branches_total > 0 {
207            file_stats.branches_total = Some(fc.branches_total);
208            file_stats.branches_covered = Some(fc.branches_covered);
209        }
210
211        file_stats.calculate_percentages();
212
213        // Store file-level coverage
214        stats.files.insert(path_str, file_stats.clone());
215
216        // Update aggregate stats
217        stats.lines_total += fc.lines_total;
218        stats.lines_covered += fc.lines_covered;
219        stats.lines_missed += fc.lines_total.saturating_sub(fc.lines_covered);
220
221        if fc.branches_total > 0 {
222            stats.branches_total = Some(stats.branches_total.unwrap_or(0) + fc.branches_total);
223            stats.branches_covered =
224                Some(stats.branches_covered.unwrap_or(0) + fc.branches_covered);
225        }
226
227        stats.calculate_percentages();
228    }
229}
230
231/// Parse Cobertura XML format
232fn parse_cobertura_xml(path: &Path) -> Result<Vec<FileCoverage>> {
233    let content = fs::read_to_string(path).context("Failed to read Cobertura XML file")?;
234
235    let mut file_coverage = Vec::new();
236
237    // Simple XML parsing without external dependencies
238    // This is a basic parser that looks for <class> elements with coverage attributes
239    let lines: Vec<&str> = content.lines().collect();
240    let mut current_file: Option<String> = None;
241    let mut lines_total = 0;
242    let mut lines_covered = 0;
243    let mut branches_total = 0;
244    let mut branches_covered = 0;
245
246    for line in lines {
247        let trimmed = line.trim();
248
249        // Look for class or file elements with filename attribute
250        if trimmed.contains("<class") || trimmed.contains("<file") {
251            // Save previous file if exists
252            if let Some(file_path) = current_file.take() {
253                if lines_total > 0 {
254                    file_coverage.push(FileCoverage {
255                        path: PathBuf::from(file_path),
256                        lines_total,
257                        lines_covered,
258                        branches_total,
259                        branches_covered,
260                    });
261                }
262                lines_total = 0;
263                lines_covered = 0;
264                branches_total = 0;
265                branches_covered = 0;
266            }
267
268            // Extract filename
269            if let Some(filename) = extract_attribute(trimmed, "filename") {
270                current_file = Some(filename);
271            } else if let Some(filename) = extract_attribute(trimmed, "name") {
272                current_file = Some(filename);
273            }
274
275            // Extract coverage metrics if present in the same tag
276            if let Some(val) = extract_attribute(trimmed, "lines-valid") {
277                lines_total = val.parse().unwrap_or(0);
278            }
279            if let Some(val) = extract_attribute(trimmed, "lines-covered") {
280                lines_covered = val.parse().unwrap_or(0);
281            }
282            if let Some(val) = extract_attribute(trimmed, "branches-valid") {
283                branches_total = val.parse().unwrap_or(0);
284            }
285            if let Some(val) = extract_attribute(trimmed, "branches-covered") {
286                branches_covered = val.parse().unwrap_or(0);
287            }
288        }
289
290        // Alternative: extract from line elements
291        if current_file.is_some() && trimmed.contains("<line") {
292            if let Some(hits) = extract_attribute(trimmed, "hits") {
293                lines_total += 1;
294                if hits.parse::<usize>().unwrap_or(0) > 0 {
295                    lines_covered += 1;
296                }
297            }
298
299            // Check for branch coverage
300            if let Some(branch) = extract_attribute(trimmed, "branch")
301                && branch == "true"
302                && let Some(condition_coverage) = extract_attribute(trimmed, "condition-coverage")
303                && let Some((covered, total)) = parse_condition_coverage(&condition_coverage)
304            {
305                branches_total += total;
306                branches_covered += covered;
307            }
308        }
309    }
310
311    // Save last file
312    if let Some(file_path) = current_file
313        && lines_total > 0
314    {
315        file_coverage.push(FileCoverage {
316            path: PathBuf::from(file_path),
317            lines_total,
318            lines_covered,
319            branches_total,
320            branches_covered,
321        });
322    }
323
324    Ok(file_coverage)
325}
326
327/// Parse Lcov format
328fn parse_lcov(path: &Path) -> Result<Vec<FileCoverage>> {
329    let content = fs::read_to_string(path).context("Failed to read Lcov file")?;
330
331    let mut file_coverage = Vec::new();
332    let mut current_file: Option<&str> = None;
333    let mut lines_total = 0;
334    let mut lines_covered = 0;
335    let mut branches_total = 0;
336    let mut branches_covered = 0;
337
338    for line in content.lines() {
339        let trimmed = line.trim();
340
341        if trimmed.starts_with("SF:") {
342            // Start of a new file
343            if let Some(file_path) = current_file.take() {
344                file_coverage.push(FileCoverage {
345                    path: PathBuf::from(file_path),
346                    lines_total,
347                    lines_covered,
348                    branches_total,
349                    branches_covered,
350                });
351                lines_total = 0;
352                lines_covered = 0;
353                branches_total = 0;
354                branches_covered = 0;
355            }
356            current_file = trimmed.strip_prefix("SF:");
357        } else if trimmed.starts_with("DA:")
358            && let Some(comma_pos) = trimmed.find(',')
359            && let Ok(count) = trimmed[comma_pos + 1..].parse::<usize>()
360        {
361            lines_total += 1;
362            if count > 0 {
363                lines_covered += 1;
364            }
365        } else if trimmed.starts_with("BRDA:") {
366            // Branch coverage: BRDA:line_number,block_number,branch_number,taken
367            branches_total += 1;
368            let parts: Vec<&str> = trimmed
369                .strip_prefix("BRDA:")
370                .expect("")
371                .split(',')
372                .collect();
373            if parts.len() >= 4 {
374                let taken = parts[3];
375                if taken != "-" && taken != "0" {
376                    branches_covered += 1;
377                }
378            }
379        } else if trimmed.starts_with("LF:")
380            && let Ok(count) = trimmed[3..].parse::<usize>()
381        {
382            lines_total = count;
383        } else if trimmed.starts_with("LH:")
384            && let Ok(count) = trimmed[3..].parse::<usize>()
385        {
386            lines_covered = count;
387        } else if trimmed.starts_with("BRF:")
388            && let Ok(count) = trimmed[4..].parse::<usize>()
389        {
390            branches_total = count;
391        } else if trimmed.starts_with("BRH:")
392            && let Ok(count) = trimmed[4..].parse::<usize>()
393        {
394            branches_covered = count;
395        } else if trimmed == "end_of_record" {
396            // End of current file record
397            if let Some(file_path) = current_file.take() {
398                file_coverage.push(FileCoverage {
399                    path: PathBuf::from(file_path),
400                    lines_total,
401                    lines_covered,
402                    branches_total,
403                    branches_covered,
404                });
405                lines_total = 0;
406                lines_covered = 0;
407                branches_total = 0;
408                branches_covered = 0;
409            }
410        }
411    }
412
413    // Save last file if not already saved
414    if let Some(file_path) = current_file {
415        file_coverage.push(FileCoverage {
416            path: PathBuf::from(file_path),
417            lines_total,
418            lines_covered,
419            branches_total,
420            branches_covered,
421        });
422    }
423
424    Ok(file_coverage)
425}
426
427/// Extract an attribute value from an XML tag
428fn extract_attribute(line: &str, attr_name: &str) -> Option<String> {
429    let pattern = format!("{}=\"", attr_name);
430    if let Some(start) = line.find(&pattern) {
431        let value_start = start + pattern.len();
432        if let Some(end) = line[value_start..].find('"') {
433            return Some(line[value_start..value_start + end].to_string());
434        }
435    }
436    None
437}
438
439/// Parse condition coverage string like "50% (1/2)"
440fn parse_condition_coverage(coverage_str: &str) -> Option<(usize, usize)> {
441    if let Some(paren_start) = coverage_str.find('(')
442        && let Some(paren_end) = coverage_str.find(')')
443    {
444        let fraction = &coverage_str[paren_start + 1..paren_end];
445        let parts: Vec<&str> = fraction.split('/').collect();
446        if parts.len() == 2 {
447            let covered = parts[0].parse().ok()?;
448            let total = parts[1].parse().ok()?;
449            return Some((covered, total));
450        }
451    }
452    None
453}
454
455/// Map coverage data to features
456pub fn map_coverage_to_features(
457    features: &[Feature],
458    coverage_map: HashMap<String, CoverageStats>,
459    base_path: &Path,
460) -> HashMap<String, CoverageStats> {
461    let mut feature_coverage: HashMap<String, CoverageStats> = HashMap::new();
462
463    // Normalize base path
464    let canonical_base = std::fs::canonicalize(base_path).ok();
465
466    for (file_path, coverage) in coverage_map {
467        // Find which feature this file belongs to
468        if let Some(feature_name) =
469            find_feature_for_file(&file_path, features, canonical_base.as_deref())
470        {
471            let stats = feature_coverage.entry(feature_name.clone()).or_default();
472
473            // Add each file's coverage to the feature
474            for (individual_file_path, file_stats) in &coverage.files {
475                // Check if this individual file belongs to the current feature
476                if let Some(file_feature) =
477                    find_feature_for_file(individual_file_path, features, canonical_base.as_deref())
478                    && file_feature == feature_name
479                {
480                    stats
481                        .files
482                        .insert(individual_file_path.clone(), file_stats.clone());
483                }
484            }
485
486            stats.merge(&coverage);
487        }
488    }
489
490    feature_coverage
491}
492
493/// Find which feature a file belongs to
494fn find_feature_for_file(
495    file_path: &str,
496    features: &[Feature],
497    canonical_base: Option<&Path>,
498) -> Option<String> {
499    let file_path_buf = PathBuf::from(file_path);
500
501    // Try to canonicalize the file path, or use as-is if it fails
502    let canonical_file = std::fs::canonicalize(&file_path_buf)
503        .or_else(|_| {
504            // If absolute path fails, try relative to base
505            if let Some(base) = canonical_base {
506                std::fs::canonicalize(base.join(&file_path_buf))
507            } else {
508                Err(std::io::Error::new(std::io::ErrorKind::NotFound, ""))
509            }
510        })
511        .ok();
512
513    // Normalize file path by removing leading ./ and converting to string
514    let normalized_file = normalize_path(file_path);
515
516    fn search_features(
517        canonical_file: Option<&Path>,
518        normalized_file: &str,
519        features: &[Feature],
520    ) -> Option<String> {
521        for feature in features {
522            let feature_path = PathBuf::from(&feature.path);
523
524            // Try canonical comparison first
525            if let Some(cf) = canonical_file
526                && let Ok(canonical_feature) = std::fs::canonicalize(&feature_path)
527                && cf.starts_with(&canonical_feature)
528            {
529                // Check nested features first (more specific)
530                if let Some(nested) =
531                    search_features(canonical_file, normalized_file, &feature.features)
532                {
533                    return Some(nested);
534                }
535                return Some(feature.name.clone());
536            }
537
538            // Fallback to normalized string comparison
539            let normalized_feature = normalize_path(&feature.path);
540
541            if normalized_file.starts_with(&normalized_feature) {
542                // Check nested features first (more specific)
543                if let Some(nested) =
544                    search_features(canonical_file, normalized_file, &feature.features)
545                {
546                    return Some(nested);
547                }
548                return Some(feature.name.clone());
549            }
550        }
551        None
552    }
553
554    search_features(canonical_file.as_deref(), &normalized_file, features)
555}
556
557/// Normalize a path by removing leading ./ and converting to forward slashes
558fn normalize_path(path: &str) -> String {
559    let path = path.trim_start_matches("./");
560    let path = path.replace('\\', "/");
561    path.to_string()
562}