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(
158 coverage_dir: &Path,
159 base_path: &Path,
160) -> Result<HashMap<String, CoverageStats>> {
161 let mut coverage_map: HashMap<String, CoverageStats> = HashMap::new();
162
163 if !coverage_dir.exists() {
164 return Ok(coverage_map);
165 }
166
167 let project_root =
169 find_common_ancestor(coverage_dir, base_path).unwrap_or_else(|| base_path.to_path_buf());
170
171 let entries = fs::read_dir(coverage_dir).context("Failed to read coverage directory")?;
173
174 for entry in entries {
175 let entry = entry?;
176 let path = entry.path();
177
178 if path.is_file() {
179 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
180
181 if (file_name.ends_with(".xml") || file_name.contains("cobertura"))
183 && let Ok(file_coverage) = parse_cobertura_xml(&path)
184 {
185 merge_file_coverage(&mut coverage_map, file_coverage, &project_root, base_path);
186 } else if (file_name.ends_with(".info") || file_name.contains("lcov"))
187 && let Ok(file_coverage) = parse_lcov(&path)
188 {
189 merge_file_coverage(&mut coverage_map, file_coverage, &project_root, base_path);
190 }
191 }
192 }
193
194 Ok(coverage_map)
195}
196
197fn find_common_ancestor(path1: &Path, path2: &Path) -> Option<PathBuf> {
199 let canon1 = std::fs::canonicalize(path1).ok()?;
201 let canon2 = std::fs::canonicalize(path2).ok()?;
202
203 let mut common = PathBuf::new();
204 let components1: Vec<_> = canon1.components().collect();
205 let components2: Vec<_> = canon2.components().collect();
206
207 for (c1, c2) in components1.iter().zip(components2.iter()) {
208 if c1 == c2 {
209 common.push(c1);
210 } else {
211 break;
212 }
213 }
214
215 if common.as_os_str().is_empty() {
216 None
217 } else {
218 Some(common)
219 }
220}
221
222fn merge_file_coverage(
224 coverage_map: &mut HashMap<String, CoverageStats>,
225 file_coverage: Vec<FileCoverage>,
226 project_root: &Path,
227 base_path: &Path,
228) {
229 for fc in file_coverage {
230 let normalized_path = normalize_coverage_path(&fc.path, project_root, base_path);
232
233 let stats = coverage_map.entry(normalized_path.clone()).or_default();
234
235 let mut file_stats = FileCoverageStats::new();
237 file_stats.lines_total = fc.lines_total;
238 file_stats.lines_covered = fc.lines_covered;
239 file_stats.lines_missed = fc.lines_total.saturating_sub(fc.lines_covered);
240
241 if fc.branches_total > 0 {
242 file_stats.branches_total = Some(fc.branches_total);
243 file_stats.branches_covered = Some(fc.branches_covered);
244 }
245
246 file_stats.calculate_percentages();
247
248 stats.files.insert(normalized_path, file_stats.clone());
250
251 stats.lines_total += fc.lines_total;
253 stats.lines_covered += fc.lines_covered;
254 stats.lines_missed += fc.lines_total.saturating_sub(fc.lines_covered);
255
256 if fc.branches_total > 0 {
257 stats.branches_total = Some(stats.branches_total.unwrap_or(0) + fc.branches_total);
258 stats.branches_covered =
259 Some(stats.branches_covered.unwrap_or(0) + fc.branches_covered);
260 }
261
262 stats.calculate_percentages();
263 }
264}
265
266fn normalize_coverage_path(file_path: &Path, project_root: &Path, base_path: &Path) -> String {
268 let resolved_path = if file_path.is_absolute() {
270 file_path.to_path_buf()
271 } else {
272 project_root.join(file_path)
273 };
274
275 let canon_resolved = std::fs::canonicalize(&resolved_path).unwrap_or(resolved_path.clone());
277 let canon_base = std::fs::canonicalize(base_path).unwrap_or_else(|_| base_path.to_path_buf());
278
279 if let Ok(relative) = canon_resolved.strip_prefix(&canon_base) {
281 relative.to_string_lossy().to_string()
282 } else {
283 if let Ok(relative) = resolved_path.strip_prefix(base_path) {
285 relative.to_string_lossy().to_string()
286 } else {
287 file_path.to_string_lossy().to_string()
289 }
290 }
291}
292
293fn parse_cobertura_xml(path: &Path) -> Result<Vec<FileCoverage>> {
295 let content = fs::read_to_string(path).context("Failed to read Cobertura XML file")?;
296
297 let mut file_coverage = Vec::new();
298
299 let lines: Vec<&str> = content.lines().collect();
302 let mut current_file: Option<String> = None;
303 let mut lines_total = 0;
304 let mut lines_covered = 0;
305 let mut branches_total = 0;
306 let mut branches_covered = 0;
307
308 for line in lines {
309 let trimmed = line.trim();
310
311 if trimmed.contains("<class") || trimmed.contains("<file") {
313 if let Some(file_path) = current_file.take() {
315 if lines_total > 0 {
316 file_coverage.push(FileCoverage {
317 path: PathBuf::from(file_path),
318 lines_total,
319 lines_covered,
320 branches_total,
321 branches_covered,
322 });
323 }
324 lines_total = 0;
325 lines_covered = 0;
326 branches_total = 0;
327 branches_covered = 0;
328 }
329
330 if let Some(filename) = extract_attribute(trimmed, "filename") {
332 current_file = Some(filename);
333 } else if let Some(filename) = extract_attribute(trimmed, "name") {
334 current_file = Some(filename);
335 }
336
337 if let Some(val) = extract_attribute(trimmed, "lines-valid") {
339 lines_total = val.parse().unwrap_or(0);
340 }
341 if let Some(val) = extract_attribute(trimmed, "lines-covered") {
342 lines_covered = val.parse().unwrap_or(0);
343 }
344 if let Some(val) = extract_attribute(trimmed, "branches-valid") {
345 branches_total = val.parse().unwrap_or(0);
346 }
347 if let Some(val) = extract_attribute(trimmed, "branches-covered") {
348 branches_covered = val.parse().unwrap_or(0);
349 }
350 }
351
352 if current_file.is_some() && trimmed.contains("<line") {
354 if let Some(hits) = extract_attribute(trimmed, "hits") {
355 lines_total += 1;
356 if hits.parse::<usize>().unwrap_or(0) > 0 {
357 lines_covered += 1;
358 }
359 }
360
361 if let Some(branch) = extract_attribute(trimmed, "branch")
363 && branch == "true"
364 && let Some(condition_coverage) = extract_attribute(trimmed, "condition-coverage")
365 && let Some((covered, total)) = parse_condition_coverage(&condition_coverage)
366 {
367 branches_total += total;
368 branches_covered += covered;
369 }
370 }
371 }
372
373 if let Some(file_path) = current_file
375 && lines_total > 0
376 {
377 file_coverage.push(FileCoverage {
378 path: PathBuf::from(file_path),
379 lines_total,
380 lines_covered,
381 branches_total,
382 branches_covered,
383 });
384 }
385
386 Ok(file_coverage)
387}
388
389fn parse_lcov(path: &Path) -> Result<Vec<FileCoverage>> {
391 let content = fs::read_to_string(path).context("Failed to read Lcov file")?;
392
393 let mut file_coverage = Vec::new();
394 let mut current_file: Option<&str> = None;
395 let mut lines_total = 0;
396 let mut lines_covered = 0;
397 let mut branches_total = 0;
398 let mut branches_covered = 0;
399
400 for line in content.lines() {
401 let trimmed = line.trim();
402
403 if trimmed.starts_with("SF:") {
404 if let Some(file_path) = current_file.take() {
406 file_coverage.push(FileCoverage {
407 path: PathBuf::from(file_path),
408 lines_total,
409 lines_covered,
410 branches_total,
411 branches_covered,
412 });
413 lines_total = 0;
414 lines_covered = 0;
415 branches_total = 0;
416 branches_covered = 0;
417 }
418 current_file = trimmed.strip_prefix("SF:");
419 } else if trimmed.starts_with("DA:")
420 && let Some(comma_pos) = trimmed.find(',')
421 && let Ok(count) = trimmed[comma_pos + 1..].parse::<usize>()
422 {
423 lines_total += 1;
424 if count > 0 {
425 lines_covered += 1;
426 }
427 } else if trimmed.starts_with("BRDA:") {
428 branches_total += 1;
430 let parts: Vec<&str> = trimmed
431 .strip_prefix("BRDA:")
432 .expect("")
433 .split(',')
434 .collect();
435 if parts.len() >= 4 {
436 let taken = parts[3];
437 if taken != "-" && taken != "0" {
438 branches_covered += 1;
439 }
440 }
441 } else if trimmed.starts_with("LF:")
442 && let Ok(count) = trimmed[3..].parse::<usize>()
443 {
444 lines_total = count;
445 } else if trimmed.starts_with("LH:")
446 && let Ok(count) = trimmed[3..].parse::<usize>()
447 {
448 lines_covered = count;
449 } else if trimmed.starts_with("BRF:")
450 && let Ok(count) = trimmed[4..].parse::<usize>()
451 {
452 branches_total = count;
453 } else if trimmed.starts_with("BRH:")
454 && let Ok(count) = trimmed[4..].parse::<usize>()
455 {
456 branches_covered = count;
457 } else if trimmed == "end_of_record" {
458 if let Some(file_path) = current_file.take() {
460 file_coverage.push(FileCoverage {
461 path: PathBuf::from(file_path),
462 lines_total,
463 lines_covered,
464 branches_total,
465 branches_covered,
466 });
467 lines_total = 0;
468 lines_covered = 0;
469 branches_total = 0;
470 branches_covered = 0;
471 }
472 }
473 }
474
475 if let Some(file_path) = current_file {
477 file_coverage.push(FileCoverage {
478 path: PathBuf::from(file_path),
479 lines_total,
480 lines_covered,
481 branches_total,
482 branches_covered,
483 });
484 }
485
486 Ok(file_coverage)
487}
488
489fn extract_attribute(line: &str, attr_name: &str) -> Option<String> {
491 let pattern = format!("{}=\"", attr_name);
492 if let Some(start) = line.find(&pattern) {
493 let value_start = start + pattern.len();
494 if let Some(end) = line[value_start..].find('"') {
495 return Some(line[value_start..value_start + end].to_string());
496 }
497 }
498 None
499}
500
501fn parse_condition_coverage(coverage_str: &str) -> Option<(usize, usize)> {
503 if let Some(paren_start) = coverage_str.find('(')
504 && let Some(paren_end) = coverage_str.find(')')
505 {
506 let fraction = &coverage_str[paren_start + 1..paren_end];
507 let parts: Vec<&str> = fraction.split('/').collect();
508 if parts.len() == 2 {
509 let covered = parts[0].parse().ok()?;
510 let total = parts[1].parse().ok()?;
511 return Some((covered, total));
512 }
513 }
514 None
515}
516
517pub fn map_coverage_to_features(
519 features: &[Feature],
520 coverage_map: HashMap<String, CoverageStats>,
521 base_path: &Path,
522) -> HashMap<String, CoverageStats> {
523 let mut feature_coverage: HashMap<String, CoverageStats> = HashMap::new();
524
525 let canonical_base = std::fs::canonicalize(base_path).ok();
527
528 for (file_path, coverage) in coverage_map {
529 if let Some(feature_path) =
531 find_feature_for_file(&file_path, features, canonical_base.as_deref())
532 {
533 let stats = feature_coverage.entry(feature_path.clone()).or_default();
534
535 for (individual_file_path, file_stats) in &coverage.files {
537 if let Some(file_feature) =
539 find_feature_for_file(individual_file_path, features, canonical_base.as_deref())
540 && file_feature == feature_path
541 {
542 stats
543 .files
544 .insert(individual_file_path.clone(), file_stats.clone());
545 }
546 }
547
548 stats.merge(&coverage);
549 }
550 }
551
552 feature_coverage
553}
554
555fn find_feature_for_file(
557 file_path: &str,
558 features: &[Feature],
559 _canonical_base: Option<&Path>,
560) -> Option<String> {
561 let normalized_file = normalize_path(file_path);
562
563 fn search_features(normalized_file: &str, features: &[Feature]) -> Option<String> {
564 for feature in features {
565 let normalized_feature = normalize_path(&feature.path);
566
567 if normalized_file.starts_with(&normalized_feature) {
569 let is_exact_match = normalized_file.len() == normalized_feature.len();
570 let has_path_separator = normalized_file
571 .get(normalized_feature.len()..normalized_feature.len() + 1)
572 == Some("/");
573
574 if is_exact_match || has_path_separator {
575 if let Some(nested) = search_features(normalized_file, &feature.features) {
577 return Some(nested);
578 }
579 return Some(feature.path.clone());
580 }
581 }
582 }
583 None
584 }
585
586 search_features(&normalized_file, features)
587}
588
589fn normalize_path(path: &str) -> String {
591 let path = path.trim_start_matches("./");
592 let path = path.replace('\\', "/");
593 path.to_string()
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn test_find_feature_for_file_path_boundary() {
602 let features = vec![
604 Feature {
605 name: "OffersRoute".to_string(),
606 description: String::new(),
607 owner: String::new(),
608 is_owner_inherited: false,
609 path: "airline/routes/OffersRoute".to_string(),
610 features: vec![],
611 meta: HashMap::new(),
612 changes: vec![],
613 decisions: vec![],
614 stats: None,
615 },
616 Feature {
617 name: "CruiseOffersRoute".to_string(),
618 description: String::new(),
619 owner: String::new(),
620 is_owner_inherited: false,
621 path: "cruise/routes/OffersRoute".to_string(),
622 features: vec![],
623 meta: HashMap::new(),
624 changes: vec![],
625 decisions: vec![],
626 stats: None,
627 },
628 ];
629
630 let result =
632 find_feature_for_file("cruise/routes/OffersRoute/BidCard.tsx", &features, None);
633 assert_eq!(result, Some("cruise/routes/OffersRoute".to_string()));
634
635 let result =
637 find_feature_for_file("airline/routes/OffersRoute/BidCard.tsx", &features, None);
638 assert_eq!(result, Some("airline/routes/OffersRoute".to_string()));
639 }
640
641 #[test]
642 fn test_find_feature_for_file_no_false_prefix_match() {
643 let features = vec![
645 Feature {
646 name: "OffersRoute".to_string(),
647 description: String::new(),
648 owner: String::new(),
649 is_owner_inherited: false,
650 path: "routes/OffersRoute".to_string(),
651 features: vec![],
652 meta: HashMap::new(),
653 changes: vec![],
654 decisions: vec![],
655 stats: None,
656 },
657 Feature {
658 name: "OffersRouteExtra".to_string(),
659 description: String::new(),
660 owner: String::new(),
661 is_owner_inherited: false,
662 path: "routes/OffersRouteExtra".to_string(),
663 features: vec![],
664 meta: HashMap::new(),
665 changes: vec![],
666 decisions: vec![],
667 stats: None,
668 },
669 ];
670
671 let result =
673 find_feature_for_file("routes/OffersRouteExtra/Component.tsx", &features, None);
674 assert_eq!(result, Some("routes/OffersRouteExtra".to_string()));
675
676 let result = find_feature_for_file("routes/OffersRoute/Component.tsx", &features, None);
678 assert_eq!(result, Some("routes/OffersRoute".to_string()));
679 }
680
681 #[test]
682 fn test_find_feature_for_file_exact_match() {
683 let features = vec![Feature {
685 name: "MyFeature".to_string(),
686 description: String::new(),
687 owner: String::new(),
688 is_owner_inherited: false,
689 path: "src/features/MyFeature".to_string(),
690 features: vec![],
691 meta: HashMap::new(),
692 changes: vec![],
693 decisions: vec![],
694 stats: None,
695 }];
696
697 let result = find_feature_for_file("src/features/MyFeature", &features, None);
699 assert_eq!(result, Some("src/features/MyFeature".to_string()));
700
701 let result = find_feature_for_file("src/features/MyFeature/index.tsx", &features, None);
703 assert_eq!(result, Some("src/features/MyFeature".to_string()));
704 }
705
706 #[test]
707 fn test_find_feature_for_file_no_match() {
708 let features = vec![Feature {
710 name: "MyFeature".to_string(),
711 description: String::new(),
712 owner: String::new(),
713 is_owner_inherited: false,
714 path: "src/features/MyFeature".to_string(),
715 features: vec![],
716 meta: HashMap::new(),
717 changes: vec![],
718 decisions: vec![],
719 stats: None,
720 }];
721
722 let result = find_feature_for_file("src/other/Component.tsx", &features, None);
724 assert_eq!(result, None);
725 }
726
727 #[test]
728 fn test_find_feature_for_file_nested_features() {
729 let features = vec![Feature {
731 name: "ParentFeature".to_string(),
732 description: String::new(),
733 owner: String::new(),
734 is_owner_inherited: false,
735 path: "src/features/ParentFeature".to_string(),
736 features: vec![
737 Feature {
738 name: "SubFeatureA".to_string(),
739 description: String::new(),
740 owner: String::new(),
741 is_owner_inherited: false,
742 path: "src/features/ParentFeature/SubFeatureA".to_string(),
743 features: vec![],
744 meta: HashMap::new(),
745 changes: vec![],
746 decisions: vec![],
747 stats: None,
748 },
749 Feature {
750 name: "SubFeatureB".to_string(),
751 description: String::new(),
752 owner: String::new(),
753 is_owner_inherited: false,
754 path: "src/features/ParentFeature/SubFeatureB".to_string(),
755 features: vec![],
756 meta: HashMap::new(),
757 changes: vec![],
758 decisions: vec![],
759 stats: None,
760 },
761 ],
762 meta: HashMap::new(),
763 changes: vec![],
764 decisions: vec![],
765 stats: None,
766 }];
767
768 let result = find_feature_for_file(
770 "src/features/ParentFeature/SubFeatureA/Component.tsx",
771 &features,
772 None,
773 );
774 assert_eq!(
775 result,
776 Some("src/features/ParentFeature/SubFeatureA".to_string())
777 );
778
779 let result = find_feature_for_file(
781 "src/features/ParentFeature/SubFeatureB/index.tsx",
782 &features,
783 None,
784 );
785 assert_eq!(
786 result,
787 Some("src/features/ParentFeature/SubFeatureB".to_string())
788 );
789
790 let result = find_feature_for_file("src/features/ParentFeature/utils.tsx", &features, None);
792 assert_eq!(result, Some("src/features/ParentFeature".to_string()));
793 }
794}