1use std::collections::BTreeMap;
7
8use covguard_paths::{normalize_coverage_path, normalize_coverage_path_with_strip};
9use covguard_ports::CoverageProvider;
10use thiserror::Error;
11
12pub type CoverageMap = BTreeMap<String, BTreeMap<u32, u32>>;
21
22#[derive(Debug, Error, PartialEq, Eq)]
28pub enum LcovError {
29 #[error("Invalid LCOV format: {0}")]
31 InvalidFormat(String),
32
33 #[error("I/O error: {0}")]
35 IoError(String),
36}
37
38pub struct LcovCoverageProvider;
40
41impl CoverageProvider for LcovCoverageProvider {
42 fn parse_lcov(
43 &self,
44 text: &str,
45 strip_prefixes: &[String],
46 ) -> Result<covguard_ports::CoverageMap, String> {
47 parse_lcov_with_strip(text, strip_prefixes).map_err(|e| e.to_string())
48 }
49
50 fn merge_coverage(
51 &self,
52 maps: Vec<covguard_ports::CoverageMap>,
53 ) -> covguard_ports::CoverageMap {
54 merge_coverage(maps)
55 }
56}
57
58pub fn normalize_path(path: &str) -> String {
78 normalize_coverage_path(path)
79}
80
81pub fn normalize_path_with_strip(path: &str, strip_prefixes: &[String]) -> String {
83 normalize_coverage_path_with_strip(path, strip_prefixes)
84}
85
86pub fn parse_lcov(text: &str) -> Result<CoverageMap, LcovError> {
115 parse_lcov_with_strip(text, &[])
116}
117
118pub fn parse_lcov_with_strip(
120 text: &str,
121 strip_prefixes: &[String],
122) -> Result<CoverageMap, LcovError> {
123 let mut coverage_map: CoverageMap = BTreeMap::new();
124 let mut current_file: Option<String> = None;
125 let mut current_lines: BTreeMap<u32, u32> = BTreeMap::new();
126
127 for (line_num, line) in text.lines().enumerate() {
128 let line = line.trim();
129
130 if line.is_empty() {
132 continue;
133 }
134
135 if line.starts_with("TN:") {
136 continue;
138 }
139
140 if let Some(path) = line.strip_prefix("SF:") {
141 if let Some(file) = current_file.take() {
144 merge_file_coverage(&mut coverage_map, &file, ¤t_lines);
145 current_lines.clear();
146 }
147 current_file = Some(normalize_path_with_strip(path, strip_prefixes));
148 current_lines = BTreeMap::new();
149 continue;
150 }
151
152 if let Some(data) = line.strip_prefix("DA:") {
153 if current_file.is_none() {
155 return Err(LcovError::InvalidFormat(format!(
156 "DA record at line {} without preceding SF record",
157 line_num + 1
158 )));
159 }
160
161 let parts: Vec<&str> = data.split(',').collect();
162 if parts.len() < 2 {
163 return Err(LcovError::InvalidFormat(format!(
164 "Invalid DA format at line {}: expected 'DA:<line>,<hits>', got '{}'",
165 line_num + 1,
166 line
167 )));
168 }
169
170 let line_number: u32 = parts[0].parse().map_err(|_| {
171 LcovError::InvalidFormat(format!(
172 "Invalid line number at line {}: '{}'",
173 line_num + 1,
174 parts[0]
175 ))
176 })?;
177
178 let hits: u32 = parts[1].parse().map_err(|_| {
179 LcovError::InvalidFormat(format!(
180 "Invalid hit count at line {}: '{}'",
181 line_num + 1,
182 parts[1]
183 ))
184 })?;
185
186 current_lines.insert(line_number, hits);
187 continue;
188 }
189
190 if line == "end_of_record" {
191 if let Some(file) = current_file.take() {
193 merge_file_coverage(&mut coverage_map, &file, ¤t_lines);
194 current_lines.clear();
195 }
196 continue;
197 }
198
199 }
202
203 if let Some(file) = current_file {
205 merge_file_coverage(&mut coverage_map, &file, ¤t_lines);
206 }
207
208 Ok(coverage_map)
209}
210
211fn merge_file_coverage(coverage_map: &mut CoverageMap, file: &str, lines: &BTreeMap<u32, u32>) {
212 let entry = coverage_map.entry(file.to_string()).or_default();
213 for (line, hits) in lines {
214 let existing = entry.entry(*line).or_insert(*hits);
215 if *hits > *existing {
216 *existing = *hits;
217 }
218 }
219}
220
221pub fn merge_coverage(maps: Vec<CoverageMap>) -> CoverageMap {
250 let mut merged: CoverageMap = BTreeMap::new();
251
252 for map in maps {
253 for (file, lines) in map {
254 let entry = merged.entry(file).or_default();
255 for (line, hits) in lines {
256 let current = entry.entry(line).or_insert(0);
257 *current = (*current).max(hits);
258 }
259 }
260 }
261
262 merged
263}
264
265pub fn get_hits(map: &CoverageMap, path: &str, line: u32) -> Option<u32> {
286 map.get(path).and_then(|lines| lines.get(&line).copied())
287}
288
289#[cfg(test)]
294mod tests {
295 use super::*;
296 use covguard_ports::CoverageProvider;
297
298 #[test]
303 fn test_normalize_path_simple() {
304 assert_eq!(normalize_path("src/lib.rs"), "src/lib.rs");
305 }
306
307 #[test]
308 fn test_normalize_path_removes_leading_dot_slash() {
309 assert_eq!(normalize_path("./src/lib.rs"), "src/lib.rs");
310 assert_eq!(normalize_path("././src/lib.rs"), "src/lib.rs");
311 }
312
313 #[test]
314 fn test_normalize_path_converts_backslashes() {
315 assert_eq!(normalize_path("src\\lib.rs"), "src/lib.rs");
316 assert_eq!(normalize_path("src\\sub\\lib.rs"), "src/sub/lib.rs");
317 }
318
319 #[test]
320 fn test_normalize_path_handles_absolute_unix() {
321 assert_eq!(
322 normalize_path("/home/user/project/src/lib.rs"),
323 "src/lib.rs"
324 );
325 assert_eq!(
326 normalize_path("/Users/user/project/src/main.rs"),
327 "src/main.rs"
328 );
329 }
330
331 #[test]
332 fn test_normalize_path_handles_absolute_windows() {
333 assert_eq!(
334 normalize_path("C:\\Users\\user\\project\\src\\lib.rs"),
335 "src/lib.rs"
336 );
337 assert_eq!(normalize_path("D:/code/project/src/main.rs"), "src/main.rs");
338 }
339
340 #[test]
341 fn test_normalize_path_with_strip_prefixes() {
342 let prefixes = vec!["/home/runner/work/".to_string(), "C:\\repo\\".to_string()];
343 assert_eq!(
344 normalize_path_with_strip("/home/runner/work/src/lib.rs", &prefixes),
345 "src/lib.rs"
346 );
347 assert_eq!(
348 normalize_path_with_strip("C:\\repo\\lib\\mod.rs", &prefixes),
349 "lib/mod.rs"
350 );
351 }
352
353 #[test]
354 fn test_normalize_path_windows_marker_strips_to_lib() {
355 assert_eq!(
356 normalize_path("C:/code/project/lib/utils.rs"),
357 "lib/utils.rs"
358 );
359 }
360
361 #[test]
366 fn test_parse_covered_fixture() {
367 let lcov = r#"TN:
368SF:src/lib.rs
369DA:1,1
370DA:2,1
371DA:3,1
372end_of_record
373"#;
374 let coverage = parse_lcov(lcov).unwrap();
375
376 assert_eq!(coverage.len(), 1);
377 let file = coverage.get("src/lib.rs").unwrap();
378 assert_eq!(file.len(), 3);
379 assert_eq!(file.get(&1), Some(&1));
380 assert_eq!(file.get(&2), Some(&1));
381 assert_eq!(file.get(&3), Some(&1));
382 }
383
384 #[test]
385 fn test_parse_uncovered_fixture() {
386 let lcov = r#"TN:
387SF:src/lib.rs
388DA:1,0
389DA:2,0
390DA:3,0
391end_of_record
392"#;
393 let coverage = parse_lcov(lcov).unwrap();
394
395 assert_eq!(coverage.len(), 1);
396 let file = coverage.get("src/lib.rs").unwrap();
397 assert_eq!(file.len(), 3);
398 assert_eq!(file.get(&1), Some(&0));
399 assert_eq!(file.get(&2), Some(&0));
400 assert_eq!(file.get(&3), Some(&0));
401 }
402
403 #[test]
408 fn test_parse_multiple_files() {
409 let lcov = r#"TN:test
410SF:src/lib.rs
411DA:1,1
412end_of_record
413SF:src/main.rs
414DA:5,0
415DA:6,2
416end_of_record
417"#;
418 let coverage = parse_lcov(lcov).unwrap();
419
420 assert_eq!(coverage.len(), 2);
421
422 let lib = coverage.get("src/lib.rs").unwrap();
423 assert_eq!(lib.len(), 1);
424 assert_eq!(lib.get(&1), Some(&1));
425
426 let main = coverage.get("src/main.rs").unwrap();
427 assert_eq!(main.len(), 2);
428 assert_eq!(main.get(&5), Some(&0));
429 assert_eq!(main.get(&6), Some(&2));
430 }
431
432 #[test]
433 fn test_parse_lcov_merges_duplicate_records_takes_max_hits() {
434 let lcov = r#"TN:test
435SF:src/lib.rs
436DA:1,1
437end_of_record
438SF:src/lib.rs
439DA:1,3
440end_of_record
441"#;
442 let coverage = parse_lcov(lcov).unwrap();
443 let file = coverage.get("src/lib.rs").unwrap();
444 assert_eq!(file.get(&1), Some(&3));
445 }
446
447 #[test]
448 fn test_parse_absolute_sf_paths() {
449 let lcov = r#"TN:
450SF:/home/user/project/src/lib.rs
451DA:1,1
452end_of_record
453"#;
454 let coverage = parse_lcov(lcov).unwrap();
455
456 assert!(coverage.contains_key("src/lib.rs"));
457 assert_eq!(coverage.get("src/lib.rs").unwrap().get(&1), Some(&1));
458 }
459
460 #[test]
461 fn test_parse_windows_sf_paths() {
462 let lcov = r#"TN:
463SF:C:\Users\dev\project\src\lib.rs
464DA:1,1
465end_of_record
466"#;
467 let coverage = parse_lcov(lcov).unwrap();
468
469 assert!(coverage.contains_key("src/lib.rs"));
470 }
471
472 #[test]
473 fn test_parse_empty_input() {
474 let coverage = parse_lcov("").unwrap();
475 assert!(coverage.is_empty());
476 }
477
478 #[test]
479 fn test_parse_empty_lines() {
480 let lcov = r#"
481
482TN:
483
484SF:src/lib.rs
485
486DA:1,1
487
488end_of_record
489
490"#;
491 let coverage = parse_lcov(lcov).unwrap();
492 assert_eq!(coverage.len(), 1);
493 }
494
495 #[test]
496 fn test_parse_missing_end_of_record() {
497 let lcov = r#"TN:
499SF:src/lib.rs
500DA:1,1
501SF:src/main.rs
502DA:2,0
503"#;
504 let coverage = parse_lcov(lcov).unwrap();
505
506 assert_eq!(coverage.len(), 2);
507 assert!(coverage.contains_key("src/lib.rs"));
508 assert!(coverage.contains_key("src/main.rs"));
509 }
510
511 #[test]
512 fn test_parse_ignores_other_lcov_records() {
513 let lcov = r#"TN:test
514SF:src/lib.rs
515FN:10,my_function
516FNDA:5,my_function
517FNF:1
518FNH:1
519DA:1,5
520DA:2,5
521BRDA:1,0,0,1
522BRDA:1,0,1,0
523BRF:2
524BRH:1
525LF:2
526LH:2
527end_of_record
528"#;
529 let coverage = parse_lcov(lcov).unwrap();
530
531 assert_eq!(coverage.len(), 1);
532 let file = coverage.get("src/lib.rs").unwrap();
533 assert_eq!(file.len(), 2);
534 assert_eq!(file.get(&1), Some(&5));
535 assert_eq!(file.get(&2), Some(&5));
536 }
537
538 #[test]
543 fn test_parse_da_without_sf_fails() {
544 let lcov = "DA:1,1\nend_of_record\n";
545 let result = parse_lcov(lcov);
546
547 assert!(matches!(
548 result,
549 Err(LcovError::InvalidFormat(msg)) if msg.contains("without preceding SF")
550 ));
551 }
552
553 #[test]
554 fn test_parse_invalid_da_format() {
555 let lcov = "SF:src/lib.rs\nDA:invalid\nend_of_record\n";
556 let result = parse_lcov(lcov);
557
558 assert!(matches!(
559 result,
560 Err(LcovError::InvalidFormat(msg)) if msg.contains("Invalid DA format")
561 ));
562 }
563
564 #[test]
565 fn test_parse_invalid_line_number() {
566 let lcov = "SF:src/lib.rs\nDA:abc,1\nend_of_record\n";
567 let result = parse_lcov(lcov);
568
569 assert!(matches!(
570 result,
571 Err(LcovError::InvalidFormat(msg)) if msg.contains("Invalid line number")
572 ));
573 }
574
575 #[test]
576 fn test_parse_invalid_hit_count() {
577 let lcov = "SF:src/lib.rs\nDA:1,xyz\nend_of_record\n";
578 let result = parse_lcov(lcov);
579
580 assert!(matches!(
581 result,
582 Err(LcovError::InvalidFormat(msg)) if msg.contains("Invalid hit count")
583 ));
584 }
585
586 #[test]
591 fn test_merge_disjoint_files() {
592 let map1 = {
593 let mut m: CoverageMap = BTreeMap::new();
594 let mut lines = BTreeMap::new();
595 lines.insert(1, 1);
596 m.insert("src/a.rs".to_string(), lines);
597 m
598 };
599
600 let map2 = {
601 let mut m: CoverageMap = BTreeMap::new();
602 let mut lines = BTreeMap::new();
603 lines.insert(1, 1);
604 m.insert("src/b.rs".to_string(), lines);
605 m
606 };
607
608 let merged = merge_coverage(vec![map1, map2]);
609
610 assert_eq!(merged.len(), 2);
611 assert!(merged.contains_key("src/a.rs"));
612 assert!(merged.contains_key("src/b.rs"));
613 }
614
615 #[test]
616 fn test_merge_same_file_disjoint_lines() {
617 let map1 = {
618 let mut m: CoverageMap = BTreeMap::new();
619 let mut lines = BTreeMap::new();
620 lines.insert(1, 1);
621 lines.insert(2, 0);
622 m.insert("src/lib.rs".to_string(), lines);
623 m
624 };
625
626 let map2 = {
627 let mut m: CoverageMap = BTreeMap::new();
628 let mut lines = BTreeMap::new();
629 lines.insert(3, 1);
630 lines.insert(4, 0);
631 m.insert("src/lib.rs".to_string(), lines);
632 m
633 };
634
635 let merged = merge_coverage(vec![map1, map2]);
636
637 assert_eq!(merged.len(), 1);
638 let file = merged.get("src/lib.rs").unwrap();
639 assert_eq!(file.len(), 4);
640 assert_eq!(file.get(&1), Some(&1));
641 assert_eq!(file.get(&2), Some(&0));
642 assert_eq!(file.get(&3), Some(&1));
643 assert_eq!(file.get(&4), Some(&0));
644 }
645
646 #[test]
647 fn test_merge_same_line_takes_max() {
648 let map1 = {
649 let mut m: CoverageMap = BTreeMap::new();
650 let mut lines = BTreeMap::new();
651 lines.insert(1, 0);
652 lines.insert(2, 5);
653 m.insert("src/lib.rs".to_string(), lines);
654 m
655 };
656
657 let map2 = {
658 let mut m: CoverageMap = BTreeMap::new();
659 let mut lines = BTreeMap::new();
660 lines.insert(1, 3);
661 lines.insert(2, 2);
662 m.insert("src/lib.rs".to_string(), lines);
663 m
664 };
665
666 let merged = merge_coverage(vec![map1, map2]);
667
668 let file = merged.get("src/lib.rs").unwrap();
669 assert_eq!(file.get(&1), Some(&3)); assert_eq!(file.get(&2), Some(&5)); }
672
673 #[test]
674 fn test_merge_empty() {
675 let merged = merge_coverage(vec![]);
676 assert!(merged.is_empty());
677 }
678
679 #[test]
680 fn test_merge_single_map() {
681 let map = {
682 let mut m: CoverageMap = BTreeMap::new();
683 let mut lines = BTreeMap::new();
684 lines.insert(1, 1);
685 m.insert("src/lib.rs".to_string(), lines);
686 m
687 };
688
689 let merged = merge_coverage(vec![map]);
690
691 assert_eq!(merged.len(), 1);
692 assert_eq!(merged.get("src/lib.rs").unwrap().get(&1), Some(&1));
693 }
694
695 #[test]
700 fn test_get_hits_existing() {
701 let mut coverage: CoverageMap = BTreeMap::new();
702 let mut lines = BTreeMap::new();
703 lines.insert(1, 5);
704 lines.insert(2, 0);
705 coverage.insert("src/lib.rs".to_string(), lines);
706
707 assert_eq!(get_hits(&coverage, "src/lib.rs", 1), Some(5));
708 assert_eq!(get_hits(&coverage, "src/lib.rs", 2), Some(0));
709 }
710
711 #[test]
712 fn test_get_hits_missing_line() {
713 let mut coverage: CoverageMap = BTreeMap::new();
714 let mut lines = BTreeMap::new();
715 lines.insert(1, 5);
716 coverage.insert("src/lib.rs".to_string(), lines);
717
718 assert_eq!(get_hits(&coverage, "src/lib.rs", 99), None);
719 }
720
721 #[test]
722 fn test_get_hits_missing_file() {
723 let coverage: CoverageMap = BTreeMap::new();
724
725 assert_eq!(get_hits(&coverage, "nonexistent.rs", 1), None);
726 }
727
728 #[test]
729 fn test_lcov_coverage_provider_parse_and_merge() {
730 let provider = LcovCoverageProvider;
731 let map1 = provider
732 .parse_lcov("SF:src/lib.rs\nDA:1,0\nend_of_record\n", &[])
733 .expect("parse first");
734 let map2 = provider
735 .parse_lcov("SF:src/lib.rs\nDA:1,2\nend_of_record\n", &[])
736 .expect("parse second");
737 let merged = provider.merge_coverage(vec![map1, map2]);
738 assert_eq!(get_hits(&merged, "src/lib.rs", 1), Some(2));
739 }
740}
741
742#[cfg(test)]
747mod proptests {
748 use super::*;
749 use proptest::prelude::*;
750
751 proptest! {
752 #[test]
754 fn merge_is_commutative(
755 lines1 in prop::collection::btree_map(1u32..100, 0u32..1000, 0..10),
756 lines2 in prop::collection::btree_map(1u32..100, 0u32..1000, 0..10),
757 ) {
758 let mut map1: CoverageMap = BTreeMap::new();
759 map1.insert("file.rs".to_string(), lines1.clone());
760
761 let mut map2: CoverageMap = BTreeMap::new();
762 map2.insert("file.rs".to_string(), lines2.clone());
763
764 let merged_12 = merge_coverage(vec![map1.clone(), map2.clone()]);
765 let merged_21 = merge_coverage(vec![map2, map1]);
766
767 prop_assert_eq!(merged_12, merged_21);
768 }
769
770 #[test]
772 fn merge_with_empty_is_identity(
773 lines in prop::collection::btree_map(1u32..100, 0u32..1000, 0..10),
774 ) {
775 let mut map: CoverageMap = BTreeMap::new();
776 map.insert("file.rs".to_string(), lines);
777
778 let merged = merge_coverage(vec![map.clone(), BTreeMap::new()]);
779
780 prop_assert_eq!(merged, map);
781 }
782
783 #[test]
785 fn merge_takes_max_hits(
786 line in 1u32..100,
787 hits1 in 0u32..1000,
788 hits2 in 0u32..1000,
789 ) {
790 let mut map1: CoverageMap = BTreeMap::new();
791 let mut lines1 = BTreeMap::new();
792 lines1.insert(line, hits1);
793 map1.insert("file.rs".to_string(), lines1);
794
795 let mut map2: CoverageMap = BTreeMap::new();
796 let mut lines2 = BTreeMap::new();
797 lines2.insert(line, hits2);
798 map2.insert("file.rs".to_string(), lines2);
799
800 let merged = merge_coverage(vec![map1, map2]);
801 let actual_hits = get_hits(&merged, "file.rs", line).unwrap();
802
803 prop_assert_eq!(actual_hits, hits1.max(hits2));
804 }
805
806 #[test]
808 fn normalize_path_idempotent(path in "[a-z/\\.]{1,50}") {
809 let once = normalize_path(&path);
810 let twice = normalize_path(&once);
811 prop_assert_eq!(once, twice);
812 }
813
814 #[test]
816 fn roundtrip_simple_lcov(
817 line_num in 1u32..1000,
818 hits in 0u32..1000,
819 ) {
820 let lcov = format!(
821 "TN:\nSF:src/test.rs\nDA:{},{}\nend_of_record\n",
822 line_num, hits
823 );
824
825 let coverage = parse_lcov(&lcov).unwrap();
826 prop_assert_eq!(
827 coverage.get("src/test.rs").unwrap().get(&line_num),
828 Some(&hits)
829 );
830 }
831 }
832}
833
834#[cfg(test)]
839mod integration_tests {
840 use super::*;
841
842 #[test]
844 fn test_parse_covered_info_fixture_file() {
845 let lcov_content = include_str!("../../../fixtures/lcov/covered.info");
846 let coverage = parse_lcov(lcov_content).unwrap();
847
848 assert_eq!(coverage.len(), 1);
850 let file = coverage.get("src/lib.rs").unwrap();
851 assert_eq!(file.len(), 3);
852 assert_eq!(file.get(&1), Some(&1));
853 assert_eq!(file.get(&2), Some(&1));
854 assert_eq!(file.get(&3), Some(&1));
855 }
856
857 #[test]
859 fn test_parse_uncovered_info_fixture_file() {
860 let lcov_content = include_str!("../../../fixtures/lcov/uncovered.info");
861 let coverage = parse_lcov(lcov_content).unwrap();
862
863 assert_eq!(coverage.len(), 1);
865 let file = coverage.get("src/lib.rs").unwrap();
866 assert_eq!(file.len(), 3);
867 assert_eq!(file.get(&1), Some(&0));
868 assert_eq!(file.get(&2), Some(&0));
869 assert_eq!(file.get(&3), Some(&0));
870 }
871
872 #[test]
874 fn test_merge_fixture_files() {
875 let covered = include_str!("../../../fixtures/lcov/covered.info");
876 let uncovered = include_str!("../../../fixtures/lcov/uncovered.info");
877
878 let map1 = parse_lcov(covered).unwrap();
879 let map2 = parse_lcov(uncovered).unwrap();
880
881 let merged = merge_coverage(vec![map1, map2]);
882
883 let file = merged.get("src/lib.rs").unwrap();
885 assert_eq!(file.get(&1), Some(&1)); assert_eq!(file.get(&2), Some(&1)); assert_eq!(file.get(&3), Some(&1)); }
889}