1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use super::types::{EvidenceKind, Finding, FindingEvidence};
10
11pub type CoverageIndex = HashMap<(PathBuf, usize), usize>;
13
14pub 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
41pub 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
47pub 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
59pub fn lookup_coverage(index: &CoverageIndex, file: &Path, line: usize) -> Option<usize> {
63 if let Some(&hits) = index.get(&(file.to_path_buf(), line)) {
65 return Some(hits);
66 }
67
68 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
83fn 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
99pub 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
107pub 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 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); assert_eq!(coverage_factor(3), 0.2); assert_eq!(coverage_factor(10), 0.0); assert_eq!(coverage_factor(100), -0.3); }
164
165 #[test]
166 fn test_coverage_adjusted_suspiciousness() {
167 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 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 let adjusted = coverage_adjusted_suspiciousness(0.5, 10, 1.0);
179 assert!((adjusted - 0.5).abs() < 0.01);
180
181 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 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); 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 #[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); 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 assert!(
247 (findings[0].suspiciousness - 0.72).abs() < 0.01,
248 "Expected ~0.72, got {}",
249 findings[0].suspiciousness
250 );
251 assert!(findings[0].evidence.iter().any(|e| e.description.contains("3 hits")));
253 }
254
255 #[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); 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 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]
276 fn test_apply_coverage_weights_no_match() {
277 use crate::bug_hunter::Finding;
278
279 let index = CoverageIndex::new(); 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 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]
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]
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]
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 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]
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]
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 assert!(index.is_empty());
364 }
365
366 #[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 assert!(index.is_empty());
373 }
374
375 #[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 assert_eq!(index.len(), 1);
382 assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 2)), Some(&3));
383 }
384
385 #[test]
387 fn test_coverage_factor_boundaries() {
388 assert_eq!(coverage_factor(1), 0.2); assert_eq!(coverage_factor(5), 0.2); assert_eq!(coverage_factor(6), 0.0); assert_eq!(coverage_factor(20), 0.0); assert_eq!(coverage_factor(21), -0.3); }
394
395 #[test]
397 fn test_coverage_adjusted_suspiciousness_clamping() {
398 let adjusted = coverage_adjusted_suspiciousness(0.9, 0, 2.0);
400 assert!(adjusted <= 1.0, "Should clamp to 1.0, got {}", adjusted);
401
402 let adjusted = coverage_adjusted_suspiciousness(0.1, 100, 5.0);
404 assert!(adjusted >= 0.0, "Should clamp to 0.0, got {}", adjusted);
405 }
406}