use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::types::{EvidenceKind, Finding, FindingEvidence};
pub type CoverageIndex = HashMap<(PathBuf, usize), usize>;
pub fn parse_lcov(content: &str) -> CoverageIndex {
let mut index = CoverageIndex::new();
let mut current_file: Option<PathBuf> = None;
for line in content.lines() {
if let Some(file) = line.strip_prefix("SF:") {
current_file = Some(PathBuf::from(file.trim()));
} else if let Some(da) = line.strip_prefix("DA:") {
if let Some(ref file) = current_file {
let parts: Vec<&str> = da.split(',').collect();
if parts.len() >= 2 {
if let (Ok(line_num), Ok(hits)) =
(parts[0].parse::<usize>(), parts[1].parse::<usize>())
{
index.insert((file.clone(), line_num), hits);
}
}
}
} else if line == "end_of_record" {
current_file = None;
}
}
index
}
pub fn load_coverage_index(lcov_path: &Path) -> Option<CoverageIndex> {
let content = std::fs::read_to_string(lcov_path).ok()?;
Some(parse_lcov(&content))
}
pub fn find_coverage_file(project_path: &Path) -> Option<PathBuf> {
let candidates = [
project_path.join("lcov.info"),
project_path.join("target/coverage/lcov.info"),
project_path.join("coverage/lcov.info"),
project_path.join("target/llvm-cov/lcov.info"),
];
candidates.into_iter().find(|c| c.exists())
}
pub fn lookup_coverage(index: &CoverageIndex, file: &Path, line: usize) -> Option<usize> {
if let Some(&hits) = index.get(&(file.to_path_buf(), line)) {
return Some(hits);
}
let file_name = file.file_name()?.to_string_lossy();
for ((path, l), &hits) in index {
if *l == line {
if let Some(name) = path.file_name() {
if name.to_string_lossy() == file_name {
return Some(hits);
}
}
}
}
None
}
fn coverage_factor(hits: usize) -> f64 {
match hits {
0 => 0.5,
1..=5 => 0.2,
6..=20 => 0.0,
_ => -0.3,
}
}
pub fn coverage_adjusted_suspiciousness(base: f64, hits: usize, weight: f64) -> f64 {
let factor = coverage_factor(hits);
(base * (1.0 + weight * factor)).clamp(0.0, 1.0)
}
pub fn apply_coverage_weights(findings: &mut [Finding], index: &CoverageIndex, weight: f64) {
for finding in findings.iter_mut() {
if let Some(hits) = lookup_coverage(index, &finding.file, finding.line) {
let original = finding.suspiciousness;
finding.suspiciousness = coverage_adjusted_suspiciousness(original, hits, weight);
let coverage_desc = match hits {
0 => "uncovered".to_string(),
n => format!("{} hits", n),
};
finding.evidence.push(FindingEvidence {
evidence_type: EvidenceKind::SbflScore,
description: format!("Coverage: {}", coverage_desc),
data: Some(hits.to_string()),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_lcov_basic() {
let content = r#"SF:src/lib.rs
DA:1,10
DA:2,5
DA:3,0
end_of_record
SF:src/main.rs
DA:1,1
end_of_record
"#;
let index = parse_lcov(content);
assert_eq!(index.len(), 4);
assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 1)), Some(&10));
assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 3)), Some(&0));
assert_eq!(index.get(&(PathBuf::from("src/main.rs"), 1)), Some(&1));
}
#[test]
fn test_parse_lcov_empty() {
let index = parse_lcov("");
assert!(index.is_empty());
}
#[test]
fn test_coverage_factor() {
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); }
#[test]
fn test_coverage_adjusted_suspiciousness() {
let adjusted = coverage_adjusted_suspiciousness(0.5, 0, 1.0);
assert!(adjusted > 0.5);
assert!((adjusted - 0.75).abs() < 0.01);
let adjusted = coverage_adjusted_suspiciousness(0.5, 100, 1.0);
assert!(adjusted < 0.5);
assert!((adjusted - 0.35).abs() < 0.01);
let adjusted = coverage_adjusted_suspiciousness(0.5, 10, 1.0);
assert!((adjusted - 0.5).abs() < 0.01);
let adjusted = coverage_adjusted_suspiciousness(0.5, 0, 0.0);
assert!((adjusted - 0.5).abs() < 0.01);
}
#[test]
fn test_lookup_coverage_exact() {
let mut index = CoverageIndex::new();
index.insert((PathBuf::from("src/lib.rs"), 10), 5);
let result = lookup_coverage(&index, Path::new("src/lib.rs"), 10);
assert_eq!(result, Some(5));
}
#[test]
fn test_lookup_coverage_filename_match() {
let mut index = CoverageIndex::new();
index.insert((PathBuf::from("/full/path/to/lib.rs"), 10), 5);
let result = lookup_coverage(&index, Path::new("src/lib.rs"), 10);
assert_eq!(result, Some(5));
}
#[test]
fn test_lookup_coverage_not_found() {
let index = CoverageIndex::new();
let result = lookup_coverage(&index, Path::new("src/lib.rs"), 10);
assert_eq!(result, None);
}
#[test]
fn test_apply_coverage_weights() {
use crate::bug_hunter::Finding;
let mut index = CoverageIndex::new();
index.insert((PathBuf::from("src/lib.rs"), 10), 0);
let mut findings =
vec![Finding::new("F-001", "src/lib.rs", 10, "Test").with_suspiciousness(0.5)];
apply_coverage_weights(&mut findings, &index, 1.0);
assert!(findings[0].suspiciousness > 0.5);
assert!(findings[0].evidence.iter().any(|e| e.description.contains("Coverage")));
}
#[test]
fn test_apply_coverage_weights_nonzero_hits() {
use crate::bug_hunter::Finding;
let mut index = CoverageIndex::new();
index.insert((PathBuf::from("src/main.rs"), 5), 3);
let mut findings =
vec![Finding::new("F-002", "src/main.rs", 5, "Test finding").with_suspiciousness(0.6)];
apply_coverage_weights(&mut findings, &index, 1.0);
assert!(
(findings[0].suspiciousness - 0.72).abs() < 0.01,
"Expected ~0.72, got {}",
findings[0].suspiciousness
);
assert!(findings[0].evidence.iter().any(|e| e.description.contains("3 hits")));
}
#[test]
fn test_apply_coverage_weights_high_hits() {
use crate::bug_hunter::Finding;
let mut index = CoverageIndex::new();
index.insert((PathBuf::from("src/lib.rs"), 20), 50);
let mut findings =
vec![Finding::new("F-003", "src/lib.rs", 20, "Well-tested code")
.with_suspiciousness(0.8)];
apply_coverage_weights(&mut findings, &index, 1.0);
assert!(findings[0].suspiciousness < 0.8, "High coverage should reduce suspiciousness");
assert!(findings[0].evidence.iter().any(|e| e.description.contains("50 hits")));
}
#[test]
fn test_apply_coverage_weights_no_match() {
use crate::bug_hunter::Finding;
let index = CoverageIndex::new();
let mut findings =
vec![Finding::new("F-004", "src/missing.rs", 1, "No coverage data")
.with_suspiciousness(0.5)];
apply_coverage_weights(&mut findings, &index, 1.0);
assert!(
(findings[0].suspiciousness - 0.5).abs() < 0.01,
"Suspiciousness should be unchanged"
);
assert!(
findings[0].evidence.is_empty(),
"No evidence should be added when no coverage match"
);
}
#[test]
fn test_load_coverage_index_from_file() {
let temp_dir = std::env::temp_dir().join("batuta_coverage_load_test");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
let lcov_path = temp_dir.join("lcov.info");
std::fs::write(&lcov_path, "SF:src/lib.rs\nDA:1,5\nDA:2,0\nend_of_record\n")
.expect("fs write failed");
let index = load_coverage_index(&lcov_path);
assert!(index.is_some());
let index = index.expect("unexpected failure");
assert_eq!(index.len(), 2);
assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 1)), Some(&5));
assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 2)), Some(&0));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_load_coverage_index_missing_file() {
let index = load_coverage_index(Path::new("/nonexistent/lcov.info"));
assert!(index.is_none());
}
#[test]
fn test_find_coverage_file_found() {
let temp_dir = std::env::temp_dir().join("batuta_find_cov_test");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
std::fs::write(temp_dir.join("lcov.info"), "SF:test\nend_of_record\n")
.expect("fs write failed");
let result = find_coverage_file(&temp_dir);
assert!(result.is_some());
assert!(result.expect("operation failed").ends_with("lcov.info"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_find_coverage_file_not_found() {
let temp_dir = std::env::temp_dir().join("batuta_find_cov_none_test");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
let result = find_coverage_file(&temp_dir);
assert!(result.is_none());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_parse_lcov_malformed_da() {
let content = "SF:src/lib.rs\nDA:abc,def\nDA:1,xyz\nDA:,5\nend_of_record\n";
let index = parse_lcov(content);
assert!(index.is_empty());
}
#[test]
fn test_parse_lcov_da_missing_count() {
let content = "SF:src/lib.rs\nDA:1\nend_of_record\n";
let index = parse_lcov(content);
assert!(index.is_empty());
}
#[test]
fn test_parse_lcov_da_before_sf() {
let content = "DA:1,5\nSF:src/lib.rs\nDA:2,3\nend_of_record\n";
let index = parse_lcov(content);
assert_eq!(index.len(), 1);
assert_eq!(index.get(&(PathBuf::from("src/lib.rs"), 2)), Some(&3));
}
#[test]
fn test_coverage_factor_boundaries() {
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); }
#[test]
fn test_coverage_adjusted_suspiciousness_clamping() {
let adjusted = coverage_adjusted_suspiciousness(0.9, 0, 2.0);
assert!(adjusted <= 1.0, "Should clamp to 1.0, got {}", adjusted);
let adjusted = coverage_adjusted_suspiciousness(0.1, 100, 5.0);
assert!(adjusted >= 0.0, "Should clamp to 0.0, got {}", adjusted);
}
}