Skip to main content

batuta/bug_hunter/
coverage.rs

1//! Coverage-Based Hotpath Weighting
2//!
3//! Applies coverage data to weight findings - bugs in uncovered code paths
4//! are more suspicious than bugs in well-covered code.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use super::types::{EvidenceKind, Finding, FindingEvidence};
10
11/// Coverage index: maps (file, line) to execution count.
12pub type CoverageIndex = HashMap<(PathBuf, usize), usize>;
13
14/// Parse LCOV file into a coverage index.
15pub fn parse_lcov(content: &str) -> CoverageIndex {
16    let mut index = CoverageIndex::new();
17    let mut current_file: Option<PathBuf> = None;
18
19    for line in content.lines() {
20        if let Some(file) = line.strip_prefix("SF:") {
21            current_file = Some(PathBuf::from(file.trim()));
22        } else if let Some(da) = line.strip_prefix("DA:") {
23            if let Some(ref file) = current_file {
24                let parts: Vec<&str> = da.split(',').collect();
25                if parts.len() >= 2 {
26                    if let (Ok(line_num), Ok(hits)) =
27                        (parts[0].parse::<usize>(), parts[1].parse::<usize>())
28                    {
29                        index.insert((file.clone(), line_num), hits);
30                    }
31                }
32            }
33        } else if line == "end_of_record" {
34            current_file = None;
35        }
36    }
37
38    index
39}
40
41/// Load coverage index from an LCOV file.
42pub fn load_coverage_index(lcov_path: &Path) -> Option<CoverageIndex> {
43    let content = std::fs::read_to_string(lcov_path).ok()?;
44    Some(parse_lcov(&content))
45}
46
47/// Find the best coverage file from standard locations.
48pub fn find_coverage_file(project_path: &Path) -> Option<PathBuf> {
49    let candidates = [
50        project_path.join("lcov.info"),
51        project_path.join("target/coverage/lcov.info"),
52        project_path.join("coverage/lcov.info"),
53        project_path.join("target/llvm-cov/lcov.info"),
54    ];
55
56    candidates.into_iter().find(|c| c.exists())
57}
58
59/// Look up coverage for a specific file and line.
60///
61/// Returns the execution count, or None if the line isn't in the coverage data.
62pub fn lookup_coverage(index: &CoverageIndex, file: &Path, line: usize) -> Option<usize> {
63    // Try exact match first
64    if let Some(&hits) = index.get(&(file.to_path_buf(), line)) {
65        return Some(hits);
66    }
67
68    // Try matching just the filename for relative/absolute path differences
69    let file_name = file.file_name()?.to_string_lossy();
70    for ((path, l), &hits) in index {
71        if *l == line {
72            if let Some(name) = path.file_name() {
73                if name.to_string_lossy() == file_name {
74                    return Some(hits);
75                }
76            }
77        }
78    }
79
80    None
81}
82
83/// Compute coverage weight factor for a hit count.
84///
85/// Returns a value in [-0.5, 0.5]:
86/// - Uncovered (0 hits): +0.5 (boost suspiciousness)
87/// - Low coverage (1-5 hits): +0.2
88/// - Medium coverage (6-20 hits): 0.0 (neutral)
89/// - High coverage (>20 hits): -0.3 (reduce suspiciousness)
90fn coverage_factor(hits: usize) -> f64 {
91    match hits {
92        0 => 0.5,
93        1..=5 => 0.2,
94        6..=20 => 0.0,
95        _ => -0.3,
96    }
97}
98
99/// Adjust suspiciousness based on coverage.
100///
101/// Formula: `(base * (1 + weight * coverage_factor)).clamp(0, 1)`
102pub fn coverage_adjusted_suspiciousness(base: f64, hits: usize, weight: f64) -> f64 {
103    let factor = coverage_factor(hits);
104    (base * (1.0 + weight * factor)).clamp(0.0, 1.0)
105}
106
107/// Apply coverage weights to findings in-place.
108pub fn apply_coverage_weights(findings: &mut [Finding], index: &CoverageIndex, weight: f64) {
109    for finding in findings.iter_mut() {
110        if let Some(hits) = lookup_coverage(index, &finding.file, finding.line) {
111            let original = finding.suspiciousness;
112            finding.suspiciousness = coverage_adjusted_suspiciousness(original, hits, weight);
113
114            // Add evidence
115            let coverage_desc = match hits {
116                0 => "uncovered".to_string(),
117                n => format!("{} hits", n),
118            };
119            finding.evidence.push(FindingEvidence {
120                evidence_type: EvidenceKind::SbflScore,
121                description: format!("Coverage: {}", coverage_desc),
122                data: Some(hits.to_string()),
123            });
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_parse_lcov_basic() {
134        let content = r#"SF:src/lib.rs
135DA:1,10
136DA:2,5
137DA:3,0
138end_of_record
139SF:src/main.rs
140DA:1,1
141end_of_record
142"#;
143
144        let index = parse_lcov(content);
145        assert_eq!(index.len(), 4);
146        assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 1)), Some(&10));
147        assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 3)), Some(&0));
148        assert_eq!(index.get(&(PathBuf::from("src/main.rs"), 1)), Some(&1));
149    }
150
151    #[test]
152    fn test_parse_lcov_empty() {
153        let index = parse_lcov("");
154        assert!(index.is_empty());
155    }
156
157    #[test]
158    fn test_coverage_factor() {
159        assert_eq!(coverage_factor(0), 0.5); // uncovered = boost
160        assert_eq!(coverage_factor(3), 0.2); // low coverage = small boost
161        assert_eq!(coverage_factor(10), 0.0); // medium = neutral
162        assert_eq!(coverage_factor(100), -0.3); // high = reduce
163    }
164
165    #[test]
166    fn test_coverage_adjusted_suspiciousness() {
167        // Uncovered code gets boosted
168        let adjusted = coverage_adjusted_suspiciousness(0.5, 0, 1.0);
169        assert!(adjusted > 0.5);
170        assert!((adjusted - 0.75).abs() < 0.01);
171
172        // High coverage gets reduced
173        let adjusted = coverage_adjusted_suspiciousness(0.5, 100, 1.0);
174        assert!(adjusted < 0.5);
175        assert!((adjusted - 0.35).abs() < 0.01);
176
177        // Neutral coverage unchanged
178        let adjusted = coverage_adjusted_suspiciousness(0.5, 10, 1.0);
179        assert!((adjusted - 0.5).abs() < 0.01);
180
181        // Zero weight = no change
182        let adjusted = coverage_adjusted_suspiciousness(0.5, 0, 0.0);
183        assert!((adjusted - 0.5).abs() < 0.01);
184    }
185
186    #[test]
187    fn test_lookup_coverage_exact() {
188        let mut index = CoverageIndex::new();
189        index.insert((PathBuf::from("src/lib.rs"), 10), 5);
190
191        let result = lookup_coverage(&index, Path::new("src/lib.rs"), 10);
192        assert_eq!(result, Some(5));
193    }
194
195    #[test]
196    fn test_lookup_coverage_filename_match() {
197        let mut index = CoverageIndex::new();
198        index.insert((PathBuf::from("/full/path/to/lib.rs"), 10), 5);
199
200        // Should match by filename even with different path
201        let result = lookup_coverage(&index, Path::new("src/lib.rs"), 10);
202        assert_eq!(result, Some(5));
203    }
204
205    #[test]
206    fn test_lookup_coverage_not_found() {
207        let index = CoverageIndex::new();
208        let result = lookup_coverage(&index, Path::new("src/lib.rs"), 10);
209        assert_eq!(result, None);
210    }
211
212    #[test]
213    fn test_apply_coverage_weights() {
214        use crate::bug_hunter::Finding;
215
216        let mut index = CoverageIndex::new();
217        index.insert((PathBuf::from("src/lib.rs"), 10), 0); // uncovered
218
219        let mut findings =
220            vec![Finding::new("F-001", "src/lib.rs", 10, "Test").with_suspiciousness(0.5)];
221
222        apply_coverage_weights(&mut findings, &index, 1.0);
223
224        assert!(findings[0].suspiciousness > 0.5);
225        assert!(findings[0].evidence.iter().any(|e| e.description.contains("Coverage")));
226    }
227
228    // ========================================================================
229    // Additional coverage tests
230    // ========================================================================
231
232    /// Test apply_coverage_weights with non-zero hit count (covers "N hits" branch)
233    #[test]
234    fn test_apply_coverage_weights_nonzero_hits() {
235        use crate::bug_hunter::Finding;
236
237        let mut index = CoverageIndex::new();
238        index.insert((PathBuf::from("src/main.rs"), 5), 3); // low coverage: 3 hits
239
240        let mut findings =
241            vec![Finding::new("F-002", "src/main.rs", 5, "Test finding").with_suspiciousness(0.6)];
242
243        apply_coverage_weights(&mut findings, &index, 1.0);
244
245        // Low coverage (1-5 hits) factor = 0.2, so 0.6 * (1 + 1.0 * 0.2) = 0.72
246        assert!(
247            (findings[0].suspiciousness - 0.72).abs() < 0.01,
248            "Expected ~0.72, got {}",
249            findings[0].suspiciousness
250        );
251        // Evidence should say "3 hits"
252        assert!(findings[0].evidence.iter().any(|e| e.description.contains("3 hits")));
253    }
254
255    /// Test apply_coverage_weights with high hit count (covers >20 hits branch)
256    #[test]
257    fn test_apply_coverage_weights_high_hits() {
258        use crate::bug_hunter::Finding;
259
260        let mut index = CoverageIndex::new();
261        index.insert((PathBuf::from("src/lib.rs"), 20), 50); // high coverage: 50 hits
262
263        let mut findings =
264            vec![Finding::new("F-003", "src/lib.rs", 20, "Well-tested code")
265                .with_suspiciousness(0.8)];
266
267        apply_coverage_weights(&mut findings, &index, 1.0);
268
269        // High coverage (>20 hits) factor = -0.3, so 0.8 * (1 + 1.0 * -0.3) = 0.56
270        assert!(findings[0].suspiciousness < 0.8, "High coverage should reduce suspiciousness");
271        assert!(findings[0].evidence.iter().any(|e| e.description.contains("50 hits")));
272    }
273
274    /// Test apply_coverage_weights with no matching coverage data (no evidence added)
275    #[test]
276    fn test_apply_coverage_weights_no_match() {
277        use crate::bug_hunter::Finding;
278
279        let index = CoverageIndex::new(); // empty index
280
281        let mut findings =
282            vec![Finding::new("F-004", "src/missing.rs", 1, "No coverage data")
283                .with_suspiciousness(0.5)];
284
285        apply_coverage_weights(&mut findings, &index, 1.0);
286
287        // Should be unchanged (no matching coverage)
288        assert!(
289            (findings[0].suspiciousness - 0.5).abs() < 0.01,
290            "Suspiciousness should be unchanged"
291        );
292        assert!(
293            findings[0].evidence.is_empty(),
294            "No evidence should be added when no coverage match"
295        );
296    }
297
298    /// Test load_coverage_index with a temp file
299    #[test]
300    fn test_load_coverage_index_from_file() {
301        let temp_dir = std::env::temp_dir().join("batuta_coverage_load_test");
302        let _ = std::fs::remove_dir_all(&temp_dir);
303        std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
304
305        let lcov_path = temp_dir.join("lcov.info");
306        std::fs::write(&lcov_path, "SF:src/lib.rs\nDA:1,5\nDA:2,0\nend_of_record\n")
307            .expect("fs write failed");
308
309        let index = load_coverage_index(&lcov_path);
310        assert!(index.is_some());
311        let index = index.expect("unexpected failure");
312        assert_eq!(index.len(), 2);
313        assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 1)), Some(&5));
314        assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 2)), Some(&0));
315
316        let _ = std::fs::remove_dir_all(&temp_dir);
317    }
318
319    /// Test load_coverage_index with nonexistent file returns None
320    #[test]
321    fn test_load_coverage_index_missing_file() {
322        let index = load_coverage_index(Path::new("/nonexistent/lcov.info"));
323        assert!(index.is_none());
324    }
325
326    /// Test find_coverage_file with existing file
327    #[test]
328    fn test_find_coverage_file_found() {
329        let temp_dir = std::env::temp_dir().join("batuta_find_cov_test");
330        let _ = std::fs::remove_dir_all(&temp_dir);
331        std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
332
333        // Create lcov.info at root
334        std::fs::write(temp_dir.join("lcov.info"), "SF:test\nend_of_record\n")
335            .expect("fs write failed");
336
337        let result = find_coverage_file(&temp_dir);
338        assert!(result.is_some());
339        assert!(result.expect("operation failed").ends_with("lcov.info"));
340
341        let _ = std::fs::remove_dir_all(&temp_dir);
342    }
343
344    /// Test find_coverage_file with no matching files
345    #[test]
346    fn test_find_coverage_file_not_found() {
347        let temp_dir = std::env::temp_dir().join("batuta_find_cov_none_test");
348        let _ = std::fs::remove_dir_all(&temp_dir);
349        std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
350
351        let result = find_coverage_file(&temp_dir);
352        assert!(result.is_none());
353
354        let _ = std::fs::remove_dir_all(&temp_dir);
355    }
356
357    /// Test parse_lcov with malformed DA lines (unparseable numbers)
358    #[test]
359    fn test_parse_lcov_malformed_da() {
360        let content = "SF:src/lib.rs\nDA:abc,def\nDA:1,xyz\nDA:,5\nend_of_record\n";
361        let index = parse_lcov(content);
362        // None of the malformed DA lines should parse
363        assert!(index.is_empty());
364    }
365
366    /// Test parse_lcov with DA line missing count field
367    #[test]
368    fn test_parse_lcov_da_missing_count() {
369        let content = "SF:src/lib.rs\nDA:1\nend_of_record\n";
370        let index = parse_lcov(content);
371        // DA with only one field (no comma) should be skipped
372        assert!(index.is_empty());
373    }
374
375    /// Test parse_lcov with DA before any SF (no current file)
376    #[test]
377    fn test_parse_lcov_da_before_sf() {
378        let content = "DA:1,5\nSF:src/lib.rs\nDA:2,3\nend_of_record\n";
379        let index = parse_lcov(content);
380        // First DA should be ignored (no current_file), second should be captured
381        assert_eq!(index.len(), 1);
382        assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 2)), Some(&3));
383    }
384
385    /// Test coverage_factor for boundary values
386    #[test]
387    fn test_coverage_factor_boundaries() {
388        assert_eq!(coverage_factor(1), 0.2); // lower bound of 1-5 range
389        assert_eq!(coverage_factor(5), 0.2); // upper bound of 1-5 range
390        assert_eq!(coverage_factor(6), 0.0); // lower bound of 6-20 range
391        assert_eq!(coverage_factor(20), 0.0); // upper bound of 6-20 range
392        assert_eq!(coverage_factor(21), -0.3); // first value in >20 range
393    }
394
395    /// Test coverage_adjusted_suspiciousness clamping at bounds
396    #[test]
397    fn test_coverage_adjusted_suspiciousness_clamping() {
398        // Should clamp to 1.0 max
399        let adjusted = coverage_adjusted_suspiciousness(0.9, 0, 2.0);
400        assert!(adjusted <= 1.0, "Should clamp to 1.0, got {}", adjusted);
401
402        // Should clamp to 0.0 min
403        let adjusted = coverage_adjusted_suspiciousness(0.1, 100, 5.0);
404        assert!(adjusted >= 0.0, "Should clamp to 0.0, got {}", adjusted);
405    }
406}