1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct FileCoverage {
11 pub lines_found: u32,
12 pub lines_hit: u32,
13 pub functions_found: u32,
14 pub functions_hit: u32,
15 pub branches_found: u32,
16 pub branches_hit: u32,
17}
18
19impl FileCoverage {
20 #[must_use]
21 pub fn line_pct(&self) -> f64 {
22 if self.lines_found == 0 {
23 0.0
24 } else {
25 (f64::from(self.lines_hit) / f64::from(self.lines_found)) * 100.0
26 }
27 }
28
29 #[must_use]
30 pub fn function_pct(&self) -> f64 {
31 if self.functions_found == 0 {
32 0.0
33 } else {
34 (f64::from(self.functions_hit) / f64::from(self.functions_found)) * 100.0
35 }
36 }
37
38 #[must_use]
39 pub fn branch_pct(&self) -> f64 {
40 if self.branches_found == 0 {
41 0.0
42 } else {
43 (f64::from(self.branches_hit) / f64::from(self.branches_found)) * 100.0
44 }
45 }
46}
47
48#[allow(clippy::struct_excessive_bools)]
59#[derive(Default)]
60struct LcovRecord {
61 lf: u32,
63 lh: u32,
64 fnf: u32,
65 fnh: u32,
66 brf: u32,
67 brh: u32,
68 saw_lf: bool,
69 saw_lh: bool,
70 saw_fnf: bool,
71 saw_fnh: bool,
72 saw_brf: bool,
73 saw_brh: bool,
74 da_found: u32,
76 da_hit: u32,
77 fn_found: u32,
78 fnda_hit: u32,
79 brda_found: u32,
80 brda_hit: u32,
81}
82
83impl LcovRecord {
84 const fn finalize(&self) -> FileCoverage {
86 FileCoverage {
87 lines_found: if self.saw_lf { self.lf } else { self.da_found },
88 lines_hit: if self.saw_lh { self.lh } else { self.da_hit },
89 functions_found: if self.saw_fnf {
90 self.fnf
91 } else {
92 self.fn_found
93 },
94 functions_hit: if self.saw_fnh {
95 self.fnh
96 } else {
97 self.fnda_hit
98 },
99 branches_found: if self.saw_brf {
100 self.brf
101 } else {
102 self.brda_found
103 },
104 branches_hit: if self.saw_brh {
105 self.brh
106 } else {
107 self.brda_hit
108 },
109 }
110 }
111
112 fn apply(&mut self, line: &str) {
116 if let Some(val) = line.strip_prefix("LF:") {
117 self.lf = val.parse().unwrap_or(0);
118 self.saw_lf = true;
119 } else if let Some(val) = line.strip_prefix("LH:") {
120 self.lh = val.parse().unwrap_or(0);
121 self.saw_lh = true;
122 } else if let Some(val) = line.strip_prefix("FNF:") {
123 self.fnf = val.parse().unwrap_or(0);
124 self.saw_fnf = true;
125 } else if let Some(val) = line.strip_prefix("FNH:") {
126 self.fnh = val.parse().unwrap_or(0);
127 self.saw_fnh = true;
128 } else if let Some(val) = line.strip_prefix("BRF:") {
129 self.brf = val.parse().unwrap_or(0);
130 self.saw_brf = true;
131 } else if let Some(val) = line.strip_prefix("BRH:") {
132 self.brh = val.parse().unwrap_or(0);
133 self.saw_brh = true;
134 } else if let Some(val) = line.strip_prefix("DA:") {
135 self.record_da(val);
136 } else if line.starts_with("FN:") {
137 self.fn_found += 1;
139 } else if let Some(val) = line.strip_prefix("FNDA:") {
140 self.record_fnda(val);
141 } else if let Some(val) = line.strip_prefix("BRDA:") {
142 self.record_brda(val);
143 }
144 }
145
146 fn record_da(&mut self, val: &str) {
148 self.da_found += 1;
149 if val.split(',').nth(1).is_some_and(lcov_count_nonzero) {
150 self.da_hit += 1;
151 }
152 }
153
154 fn record_fnda(&mut self, val: &str) {
156 if val.split(',').next().is_some_and(lcov_count_nonzero) {
157 self.fnda_hit += 1;
158 }
159 }
160
161 fn record_brda(&mut self, val: &str) {
163 self.brda_found += 1;
164 if val.split(',').nth(3).is_some_and(lcov_count_nonzero) {
165 self.brda_hit += 1;
166 }
167 }
168}
169
170fn lcov_count_nonzero(field: &str) -> bool {
172 !matches!(field.trim(), "" | "0" | "-")
173}
174
175#[must_use]
185pub fn parse_lcov(content: &str) -> HashMap<PathBuf, FileCoverage> {
186 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
187
188 let mut current_path: Option<PathBuf> = None;
189 let mut rec = LcovRecord::default();
190
191 for line in content.lines() {
192 let line = line.trim();
193 if let Some(path_str) = line.strip_prefix("SF:") {
194 current_path = Some(PathBuf::from(path_str.replace('\\', "/")));
195 rec = LcovRecord::default();
196 } else if line == "end_of_record" {
197 if let Some(path) = current_path.take() {
198 result.insert(path, rec.finalize());
199 }
200 rec = LcovRecord::default();
201 } else {
202 rec.apply(line);
203 }
204 }
205
206 result
207}
208
209#[must_use]
217#[allow(clippy::implicit_hasher)] pub fn lookup_coverage<'a>(
219 map: &'a HashMap<PathBuf, FileCoverage>,
220 relative_path: &str,
221) -> Option<&'a FileCoverage> {
222 let rel = PathBuf::from(relative_path.replace('\\', "/"));
223
224 if let Some(cov) = map.get(&rel) {
226 return Some(cov);
227 }
228
229 let rel_components: Vec<_> = rel.components().collect();
231 for (cov_path, cov) in map {
232 let cov_components: Vec<_> = cov_path.components().collect();
233 if cov_components.len() >= rel_components.len()
234 && cov_components[cov_components.len() - rel_components.len()..] == rel_components[..]
235 {
236 tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "suffix", "coverage matched");
237 return Some(cov);
238 }
239 }
240
241 let filename = rel.file_name()?;
243 for (cov_path, cov) in map {
244 if cov_path.file_name() == Some(filename) {
245 tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "filename", "coverage matched (ambiguous)");
246 return Some(cov);
247 }
248 }
249
250 tracing::debug!(file = relative_path, "no coverage entry found");
251 None
252}
253
254#[must_use]
257pub fn aggregate_line_coverage(records: &[&FileCoverage]) -> Option<f64> {
258 let total_found: u64 = records.iter().map(|c| u64::from(c.lines_found)).sum();
259 if total_found == 0 {
260 return None;
261 }
262 let total_hit: u64 = records.iter().map(|c| u64::from(c.lines_hit)).sum();
263 #[allow(clippy::cast_precision_loss)]
265 Some((total_hit as f64 / total_found as f64) * 100.0)
266}
267
268#[must_use]
274pub fn parse_coverage_auto(path: &Path, content: &str) -> HashMap<PathBuf, FileCoverage> {
275 let ext = path
276 .extension()
277 .and_then(|e| e.to_str())
278 .unwrap_or("")
279 .to_ascii_lowercase();
280 let result = match ext.as_str() {
281 "xml" => {
282 let snip = &content[..content.len().min(512)];
283 if snip.contains("<coverage") {
284 tracing::debug!(path = %path.display(), format = "cobertura", bytes = content.len(), "parsing coverage file");
285 parse_cobertura(content)
286 } else if snip.contains("<report") {
287 tracing::debug!(path = %path.display(), format = "jacoco", bytes = content.len(), "parsing coverage file");
288 parse_jacoco(content)
289 } else {
290 tracing::warn!(path = %path.display(), "coverage XML file has unrecognised root element; skipping");
291 HashMap::new()
292 }
293 }
294 "json" => {
295 if content.contains("\"files\"") && content.contains("\"meta\"") {
299 tracing::debug!(path = %path.display(), format = "coverage.py", bytes = content.len(), "parsing coverage file");
300 parse_coverage_py(content)
301 } else {
302 tracing::debug!(path = %path.display(), format = "istanbul", bytes = content.len(), "parsing coverage file");
303 parse_istanbul(content)
304 }
305 }
306 _ => {
307 tracing::debug!(path = %path.display(), format = "lcov", bytes = content.len(), "parsing coverage file");
308 parse_lcov(content)
309 }
310 };
311 tracing::debug!(path = %path.display(), file_count = result.len(), "coverage parse complete");
312 result
313}
314
315#[must_use]
320pub fn parse_cobertura(content: &str) -> HashMap<PathBuf, FileCoverage> {
321 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
322 let mut remaining = content;
323 while let Some(class_start) = remaining.find("<class ") {
324 remaining = &remaining[class_start + 7..];
325 let Some(filename) = extract_attr(remaining, "filename") else {
326 continue;
327 };
328 let class_end = remaining.find("</class>").unwrap_or(remaining.len());
329 let class_block = &remaining[..class_end];
330 let (lines_found, lines_hit, branch_found, branch_hit) = cobertura_scan_lines(class_block);
331 let (method_found, method_hit) = cobertura_scan_methods(class_block);
332 let entry = result
333 .entry(PathBuf::from(&filename))
334 .or_insert(FileCoverage {
335 lines_found: 0,
336 lines_hit: 0,
337 functions_found: 0,
338 functions_hit: 0,
339 branches_found: 0,
340 branches_hit: 0,
341 });
342 entry.lines_found += lines_found;
343 entry.lines_hit += lines_hit;
344 entry.functions_found += method_found;
345 entry.functions_hit += method_hit;
346 entry.branches_found += branch_found;
347 entry.branches_hit += branch_hit;
348 }
349 result
350}
351
352fn cobertura_scan_lines(class_block: &str) -> (u32, u32, u32, u32) {
354 let mut lines_found: u32 = 0;
355 let mut lines_hit: u32 = 0;
356 let mut branch_found: u32 = 0;
357 let mut branch_hit: u32 = 0;
358 let mut scan = class_block;
359 while let Some(pos) = scan.find("<line ") {
360 scan = &scan[pos + 6..];
361 lines_found += 1;
362 if extract_attr(scan, "hits").is_some_and(|h| h.trim() != "0") {
363 lines_hit += 1;
364 }
365 if extract_attr(scan, "branch").as_deref() == Some("true") {
366 let (hit, found) = parse_cobertura_branch_fraction(scan);
367 branch_hit += hit;
368 branch_found += found;
369 }
370 }
371 (lines_found, lines_hit, branch_found, branch_hit)
372}
373
374fn parse_cobertura_branch_fraction(scan: &str) -> (u32, u32) {
376 let Some(cond) = extract_attr(scan, "condition-coverage") else {
377 return (0, 0);
378 };
379 let Some(frac_start) = cond.find('(') else {
380 return (0, 0);
381 };
382 let frac_str = &cond[frac_start + 1..];
383 let Some(slash) = frac_str.find('/') else {
384 return (0, 0);
385 };
386 let num: u32 = frac_str[..slash].trim().parse().unwrap_or(0);
387 let den_end = frac_str[slash + 1..].find(')').unwrap_or(0);
388 let den: u32 = frac_str[slash + 1..slash + 1 + den_end]
389 .trim()
390 .parse()
391 .unwrap_or(0);
392 (num, den)
393}
394
395fn cobertura_scan_methods(class_block: &str) -> (u32, u32) {
397 let mut method_found: u32 = 0;
398 let mut method_hit: u32 = 0;
399 let mut mscan = class_block;
400 while let Some(pos) = mscan.find("<method ") {
401 mscan = &mscan[pos + 8..];
402 method_found += 1;
403 let rate: f64 = extract_attr(mscan, "line-rate")
404 .and_then(|lr| lr.parse().ok())
405 .unwrap_or(0.0);
406 if rate > 0.0 {
407 method_hit += 1;
408 }
409 }
410 (method_found, method_hit)
411}
412
413#[must_use]
418pub fn parse_jacoco(content: &str) -> HashMap<PathBuf, FileCoverage> {
419 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
420 let mut scan = content;
421 while let Some(pkg_start) = scan.find("<package ") {
422 scan = &scan[pkg_start + 9..];
423 let pkg_name = extract_attr(scan, "name").unwrap_or_default();
424 let pkg_end = scan.find("</package>").unwrap_or(scan.len());
425 parse_jacoco_package(&scan[..pkg_end], &pkg_name, &mut result);
426 if pkg_end < scan.len() {
427 scan = &scan[pkg_end..];
428 } else {
429 break;
430 }
431 }
432 result
433}
434
435fn parse_jacoco_package(
436 pkg_block: &str,
437 pkg_name: &str,
438 result: &mut HashMap<PathBuf, FileCoverage>,
439) {
440 let mut sf_scan = pkg_block;
441 while let Some(sf_start) = sf_scan.find("<sourcefile ") {
442 sf_scan = &sf_scan[sf_start + 12..];
443 let Some(sf_name) = extract_attr(sf_scan, "name") else {
444 continue;
445 };
446 let sf_end = sf_scan.find("</sourcefile>").unwrap_or(sf_scan.len());
447 let cov = parse_jacoco_counters(&sf_scan[..sf_end]);
448 let path = if pkg_name.is_empty() {
449 PathBuf::from(&sf_name)
450 } else {
451 PathBuf::from(format!("{pkg_name}/{sf_name}"))
452 };
453 result.insert(path, cov);
454 }
455}
456
457fn parse_jacoco_counters(sf_block: &str) -> FileCoverage {
458 let mut lines_found: u32 = 0;
459 let mut lines_hit: u32 = 0;
460 let mut fn_found: u32 = 0;
461 let mut fn_hit: u32 = 0;
462 let mut br_found: u32 = 0;
463 let mut br_hit: u32 = 0;
464 let mut cscan = sf_block;
465 while let Some(cpos) = cscan.find("<counter ") {
466 cscan = &cscan[cpos + 9..];
467 let ctype = extract_attr(cscan, "type").unwrap_or_default();
468 let missed: u32 = extract_attr(cscan, "missed")
469 .and_then(|v| v.parse().ok())
470 .unwrap_or(0);
471 let covered: u32 = extract_attr(cscan, "covered")
472 .and_then(|v| v.parse().ok())
473 .unwrap_or(0);
474 match ctype.as_str() {
475 "LINE" => {
476 lines_found = missed + covered;
477 lines_hit = covered;
478 }
479 "METHOD" => {
480 fn_found = missed + covered;
481 fn_hit = covered;
482 }
483 "BRANCH" => {
484 br_found = missed + covered;
485 br_hit = covered;
486 }
487 _ => {}
488 }
489 }
490 FileCoverage {
491 lines_found,
492 lines_hit,
493 functions_found: fn_found,
494 functions_hit: fn_hit,
495 branches_found: br_found,
496 branches_hit: br_hit,
497 }
498}
499
500#[must_use]
505pub fn parse_istanbul(content: &str) -> HashMap<PathBuf, FileCoverage> {
506 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
507
508 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
509 return result;
510 };
511 let Some(obj) = root.as_object() else {
512 return result;
513 };
514
515 for (path_str, file_val) in obj {
516 if path_str == "total" {
518 continue;
519 }
520 #[allow(clippy::cast_possible_truncation)]
522 let lines_total: u32 = file_val["lines"]["total"].as_u64().unwrap_or(0) as u32;
523 #[allow(clippy::cast_possible_truncation)]
524 let lines_covered: u32 = file_val["lines"]["covered"].as_u64().unwrap_or(0) as u32;
525 #[allow(clippy::cast_possible_truncation)]
526 let fn_total: u32 = file_val["functions"]["total"].as_u64().unwrap_or(0) as u32;
527 #[allow(clippy::cast_possible_truncation)]
528 let fn_covered: u32 = file_val["functions"]["covered"].as_u64().unwrap_or(0) as u32;
529 #[allow(clippy::cast_possible_truncation)]
530 let br_total: u32 = file_val["branches"]["total"].as_u64().unwrap_or(0) as u32;
531 #[allow(clippy::cast_possible_truncation)]
532 let br_covered: u32 = file_val["branches"]["covered"].as_u64().unwrap_or(0) as u32;
533
534 result.insert(
535 PathBuf::from(path_str.replace('\\', "/")),
536 FileCoverage {
537 lines_found: lines_total,
538 lines_hit: lines_covered,
539 functions_found: fn_total,
540 functions_hit: fn_covered,
541 branches_found: br_total,
542 branches_hit: br_covered,
543 },
544 );
545 }
546
547 result
548}
549
550#[must_use]
556pub fn parse_coverage_py(content: &str) -> HashMap<PathBuf, FileCoverage> {
557 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
558
559 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
560 return result;
561 };
562 let Some(files) = root.get("files").and_then(serde_json::Value::as_object) else {
563 return result;
564 };
565
566 for (path_str, file_val) in files {
567 let summary = &file_val["summary"];
568 #[allow(clippy::cast_possible_truncation)]
570 let lines_found: u32 = summary["num_statements"].as_u64().unwrap_or(0) as u32;
571 #[allow(clippy::cast_possible_truncation)]
572 let lines_hit: u32 = summary["covered_lines"].as_u64().unwrap_or(0) as u32;
573 #[allow(clippy::cast_possible_truncation)]
574 let br_found: u32 = summary["num_branches"].as_u64().unwrap_or(0) as u32;
575 #[allow(clippy::cast_possible_truncation)]
576 let br_hit: u32 = summary["covered_branches"].as_u64().unwrap_or(0) as u32;
577
578 result.insert(
579 PathBuf::from(path_str.replace('\\', "/")),
580 FileCoverage {
581 lines_found,
582 lines_hit,
583 functions_found: 0,
584 functions_hit: 0,
585 branches_found: br_found,
586 branches_hit: br_hit,
587 },
588 );
589 }
590
591 result
592}
593
594fn extract_attr(fragment: &str, attr: &str) -> Option<String> {
597 let needle = format!("{attr}=");
598 let pos = fragment.find(&needle)?;
599 let after = &fragment[pos + needle.len()..];
600 let quote = after.chars().next()?;
601 if quote == '"' || quote == '\'' {
602 let inner = &after[1..];
603 let end = inner.find(quote)?;
604 Some(inner[..end].to_string())
605 } else {
606 None
607 }
608}
609
610#[must_use]
613pub fn resolve_coverage_file(config_path: Option<&Path>) -> Option<PathBuf> {
614 if let Ok(env_path) = std::env::var("SLOC_COVERAGE_FILE") {
615 if !env_path.is_empty() {
616 return Some(PathBuf::from(env_path));
617 }
618 }
619 config_path.map(PathBuf::from)
620}