Skip to main content

covguard_adapters_coverage/
lib.rs

1//! LCOV coverage file parser for covguard.
2//!
3//! This crate provides parsing and merging of LCOV format coverage files,
4//! producing a normalized coverage map that can be used by the domain layer.
5
6use std::collections::BTreeMap;
7
8use covguard_paths::{normalize_coverage_path, normalize_coverage_path_with_strip};
9use covguard_ports::CoverageProvider;
10use thiserror::Error;
11
12// ============================================================================
13// Types
14// ============================================================================
15
16/// A map of file paths to their line coverage data.
17///
18/// The outer map is keyed by normalized file path (repo-relative, forward slashes).
19/// The inner map is keyed by line number (1-indexed) with hit count as value.
20pub type CoverageMap = BTreeMap<String, BTreeMap<u32, u32>>;
21
22// ============================================================================
23// Errors
24// ============================================================================
25
26/// Errors that can occur while parsing LCOV files.
27#[derive(Debug, Error, PartialEq, Eq)]
28pub enum LcovError {
29    /// Invalid format in the LCOV file.
30    #[error("Invalid LCOV format: {0}")]
31    InvalidFormat(String),
32
33    /// I/O error while reading the file.
34    #[error("I/O error: {0}")]
35    IoError(String),
36}
37
38/// Default LCOV coverage provider backed by this crate's parser and merger.
39pub 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
58// ============================================================================
59// Path Normalization
60// ============================================================================
61
62/// Normalize a file path to repo-relative format.
63///
64/// - Converts backslashes to forward slashes
65/// - Removes leading `./`
66/// - Handles common absolute path prefixes (strips them if detected)
67///
68/// # Examples
69///
70/// ```
71/// use covguard_adapters_coverage::normalize_path;
72///
73/// assert_eq!(normalize_path("src/lib.rs"), "src/lib.rs");
74/// assert_eq!(normalize_path("./src/lib.rs"), "src/lib.rs");
75/// assert_eq!(normalize_path("src\\lib.rs"), "src/lib.rs");
76/// ```
77pub fn normalize_path(path: &str) -> String {
78    normalize_coverage_path(path)
79}
80
81/// Normalize a file path with optional prefix stripping.
82pub fn normalize_path_with_strip(path: &str, strip_prefixes: &[String]) -> String {
83    normalize_coverage_path_with_strip(path, strip_prefixes)
84}
85
86// ============================================================================
87// LCOV Parsing
88// ============================================================================
89
90/// Parse an LCOV format string into a coverage map.
91///
92/// LCOV format records:
93/// - `TN:<test name>` - Test name (optional, ignored)
94/// - `SF:<source file>` - Source file path (starts a record)
95/// - `DA:<line>,<hits>` - Line coverage data
96/// - `end_of_record` - Ends the current record
97///
98/// # Examples
99///
100/// ```
101/// use covguard_adapters_coverage::parse_lcov;
102///
103/// let lcov = r#"TN:
104/// SF:src/lib.rs
105/// DA:1,1
106/// DA:2,0
107/// end_of_record
108/// "#;
109///
110/// let coverage = parse_lcov(lcov).unwrap();
111/// assert_eq!(coverage.get("src/lib.rs").unwrap().get(&1), Some(&1));
112/// assert_eq!(coverage.get("src/lib.rs").unwrap().get(&2), Some(&0));
113/// ```
114pub fn parse_lcov(text: &str) -> Result<CoverageMap, LcovError> {
115    parse_lcov_with_strip(text, &[])
116}
117
118/// Parse an LCOV format string into a coverage map with optional prefix stripping.
119pub 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        // Skip empty lines
131        if line.is_empty() {
132            continue;
133        }
134
135        if line.starts_with("TN:") {
136            // Test name - ignored
137            continue;
138        }
139
140        if let Some(path) = line.strip_prefix("SF:") {
141            // Source file - start a new record
142            // If there was a previous file without end_of_record, save it
143            if let Some(file) = current_file.take() {
144                merge_file_coverage(&mut coverage_map, &file, &current_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            // Line coverage data
154            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            // End of record - save current file data
192            if let Some(file) = current_file.take() {
193                merge_file_coverage(&mut coverage_map, &file, &current_lines);
194                current_lines.clear();
195            }
196            continue;
197        }
198
199        // Ignore other LCOV records (FN, FNF, FNH, BRDA, BRF, BRH, LF, LH, etc.)
200        // These are function and branch coverage which we don't use
201    }
202
203    // Handle case where file has no end_of_record at the end
204    if let Some(file) = current_file {
205        merge_file_coverage(&mut coverage_map, &file, &current_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
221// ============================================================================
222// Coverage Merging
223// ============================================================================
224
225/// Merge multiple coverage maps into one.
226///
227/// For files that appear in multiple maps:
228/// - Lines are unioned
229/// - Hit counts for the same line take the maximum value
230///
231/// # Examples
232///
233/// ```
234/// use covguard_adapters_coverage::{parse_lcov, merge_coverage};
235///
236/// let lcov1 = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nend_of_record\n";
237/// let lcov2 = "SF:src/lib.rs\nDA:2,1\nDA:3,1\nend_of_record\n";
238///
239/// let map1 = parse_lcov(lcov1).unwrap();
240/// let map2 = parse_lcov(lcov2).unwrap();
241///
242/// let merged = merge_coverage(vec![map1, map2]);
243///
244/// let file = merged.get("src/lib.rs").unwrap();
245/// assert_eq!(file.get(&1), Some(&1));  // from map1
246/// assert_eq!(file.get(&2), Some(&1));  // max(0, 1) = 1
247/// assert_eq!(file.get(&3), Some(&1));  // from map2
248/// ```
249pub 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
265// ============================================================================
266// Query Helpers
267// ============================================================================
268
269/// Get the hit count for a specific file and line.
270///
271/// Returns `None` if the file or line is not in the coverage map.
272///
273/// # Examples
274///
275/// ```
276/// use covguard_adapters_coverage::{parse_lcov, get_hits};
277///
278/// let lcov = "SF:src/lib.rs\nDA:1,5\nend_of_record\n";
279/// let coverage = parse_lcov(lcov).unwrap();
280///
281/// assert_eq!(get_hits(&coverage, "src/lib.rs", 1), Some(5));
282/// assert_eq!(get_hits(&coverage, "src/lib.rs", 2), None);
283/// assert_eq!(get_hits(&coverage, "other.rs", 1), None);
284/// ```
285pub 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// ============================================================================
290// Tests
291// ============================================================================
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use covguard_ports::CoverageProvider;
297
298    // ------------------------------------------------------------------------
299    // Path Normalization Tests
300    // ------------------------------------------------------------------------
301
302    #[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    // ------------------------------------------------------------------------
362    // Fixture Tests
363    // ------------------------------------------------------------------------
364
365    #[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    // ------------------------------------------------------------------------
404    // Parsing Tests
405    // ------------------------------------------------------------------------
406
407    #[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        // Should still work - save data when encountering next SF or EOF
498        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    // ------------------------------------------------------------------------
539    // Error Tests
540    // ------------------------------------------------------------------------
541
542    #[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    // ------------------------------------------------------------------------
587    // Merge Tests
588    // ------------------------------------------------------------------------
589
590    #[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)); // max(0, 3)
670        assert_eq!(file.get(&2), Some(&5)); // max(5, 2)
671    }
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    // ------------------------------------------------------------------------
696    // Query Helper Tests
697    // ------------------------------------------------------------------------
698
699    #[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// ============================================================================
743// Property Tests
744// ============================================================================
745
746#[cfg(test)]
747mod proptests {
748    use super::*;
749    use proptest::prelude::*;
750
751    proptest! {
752        /// Merging coverage maps should be commutative
753        #[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        /// Merging with empty map is identity
771        #[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        /// Merge result has max hits for each line
784        #[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        /// Path normalization is idempotent
807        #[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        /// Parsing valid LCOV and reparsing gives same result
815        #[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// ============================================================================
835// Integration Tests (with actual fixture files)
836// ============================================================================
837
838#[cfg(test)]
839mod integration_tests {
840    use super::*;
841
842    /// Test parsing the actual covered.info fixture file
843    #[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        // Expected: {"src/lib.rs": {1: 1, 2: 1, 3: 1}}
849        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 parsing the actual uncovered.info fixture file
858    #[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        // Expected: {"src/lib.rs": {1: 0, 2: 0, 3: 0}}
864        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 merging the covered and uncovered fixture files
873    #[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        // Both files have same path, max should be taken
884        let file = merged.get("src/lib.rs").unwrap();
885        assert_eq!(file.get(&1), Some(&1)); // max(1, 0) = 1
886        assert_eq!(file.get(&2), Some(&1)); // max(1, 0) = 1
887        assert_eq!(file.get(&3), Some(&1)); // max(1, 0) = 1
888    }
889}