use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::verdict::Severity;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
MalformedLine { line_number: usize, content: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileCoverage {
pub path: String,
pub lines: BTreeMap<u32, u32>,
pub lines_found: u32,
pub lines_hit: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageReport {
pub files: Vec<FileCoverage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAnalysis {
pub path: String,
pub changed_lines: u32,
pub covered_lines: u32,
pub uncovered_line_numbers: Vec<u32>,
pub coverage_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageAnalysis {
pub files: Vec<FileAnalysis>,
pub total_changed: u32,
pub total_covered: u32,
pub overall_pct: f64,
}
pub fn parse_lcov(content: &str) -> Result<CoverageReport, ParseError> {
let mut files = Vec::new();
let mut current_path: Option<String> = None;
let mut current_lines: BTreeMap<u32, u32> = BTreeMap::new();
let mut lines_found: u32 = 0;
let mut lines_hit: u32 = 0;
for (idx, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(path) = line.strip_prefix("SF:") {
current_path = Some(path.to_string());
current_lines.clear();
lines_found = 0;
lines_hit = 0;
} else if let Some(da) = line.strip_prefix("DA:") {
let parts: Vec<&str> = da.split(',').collect();
if parts.len() < 2 {
return Err(ParseError::MalformedLine {
line_number: idx + 1,
content: line.to_string(),
});
}
let line_no: u32 = parts[0].parse().map_err(|_| ParseError::MalformedLine {
line_number: idx + 1,
content: line.to_string(),
})?;
let count: u32 = parts[1].parse().map_err(|_| ParseError::MalformedLine {
line_number: idx + 1,
content: line.to_string(),
})?;
current_lines.insert(line_no, count);
} else if let Some(lf) = line.strip_prefix("LF:") {
lines_found = lf.parse().unwrap_or(0);
} else if let Some(lh) = line.strip_prefix("LH:") {
lines_hit = lh.parse().unwrap_or(0);
} else if line == "end_of_record" {
if let Some(path) = current_path.take() {
files.push(FileCoverage {
path,
lines: std::mem::take(&mut current_lines),
lines_found,
lines_hit,
});
}
lines_found = 0;
lines_hit = 0;
}
}
Ok(CoverageReport { files })
}
pub fn extract_changed_lines(patch: &str) -> Vec<u32> {
let mut result = Vec::new();
let mut new_line: u32 = 0;
for line in patch.lines() {
if line.starts_with("@@") {
if let Some(plus_pos) = line.find('+') {
let after_plus = &line[plus_pos + 1..];
let end = after_plus
.find(|c: char| !c.is_ascii_digit() && c != ',')
.unwrap_or(after_plus.len());
let range_str = &after_plus[..end];
let start_str = range_str.split(',').next().unwrap_or("0");
new_line = start_str.parse().unwrap_or(0);
}
} else if line.starts_with('+') {
result.push(new_line);
new_line += 1;
} else if line.starts_with('-') {
} else {
new_line += 1;
}
}
result
}
pub fn resolve_path(lcov_path: &str, pr_path: &str) -> bool {
let lcov_replaced = lcov_path.replace('\\', "/");
let lcov_normalized = lcov_replaced.strip_prefix("./").unwrap_or(&lcov_replaced);
let pr_normalized = pr_path.strip_prefix("./").unwrap_or(pr_path);
if lcov_normalized == pr_normalized {
return true;
}
let suffix = format!("/{pr_normalized}");
lcov_normalized.ends_with(&suffix)
}
pub fn analyze_coverage(
report: &CoverageReport,
changed_files: &[(String, Vec<u32>)],
) -> CoverageAnalysis {
let mut file_analyses = Vec::new();
let mut total_changed: u32 = 0;
let mut total_covered: u32 = 0;
for (path, changed_lines) in changed_files {
if changed_lines.is_empty() {
continue;
}
let file_cov = report.files.iter().find(|f| resolve_path(&f.path, path));
let mut covered: u32 = 0;
let mut uncovered_lines = Vec::new();
for &line_no in changed_lines {
match file_cov {
Some(fc) => match fc.lines.get(&line_no) {
Some(&count) if count > 0 => covered += 1,
_ => uncovered_lines.push(line_no),
},
None => uncovered_lines.push(line_no),
}
}
let changed_count = changed_lines.len() as u32;
let pct = if changed_count > 0 {
(covered as f64 / changed_count as f64) * 100.0
} else {
100.0
};
total_changed += changed_count;
total_covered += covered;
file_analyses.push(FileAnalysis {
path: path.clone(),
changed_lines: changed_count,
covered_lines: covered,
uncovered_line_numbers: uncovered_lines,
coverage_pct: pct,
});
}
let overall_pct = if total_changed > 0 {
(total_covered as f64 / total_changed as f64) * 100.0
} else {
100.0
};
CoverageAnalysis {
files: file_analyses,
total_changed,
total_covered,
overall_pct,
}
}
pub fn classify_coverage_severity(
covered: usize,
total: usize,
warn_pct: usize,
error_pct: usize,
) -> Severity {
if total == 0 {
return Severity::Pass;
}
if covered * 100 > warn_pct * total {
Severity::Pass
} else if covered * 100 > error_pct * total {
Severity::Warning
} else {
Severity::Error
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_lcov(path: &str, lines: &[(u32, u32)]) -> String {
let mut out = format!("SF:{path}\n");
for &(line, count) in lines {
out.push_str(&format!("DA:{line},{count}\n"));
}
out.push_str(&format!("LF:{}\n", lines.len()));
let hit = lines.iter().filter(|(_, c)| *c > 0).count();
out.push_str(&format!("LH:{hit}\n"));
out.push_str("end_of_record\n");
out
}
fn make_multi_lcov(entries: &[(&str, &[(u32, u32)])]) -> String {
entries
.iter()
.map(|(path, lines)| make_lcov(path, lines))
.collect::<Vec<_>>()
.join("")
}
fn make_patch(start: u32, added_lines: &[&str]) -> String {
let count = added_lines.len() as u32;
let mut out = format!("@@ -1,0 +{start},{count} @@\n");
for line in added_lines {
out.push_str(&format!("+{line}\n"));
}
out
}
#[test]
fn parse_lcov_single_file() {
let content = make_lcov("/src/main.rs", &[(1, 5), (2, 0), (3, 1)]);
let report = parse_lcov(&content).unwrap();
assert_eq!(report.files.len(), 1);
assert_eq!(report.files[0].path, "/src/main.rs");
assert_eq!(report.files[0].lines.len(), 3);
assert_eq!(report.files[0].lines[&1], 5);
assert_eq!(report.files[0].lines[&2], 0);
assert_eq!(report.files[0].lines_found, 3);
assert_eq!(report.files[0].lines_hit, 2);
}
#[test]
fn parse_lcov_multiple_files() {
let content =
make_multi_lcov(&[("/src/a.rs", &[(1, 1)]), ("/src/b.rs", &[(1, 0), (2, 3)])]);
let report = parse_lcov(&content).unwrap();
assert_eq!(report.files.len(), 2);
assert_eq!(report.files[0].path, "/src/a.rs");
assert_eq!(report.files[1].path, "/src/b.rs");
assert_eq!(report.files[1].lines.len(), 2);
}
#[test]
fn parse_lcov_ignores_branch_data() {
let content = "\
TN:test_name
SF:/src/main.rs
FN:1,main
FNDA:1,main
FNF:1
FNH:1
DA:1,1
DA:2,0
BRDA:1,0,0,1
BRF:1
BRH:1
LF:2
LH:1
end_of_record
";
let report = parse_lcov(content).unwrap();
assert_eq!(report.files.len(), 1);
assert_eq!(report.files[0].lines.len(), 2);
assert_eq!(report.files[0].lines[&1], 1);
}
#[test]
fn parse_lcov_empty_content() {
let report = parse_lcov("").unwrap();
assert!(report.files.is_empty());
}
#[test]
fn parse_lcov_malformed_da() {
let content = "SF:/src/main.rs\nDA:bad\nend_of_record\n";
let err = parse_lcov(content).unwrap_err();
match err {
ParseError::MalformedLine {
line_number,
content,
} => {
assert_eq!(line_number, 2);
assert!(content.contains("DA:bad"));
}
}
}
#[test]
fn extract_changed_lines_single_hunk() {
let patch = make_patch(10, &["line1", "line2", "line3"]);
let lines = extract_changed_lines(&patch);
assert_eq!(lines, vec![10, 11, 12]);
}
#[test]
fn extract_changed_lines_multiple_hunks() {
let patch = "\
@@ -1,3 +1,4 @@
context
+added_at_2
context
@@ -10,2 +11,3 @@
context
+added_at_12
+added_at_13
";
let lines = extract_changed_lines(patch);
assert_eq!(lines, vec![2, 12, 13]);
}
#[test]
fn extract_changed_lines_deletions_only() {
let patch = "\
@@ -1,3 +1,1 @@
-removed1
-removed2
kept
";
let lines = extract_changed_lines(patch);
assert!(lines.is_empty());
}
#[test]
fn extract_changed_lines_increment_operator() {
let patch = "@@ -1,2 +1,3 @@\n counter = 0;\n+ ++counter;\n other();\n";
let lines = extract_changed_lines(patch);
assert_eq!(lines, vec![2], "++counter line must be included");
}
#[test]
fn resolve_path_absolute_to_relative() {
assert!(resolve_path(
"/home/user/project/src/main.rs",
"src/main.rs"
));
}
#[test]
fn resolve_path_exact() {
assert!(resolve_path("src/main.rs", "src/main.rs"));
}
#[test]
fn resolve_path_no_match() {
assert!(!resolve_path("/src/other.rs", "src/main.rs"));
}
#[test]
fn resolve_path_windows_backslash() {
assert!(resolve_path("C:\\work\\repo\\src\\foo.rs", "src/foo.rs"));
}
#[test]
fn analyze_coverage_full() {
let report = parse_lcov(&make_lcov("src/main.rs", &[(1, 1), (2, 3), (3, 1)])).unwrap();
let changed = vec![("src/main.rs".to_string(), vec![1, 2, 3])];
let analysis = analyze_coverage(&report, &changed);
assert_eq!(analysis.total_changed, 3);
assert_eq!(analysis.total_covered, 3);
assert!((analysis.overall_pct - 100.0).abs() < f64::EPSILON);
assert!(analysis.files[0].uncovered_line_numbers.is_empty());
}
#[test]
fn analyze_coverage_partial() {
let report = parse_lcov(&make_lcov("src/main.rs", &[(1, 1), (2, 0), (3, 1)])).unwrap();
let changed = vec![("src/main.rs".to_string(), vec![1, 2, 3])];
let analysis = analyze_coverage(&report, &changed);
assert_eq!(analysis.total_covered, 2);
assert_eq!(analysis.total_changed, 3);
assert!((analysis.overall_pct - 66.666_666_666_666_6).abs() < 0.01);
assert_eq!(analysis.files[0].uncovered_line_numbers, vec![2]);
}
#[test]
fn analyze_coverage_missing_file() {
let report = parse_lcov(&make_lcov("src/other.rs", &[(1, 1)])).unwrap();
let changed = vec![("src/missing.rs".to_string(), vec![1, 2])];
let analysis = analyze_coverage(&report, &changed);
assert_eq!(analysis.total_covered, 0);
assert_eq!(analysis.total_changed, 2);
assert!((analysis.overall_pct - 0.0).abs() < f64::EPSILON);
}
#[test]
fn parse_lcov_da_with_checksum_accepted() {
let content = "SF:/src/a.rs\nDA:1,5,abc123\nLF:1\nLH:1\nend_of_record\n";
let report = parse_lcov(content).unwrap();
assert_eq!(report.files[0].lines[&1], 5);
}
#[test]
fn parse_lcov_da_single_field_rejected() {
let content = "SF:/src/a.rs\nDA:1\nend_of_record\n";
assert!(parse_lcov(content).is_err());
}
#[test]
fn parse_lcov_error_line_number_for_bad_line_no() {
let content = "SF:/src/a.rs\nDA:1,1\nDA:xyz,1\nend_of_record\n";
let err = parse_lcov(content).unwrap_err();
match err {
ParseError::MalformedLine { line_number, .. } => {
assert_eq!(line_number, 3, "error must report 1-indexed line number");
}
}
}
#[test]
fn parse_lcov_error_line_number_for_bad_count() {
let content = "SF:/src/a.rs\nDA:1,1\nDA:2,xyz\nend_of_record\n";
let err = parse_lcov(content).unwrap_err();
match err {
ParseError::MalformedLine { line_number, .. } => {
assert_eq!(line_number, 3, "error must report 1-indexed line number");
}
}
}
#[test]
fn extract_changed_lines_hunk_without_comma() {
let patch = "@@ -1,1 +5 @@\n+new_line\n";
let lines = extract_changed_lines(patch);
assert_eq!(
lines,
vec![5],
"single-line hunk start must parse correctly"
);
}
#[test]
fn analyze_coverage_nonzero_changed_computes_pct() {
let report = parse_lcov(&make_lcov("src/a.rs", &[(1, 0)])).unwrap();
let changed = vec![("src/a.rs".to_string(), vec![1])];
let analysis = analyze_coverage(&report, &changed);
assert_eq!(analysis.files[0].changed_lines, 1);
assert_eq!(analysis.files[0].covered_lines, 0);
assert!(
analysis.files[0].coverage_pct < 1.0,
"coverage_pct must be 0.0 when no lines are covered, got {}",
analysis.files[0].coverage_pct
);
}
#[test]
fn classify_severity_biconditional() {
assert_eq!(
classify_coverage_severity(0, 0, 80, 50),
Severity::Pass,
"total=0 must be Pass regardless of covered"
);
assert_eq!(
classify_coverage_severity(81, 100, 80, 50),
Severity::Pass,
"81% > 80% warn threshold => Pass"
);
assert_ne!(
classify_coverage_severity(80, 100, 80, 50),
Severity::Pass,
"80% == warn threshold => NOT Pass (contrapositive)"
);
assert_eq!(
classify_coverage_severity(51, 100, 80, 50),
Severity::Warning,
"51% > 50% error threshold but <= 80% => Warning"
);
assert_eq!(
classify_coverage_severity(50, 100, 80, 50),
Severity::Error,
"50% == error threshold => Error (contrapositive of Warning)"
);
assert_eq!(
classify_coverage_severity(0, 100, 80, 50),
Severity::Error,
"0% coverage => Error"
);
}
#[test]
fn classify_severity_exhaustive_small() {
let warn_pct = 80;
let error_pct = 50;
for total in 0..=20usize {
for covered in 0..=20usize {
let result = classify_coverage_severity(covered, total, warn_pct, error_pct);
let spec = if total == 0 {
Severity::Pass
} else if covered * 100 > warn_pct * total {
Severity::Pass
} else if covered * 100 > error_pct * total {
Severity::Warning
} else {
Severity::Error
};
assert_eq!(
result, spec,
"classify_coverage_severity({covered}, {total}, {warn_pct}, {error_pct}): \
got {result:?}, spec {spec:?}"
);
}
}
}
}