1use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14use crate::verdict::Severity;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ParseError {
21 MalformedLine { line_number: usize, content: String },
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct FileCoverage {
27 pub path: String,
28 pub lines: BTreeMap<u32, u32>,
30 pub lines_found: u32,
32 pub lines_hit: u32,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CoverageReport {
39 pub files: Vec<FileCoverage>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FileAnalysis {
45 pub path: String,
46 pub changed_lines: u32,
47 pub covered_lines: u32,
48 pub uncovered_line_numbers: Vec<u32>,
49 pub coverage_pct: f64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CoverageAnalysis {
55 pub files: Vec<FileAnalysis>,
56 pub total_changed: u32,
57 pub total_covered: u32,
58 pub overall_pct: f64,
59}
60
61pub fn parse_lcov(content: &str) -> Result<CoverageReport, ParseError> {
69 let mut files = Vec::new();
70 let mut current_path: Option<String> = None;
71 let mut current_lines: BTreeMap<u32, u32> = BTreeMap::new();
72 let mut lines_found: u32 = 0;
73 let mut lines_hit: u32 = 0;
74
75 for (idx, raw_line) in content.lines().enumerate() {
76 let line = raw_line.trim();
77 if line.is_empty() {
78 continue;
79 }
80
81 if let Some(path) = line.strip_prefix("SF:") {
82 current_path = Some(path.to_string());
84 current_lines.clear();
85 lines_found = 0;
86 lines_hit = 0;
87 } else if let Some(da) = line.strip_prefix("DA:") {
88 let parts: Vec<&str> = da.split(',').collect();
90 if parts.len() < 2 {
91 return Err(ParseError::MalformedLine {
92 line_number: idx + 1,
93 content: line.to_string(),
94 });
95 }
96 let line_no: u32 = parts[0].parse().map_err(|_| ParseError::MalformedLine {
97 line_number: idx + 1,
98 content: line.to_string(),
99 })?;
100 let count: u32 = parts[1].parse().map_err(|_| ParseError::MalformedLine {
101 line_number: idx + 1,
102 content: line.to_string(),
103 })?;
104 current_lines.insert(line_no, count);
105 } else if let Some(lf) = line.strip_prefix("LF:") {
106 lines_found = lf.parse().unwrap_or(0);
107 } else if let Some(lh) = line.strip_prefix("LH:") {
108 lines_hit = lh.parse().unwrap_or(0);
109 } else if line == "end_of_record" {
110 if let Some(path) = current_path.take() {
111 files.push(FileCoverage {
112 path,
113 lines: std::mem::take(&mut current_lines),
114 lines_found,
115 lines_hit,
116 });
117 }
118 lines_found = 0;
119 lines_hit = 0;
120 }
121 }
123
124 Ok(CoverageReport { files })
125}
126
127pub fn extract_changed_lines(patch: &str) -> Vec<u32> {
132 let mut result = Vec::new();
133 let mut new_line: u32 = 0;
134
135 for line in patch.lines() {
136 if line.starts_with("@@") {
137 if let Some(plus_pos) = line.find('+') {
140 let after_plus = &line[plus_pos + 1..];
141 let end = after_plus
142 .find(|c: char| !c.is_ascii_digit() && c != ',')
143 .unwrap_or(after_plus.len());
144 let range_str = &after_plus[..end];
145 let start_str = range_str.split(',').next().unwrap_or("0");
146 new_line = start_str.parse().unwrap_or(0);
147 }
148 } else if line.starts_with('+') {
149 result.push(new_line);
151 new_line += 1;
152 } else if line.starts_with('-') {
153 } else {
155 new_line += 1;
157 }
158 }
159
160 result
161}
162
163pub fn resolve_path(lcov_path: &str, pr_path: &str) -> bool {
168 let lcov_replaced = lcov_path.replace('\\', "/");
169 let lcov_normalized = lcov_replaced.strip_prefix("./").unwrap_or(&lcov_replaced);
170 let pr_normalized = pr_path.strip_prefix("./").unwrap_or(pr_path);
171
172 if lcov_normalized == pr_normalized {
173 return true;
174 }
175
176 let suffix = format!("/{pr_normalized}");
178 lcov_normalized.ends_with(&suffix)
179}
180
181pub fn analyze_coverage(
187 report: &CoverageReport,
188 changed_files: &[(String, Vec<u32>)],
189) -> CoverageAnalysis {
190 let mut file_analyses = Vec::new();
191 let mut total_changed: u32 = 0;
192 let mut total_covered: u32 = 0;
193
194 for (path, changed_lines) in changed_files {
195 if changed_lines.is_empty() {
196 continue;
197 }
198
199 let file_cov = report.files.iter().find(|f| resolve_path(&f.path, path));
201
202 let mut covered: u32 = 0;
203 let mut uncovered_lines = Vec::new();
204
205 for &line_no in changed_lines {
206 match file_cov {
207 Some(fc) => match fc.lines.get(&line_no) {
208 Some(&count) if count > 0 => covered += 1,
209 _ => uncovered_lines.push(line_no),
210 },
211 None => uncovered_lines.push(line_no),
212 }
213 }
214
215 let changed_count = changed_lines.len() as u32;
216 let pct = if changed_count > 0 {
217 (covered as f64 / changed_count as f64) * 100.0
218 } else {
219 100.0
220 };
221
222 total_changed += changed_count;
223 total_covered += covered;
224
225 file_analyses.push(FileAnalysis {
226 path: path.clone(),
227 changed_lines: changed_count,
228 covered_lines: covered,
229 uncovered_line_numbers: uncovered_lines,
230 coverage_pct: pct,
231 });
232 }
233
234 let overall_pct = if total_changed > 0 {
235 (total_covered as f64 / total_changed as f64) * 100.0
236 } else {
237 100.0
238 };
239
240 CoverageAnalysis {
241 files: file_analyses,
242 total_changed,
243 total_covered,
244 overall_pct,
245 }
246}
247
248pub fn classify_coverage_severity(
256 covered: usize,
257 total: usize,
258 warn_pct: usize,
259 error_pct: usize,
260) -> Severity {
261 if total == 0 {
262 return Severity::Pass;
263 }
264 if covered * 100 > warn_pct * total {
265 Severity::Pass
266 } else if covered * 100 > error_pct * total {
267 Severity::Warning
268 } else {
269 Severity::Error
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn make_lcov(path: &str, lines: &[(u32, u32)]) -> String {
281 let mut out = format!("SF:{path}\n");
282 for &(line, count) in lines {
283 out.push_str(&format!("DA:{line},{count}\n"));
284 }
285 out.push_str(&format!("LF:{}\n", lines.len()));
286 let hit = lines.iter().filter(|(_, c)| *c > 0).count();
287 out.push_str(&format!("LH:{hit}\n"));
288 out.push_str("end_of_record\n");
289 out
290 }
291
292 fn make_multi_lcov(entries: &[(&str, &[(u32, u32)])]) -> String {
294 entries
295 .iter()
296 .map(|(path, lines)| make_lcov(path, lines))
297 .collect::<Vec<_>>()
298 .join("")
299 }
300
301 fn make_patch(start: u32, added_lines: &[&str]) -> String {
303 let count = added_lines.len() as u32;
304 let mut out = format!("@@ -1,0 +{start},{count} @@\n");
305 for line in added_lines {
306 out.push_str(&format!("+{line}\n"));
307 }
308 out
309 }
310
311 #[test]
316 fn parse_lcov_single_file() {
317 let content = make_lcov("/src/main.rs", &[(1, 5), (2, 0), (3, 1)]);
318 let report = parse_lcov(&content).unwrap();
319 assert_eq!(report.files.len(), 1);
320 assert_eq!(report.files[0].path, "/src/main.rs");
321 assert_eq!(report.files[0].lines.len(), 3);
322 assert_eq!(report.files[0].lines[&1], 5);
323 assert_eq!(report.files[0].lines[&2], 0);
324 assert_eq!(report.files[0].lines_found, 3);
325 assert_eq!(report.files[0].lines_hit, 2);
326 }
327
328 #[test]
331 fn parse_lcov_multiple_files() {
332 let content =
333 make_multi_lcov(&[("/src/a.rs", &[(1, 1)]), ("/src/b.rs", &[(1, 0), (2, 3)])]);
334 let report = parse_lcov(&content).unwrap();
335 assert_eq!(report.files.len(), 2);
336 assert_eq!(report.files[0].path, "/src/a.rs");
337 assert_eq!(report.files[1].path, "/src/b.rs");
338 assert_eq!(report.files[1].lines.len(), 2);
339 }
340
341 #[test]
344 fn parse_lcov_ignores_branch_data() {
345 let content = "\
346TN:test_name
347SF:/src/main.rs
348FN:1,main
349FNDA:1,main
350FNF:1
351FNH:1
352DA:1,1
353DA:2,0
354BRDA:1,0,0,1
355BRF:1
356BRH:1
357LF:2
358LH:1
359end_of_record
360";
361 let report = parse_lcov(content).unwrap();
362 assert_eq!(report.files.len(), 1);
363 assert_eq!(report.files[0].lines.len(), 2);
365 assert_eq!(report.files[0].lines[&1], 1);
366 }
367
368 #[test]
371 fn parse_lcov_empty_content() {
372 let report = parse_lcov("").unwrap();
373 assert!(report.files.is_empty());
374 }
375
376 #[test]
379 fn parse_lcov_malformed_da() {
380 let content = "SF:/src/main.rs\nDA:bad\nend_of_record\n";
381 let err = parse_lcov(content).unwrap_err();
382 match err {
383 ParseError::MalformedLine {
384 line_number,
385 content,
386 } => {
387 assert_eq!(line_number, 2);
388 assert!(content.contains("DA:bad"));
389 }
390 }
391 }
392
393 #[test]
398 fn extract_changed_lines_single_hunk() {
399 let patch = make_patch(10, &["line1", "line2", "line3"]);
400 let lines = extract_changed_lines(&patch);
401 assert_eq!(lines, vec![10, 11, 12]);
402 }
403
404 #[test]
407 fn extract_changed_lines_multiple_hunks() {
408 let patch = "\
409@@ -1,3 +1,4 @@
410 context
411+added_at_2
412 context
413@@ -10,2 +11,3 @@
414 context
415+added_at_12
416+added_at_13
417";
418 let lines = extract_changed_lines(patch);
419 assert_eq!(lines, vec![2, 12, 13]);
420 }
421
422 #[test]
425 fn extract_changed_lines_deletions_only() {
426 let patch = "\
427@@ -1,3 +1,1 @@
428-removed1
429-removed2
430 kept
431";
432 let lines = extract_changed_lines(patch);
433 assert!(lines.is_empty());
434 }
435
436 #[test]
440 fn extract_changed_lines_increment_operator() {
441 let patch = "@@ -1,2 +1,3 @@\n counter = 0;\n+ ++counter;\n other();\n";
442 let lines = extract_changed_lines(patch);
443 assert_eq!(lines, vec![2], "++counter line must be included");
444 }
445
446 #[test]
451 fn resolve_path_absolute_to_relative() {
452 assert!(resolve_path(
453 "/home/user/project/src/main.rs",
454 "src/main.rs"
455 ));
456 }
457
458 #[test]
460 fn resolve_path_exact() {
461 assert!(resolve_path("src/main.rs", "src/main.rs"));
462 }
463
464 #[test]
467 fn resolve_path_no_match() {
468 assert!(!resolve_path("/src/other.rs", "src/main.rs"));
469 }
470
471 #[test]
474 fn resolve_path_windows_backslash() {
475 assert!(resolve_path("C:\\work\\repo\\src\\foo.rs", "src/foo.rs"));
476 }
477
478 #[test]
483 fn analyze_coverage_full() {
484 let report = parse_lcov(&make_lcov("src/main.rs", &[(1, 1), (2, 3), (3, 1)])).unwrap();
485 let changed = vec![("src/main.rs".to_string(), vec![1, 2, 3])];
486 let analysis = analyze_coverage(&report, &changed);
487 assert_eq!(analysis.total_changed, 3);
488 assert_eq!(analysis.total_covered, 3);
489 assert!((analysis.overall_pct - 100.0).abs() < f64::EPSILON);
490 assert!(analysis.files[0].uncovered_line_numbers.is_empty());
491 }
492
493 #[test]
496 fn analyze_coverage_partial() {
497 let report = parse_lcov(&make_lcov("src/main.rs", &[(1, 1), (2, 0), (3, 1)])).unwrap();
498 let changed = vec![("src/main.rs".to_string(), vec![1, 2, 3])];
499 let analysis = analyze_coverage(&report, &changed);
500 assert_eq!(analysis.total_covered, 2);
501 assert_eq!(analysis.total_changed, 3);
502 assert!((analysis.overall_pct - 66.666_666_666_666_6).abs() < 0.01);
504 assert_eq!(analysis.files[0].uncovered_line_numbers, vec![2]);
505 }
506
507 #[test]
510 fn analyze_coverage_missing_file() {
511 let report = parse_lcov(&make_lcov("src/other.rs", &[(1, 1)])).unwrap();
512 let changed = vec![("src/missing.rs".to_string(), vec![1, 2])];
513 let analysis = analyze_coverage(&report, &changed);
514 assert_eq!(analysis.total_covered, 0);
515 assert_eq!(analysis.total_changed, 2);
516 assert!((analysis.overall_pct - 0.0).abs() < f64::EPSILON);
517 }
518
519 #[test]
530 fn parse_lcov_da_with_checksum_accepted() {
531 let content = "SF:/src/a.rs\nDA:1,5,abc123\nLF:1\nLH:1\nend_of_record\n";
533 let report = parse_lcov(content).unwrap();
534 assert_eq!(report.files[0].lines[&1], 5);
535 }
536
537 #[test]
538 fn parse_lcov_da_single_field_rejected() {
539 let content = "SF:/src/a.rs\nDA:1\nend_of_record\n";
541 assert!(parse_lcov(content).is_err());
542 }
543
544 #[test]
551 fn parse_lcov_error_line_number_for_bad_line_no() {
552 let content = "SF:/src/a.rs\nDA:1,1\nDA:xyz,1\nend_of_record\n";
554 let err = parse_lcov(content).unwrap_err();
555 match err {
556 ParseError::MalformedLine { line_number, .. } => {
557 assert_eq!(line_number, 3, "error must report 1-indexed line number");
558 }
559 }
560 }
561
562 #[test]
566 fn parse_lcov_error_line_number_for_bad_count() {
567 let content = "SF:/src/a.rs\nDA:1,1\nDA:2,xyz\nend_of_record\n";
569 let err = parse_lcov(content).unwrap_err();
570 match err {
571 ParseError::MalformedLine { line_number, .. } => {
572 assert_eq!(line_number, 3, "error must report 1-indexed line number");
573 }
574 }
575 }
576
577 #[test]
585 fn extract_changed_lines_hunk_without_comma() {
586 let patch = "@@ -1,1 +5 @@\n+new_line\n";
588 let lines = extract_changed_lines(patch);
589 assert_eq!(
590 lines,
591 vec![5],
592 "single-line hunk start must parse correctly"
593 );
594 }
595
596 #[test]
604 fn analyze_coverage_nonzero_changed_computes_pct() {
605 let report = parse_lcov(&make_lcov("src/a.rs", &[(1, 0)])).unwrap();
607 let changed = vec![("src/a.rs".to_string(), vec![1])];
608 let analysis = analyze_coverage(&report, &changed);
609 assert_eq!(analysis.files[0].changed_lines, 1);
610 assert_eq!(analysis.files[0].covered_lines, 0);
611 assert!(
612 analysis.files[0].coverage_pct < 1.0,
613 "coverage_pct must be 0.0 when no lines are covered, got {}",
614 analysis.files[0].coverage_pct
615 );
616 }
617
618 #[test]
624 fn classify_severity_biconditional() {
625 assert_eq!(
627 classify_coverage_severity(0, 0, 80, 50),
628 Severity::Pass,
629 "total=0 must be Pass regardless of covered"
630 );
631
632 assert_eq!(
635 classify_coverage_severity(81, 100, 80, 50),
636 Severity::Pass,
637 "81% > 80% warn threshold => Pass"
638 );
639 assert_ne!(
641 classify_coverage_severity(80, 100, 80, 50),
642 Severity::Pass,
643 "80% == warn threshold => NOT Pass (contrapositive)"
644 );
645
646 assert_eq!(
648 classify_coverage_severity(51, 100, 80, 50),
649 Severity::Warning,
650 "51% > 50% error threshold but <= 80% => Warning"
651 );
652 assert_eq!(
654 classify_coverage_severity(50, 100, 80, 50),
655 Severity::Error,
656 "50% == error threshold => Error (contrapositive of Warning)"
657 );
658
659 assert_eq!(
661 classify_coverage_severity(0, 100, 80, 50),
662 Severity::Error,
663 "0% coverage => Error"
664 );
665 }
666
667 #[test]
671 fn classify_severity_exhaustive_small() {
672 let warn_pct = 80;
673 let error_pct = 50;
674
675 for total in 0..=20usize {
676 for covered in 0..=20usize {
677 let result = classify_coverage_severity(covered, total, warn_pct, error_pct);
678 let spec = if total == 0 {
679 Severity::Pass
680 } else if covered * 100 > warn_pct * total {
681 Severity::Pass
682 } else if covered * 100 > error_pct * total {
683 Severity::Warning
684 } else {
685 Severity::Error
686 };
687 assert_eq!(
688 result, spec,
689 "classify_coverage_severity({covered}, {total}, {warn_pct}, {error_pct}): \
690 got {result:?}, spec {spec:?}"
691 );
692 }
693 }
694 }
695}