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 #[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 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 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
156pub 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 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 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
190fn 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 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 stats.files.insert(path_str, file_stats.clone());
215
216 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
231fn 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 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 if trimmed.contains("<class") || trimmed.contains("<file") {
251 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 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 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 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 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 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
327fn 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 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 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 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 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
427fn 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
439fn 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
455pub 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 let canonical_base = std::fs::canonicalize(base_path).ok();
465
466 for (file_path, coverage) in coverage_map {
467 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 for (individual_file_path, file_stats) in &coverage.files {
475 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
493fn 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 let canonical_file = std::fs::canonicalize(&file_path_buf)
503 .or_else(|_| {
504 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 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 if let Some(cf) = canonical_file
526 && let Ok(canonical_feature) = std::fs::canonicalize(&feature_path)
527 && cf.starts_with(&canonical_feature)
528 {
529 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 let normalized_feature = normalize_path(&feature.path);
540
541 if normalized_file.starts_with(&normalized_feature) {
542 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
557fn normalize_path(path: &str) -> String {
559 let path = path.trim_start_matches("./");
560 let path = path.replace('\\', "/");
561 path.to_string()
562}