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#[must_use]
53pub fn parse_lcov(content: &str) -> HashMap<PathBuf, FileCoverage> {
54 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
55
56 let mut current_path: Option<PathBuf> = None;
57 let mut lf: u32 = 0;
58 let mut lh: u32 = 0;
59 let mut fnf: u32 = 0;
60 let mut fnh: u32 = 0;
61 let mut brf: u32 = 0;
62 let mut brh: u32 = 0;
63
64 for line in content.lines() {
65 let line = line.trim();
66 if let Some(path_str) = line.strip_prefix("SF:") {
67 current_path = Some(PathBuf::from(path_str.replace('\\', "/")));
68 lf = 0;
69 lh = 0;
70 fnf = 0;
71 fnh = 0;
72 brf = 0;
73 brh = 0;
74 } else if line == "end_of_record" {
75 if let Some(path) = current_path.take() {
76 result.insert(
77 path,
78 FileCoverage {
79 lines_found: lf,
80 lines_hit: lh,
81 functions_found: fnf,
82 functions_hit: fnh,
83 branches_found: brf,
84 branches_hit: brh,
85 },
86 );
87 }
88 } else if let Some(val) = line.strip_prefix("LF:") {
89 lf = val.parse().unwrap_or(0);
90 } else if let Some(val) = line.strip_prefix("LH:") {
91 lh = val.parse().unwrap_or(0);
92 } else if let Some(val) = line.strip_prefix("FNF:") {
93 fnf = val.parse().unwrap_or(0);
94 } else if let Some(val) = line.strip_prefix("FNH:") {
95 fnh = val.parse().unwrap_or(0);
96 } else if let Some(val) = line.strip_prefix("BRF:") {
97 brf = val.parse().unwrap_or(0);
98 } else if let Some(val) = line.strip_prefix("BRH:") {
99 brh = val.parse().unwrap_or(0);
100 }
101 }
102
103 result
104}
105
106#[must_use]
114#[allow(clippy::implicit_hasher)] pub fn lookup_coverage<'a>(
116 map: &'a HashMap<PathBuf, FileCoverage>,
117 relative_path: &str,
118) -> Option<&'a FileCoverage> {
119 let rel = PathBuf::from(relative_path.replace('\\', "/"));
120
121 if let Some(cov) = map.get(&rel) {
123 return Some(cov);
124 }
125
126 let rel_components: Vec<_> = rel.components().collect();
128 for (cov_path, cov) in map {
129 let cov_components: Vec<_> = cov_path.components().collect();
130 if cov_components.len() >= rel_components.len()
131 && cov_components[cov_components.len() - rel_components.len()..] == rel_components[..]
132 {
133 tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "suffix", "coverage matched");
134 return Some(cov);
135 }
136 }
137
138 let filename = rel.file_name()?;
140 for (cov_path, cov) in map {
141 if cov_path.file_name() == Some(filename) {
142 tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "filename", "coverage matched (ambiguous)");
143 return Some(cov);
144 }
145 }
146
147 tracing::debug!(file = relative_path, "no coverage entry found");
148 None
149}
150
151#[must_use]
154pub fn aggregate_line_coverage(records: &[&FileCoverage]) -> Option<f64> {
155 let total_found: u64 = records.iter().map(|c| u64::from(c.lines_found)).sum();
156 if total_found == 0 {
157 return None;
158 }
159 let total_hit: u64 = records.iter().map(|c| u64::from(c.lines_hit)).sum();
160 #[allow(clippy::cast_precision_loss)]
162 Some((total_hit as f64 / total_found as f64) * 100.0)
163}
164
165#[must_use]
171pub fn parse_coverage_auto(path: &Path, content: &str) -> HashMap<PathBuf, FileCoverage> {
172 let ext = path
173 .extension()
174 .and_then(|e| e.to_str())
175 .unwrap_or("")
176 .to_ascii_lowercase();
177 let result = match ext.as_str() {
178 "xml" => {
179 let snip = &content[..content.len().min(512)];
180 if snip.contains("<coverage") {
181 tracing::debug!(path = %path.display(), format = "cobertura", bytes = content.len(), "parsing coverage file");
182 parse_cobertura(content)
183 } else if snip.contains("<report") {
184 tracing::debug!(path = %path.display(), format = "jacoco", bytes = content.len(), "parsing coverage file");
185 parse_jacoco(content)
186 } else {
187 tracing::warn!(path = %path.display(), "coverage XML file has unrecognised root element; skipping");
188 HashMap::new()
189 }
190 }
191 "json" => {
192 if content.contains("\"files\"") && content.contains("\"meta\"") {
196 tracing::debug!(path = %path.display(), format = "coverage.py", bytes = content.len(), "parsing coverage file");
197 parse_coverage_py(content)
198 } else {
199 tracing::debug!(path = %path.display(), format = "istanbul", bytes = content.len(), "parsing coverage file");
200 parse_istanbul(content)
201 }
202 }
203 _ => {
204 tracing::debug!(path = %path.display(), format = "lcov", bytes = content.len(), "parsing coverage file");
205 parse_lcov(content)
206 }
207 };
208 tracing::debug!(path = %path.display(), file_count = result.len(), "coverage parse complete");
209 result
210}
211
212#[must_use]
217pub fn parse_cobertura(content: &str) -> HashMap<PathBuf, FileCoverage> {
218 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
219 let mut remaining = content;
220 while let Some(class_start) = remaining.find("<class ") {
221 remaining = &remaining[class_start + 7..];
222 let Some(filename) = extract_attr(remaining, "filename") else {
223 continue;
224 };
225 let class_end = remaining.find("</class>").unwrap_or(remaining.len());
226 let class_block = &remaining[..class_end];
227 let (lines_found, lines_hit, branch_found, branch_hit) = cobertura_scan_lines(class_block);
228 let (method_found, method_hit) = cobertura_scan_methods(class_block);
229 let entry = result
230 .entry(PathBuf::from(&filename))
231 .or_insert(FileCoverage {
232 lines_found: 0,
233 lines_hit: 0,
234 functions_found: 0,
235 functions_hit: 0,
236 branches_found: 0,
237 branches_hit: 0,
238 });
239 entry.lines_found += lines_found;
240 entry.lines_hit += lines_hit;
241 entry.functions_found += method_found;
242 entry.functions_hit += method_hit;
243 entry.branches_found += branch_found;
244 entry.branches_hit += branch_hit;
245 }
246 result
247}
248
249fn cobertura_scan_lines(class_block: &str) -> (u32, u32, u32, u32) {
251 let mut lines_found: u32 = 0;
252 let mut lines_hit: u32 = 0;
253 let mut branch_found: u32 = 0;
254 let mut branch_hit: u32 = 0;
255 let mut scan = class_block;
256 while let Some(pos) = scan.find("<line ") {
257 scan = &scan[pos + 6..];
258 lines_found += 1;
259 if extract_attr(scan, "hits").is_some_and(|h| h.trim() != "0") {
260 lines_hit += 1;
261 }
262 if extract_attr(scan, "branch").as_deref() == Some("true") {
263 let (hit, found) = parse_cobertura_branch_fraction(scan);
264 branch_hit += hit;
265 branch_found += found;
266 }
267 }
268 (lines_found, lines_hit, branch_found, branch_hit)
269}
270
271fn parse_cobertura_branch_fraction(scan: &str) -> (u32, u32) {
273 let Some(cond) = extract_attr(scan, "condition-coverage") else {
274 return (0, 0);
275 };
276 let Some(frac_start) = cond.find('(') else {
277 return (0, 0);
278 };
279 let frac_str = &cond[frac_start + 1..];
280 let Some(slash) = frac_str.find('/') else {
281 return (0, 0);
282 };
283 let num: u32 = frac_str[..slash].trim().parse().unwrap_or(0);
284 let den_end = frac_str[slash + 1..].find(')').unwrap_or(0);
285 let den: u32 = frac_str[slash + 1..slash + 1 + den_end]
286 .trim()
287 .parse()
288 .unwrap_or(0);
289 (num, den)
290}
291
292fn cobertura_scan_methods(class_block: &str) -> (u32, u32) {
294 let mut method_found: u32 = 0;
295 let mut method_hit: u32 = 0;
296 let mut mscan = class_block;
297 while let Some(pos) = mscan.find("<method ") {
298 mscan = &mscan[pos + 8..];
299 method_found += 1;
300 let rate: f64 = extract_attr(mscan, "line-rate")
301 .and_then(|lr| lr.parse().ok())
302 .unwrap_or(0.0);
303 if rate > 0.0 {
304 method_hit += 1;
305 }
306 }
307 (method_found, method_hit)
308}
309
310#[must_use]
315pub fn parse_jacoco(content: &str) -> HashMap<PathBuf, FileCoverage> {
316 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
317 let mut scan = content;
318 while let Some(pkg_start) = scan.find("<package ") {
319 scan = &scan[pkg_start + 9..];
320 let pkg_name = extract_attr(scan, "name").unwrap_or_default();
321 let pkg_end = scan.find("</package>").unwrap_or(scan.len());
322 parse_jacoco_package(&scan[..pkg_end], &pkg_name, &mut result);
323 if pkg_end < scan.len() {
324 scan = &scan[pkg_end..];
325 } else {
326 break;
327 }
328 }
329 result
330}
331
332fn parse_jacoco_package(
333 pkg_block: &str,
334 pkg_name: &str,
335 result: &mut HashMap<PathBuf, FileCoverage>,
336) {
337 let mut sf_scan = pkg_block;
338 while let Some(sf_start) = sf_scan.find("<sourcefile ") {
339 sf_scan = &sf_scan[sf_start + 12..];
340 let Some(sf_name) = extract_attr(sf_scan, "name") else {
341 continue;
342 };
343 let sf_end = sf_scan.find("</sourcefile>").unwrap_or(sf_scan.len());
344 let cov = parse_jacoco_counters(&sf_scan[..sf_end]);
345 let path = if pkg_name.is_empty() {
346 PathBuf::from(&sf_name)
347 } else {
348 PathBuf::from(format!("{pkg_name}/{sf_name}"))
349 };
350 result.insert(path, cov);
351 }
352}
353
354fn parse_jacoco_counters(sf_block: &str) -> FileCoverage {
355 let mut lines_found: u32 = 0;
356 let mut lines_hit: u32 = 0;
357 let mut fn_found: u32 = 0;
358 let mut fn_hit: u32 = 0;
359 let mut br_found: u32 = 0;
360 let mut br_hit: u32 = 0;
361 let mut cscan = sf_block;
362 while let Some(cpos) = cscan.find("<counter ") {
363 cscan = &cscan[cpos + 9..];
364 let ctype = extract_attr(cscan, "type").unwrap_or_default();
365 let missed: u32 = extract_attr(cscan, "missed")
366 .and_then(|v| v.parse().ok())
367 .unwrap_or(0);
368 let covered: u32 = extract_attr(cscan, "covered")
369 .and_then(|v| v.parse().ok())
370 .unwrap_or(0);
371 match ctype.as_str() {
372 "LINE" => {
373 lines_found = missed + covered;
374 lines_hit = covered;
375 }
376 "METHOD" => {
377 fn_found = missed + covered;
378 fn_hit = covered;
379 }
380 "BRANCH" => {
381 br_found = missed + covered;
382 br_hit = covered;
383 }
384 _ => {}
385 }
386 }
387 FileCoverage {
388 lines_found,
389 lines_hit,
390 functions_found: fn_found,
391 functions_hit: fn_hit,
392 branches_found: br_found,
393 branches_hit: br_hit,
394 }
395}
396
397#[must_use]
402pub fn parse_istanbul(content: &str) -> HashMap<PathBuf, FileCoverage> {
403 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
404
405 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
406 return result;
407 };
408 let Some(obj) = root.as_object() else {
409 return result;
410 };
411
412 for (path_str, file_val) in obj {
413 if path_str == "total" {
415 continue;
416 }
417 #[allow(clippy::cast_possible_truncation)]
419 let lines_total: u32 = file_val["lines"]["total"].as_u64().unwrap_or(0) as u32;
420 #[allow(clippy::cast_possible_truncation)]
421 let lines_covered: u32 = file_val["lines"]["covered"].as_u64().unwrap_or(0) as u32;
422 #[allow(clippy::cast_possible_truncation)]
423 let fn_total: u32 = file_val["functions"]["total"].as_u64().unwrap_or(0) as u32;
424 #[allow(clippy::cast_possible_truncation)]
425 let fn_covered: u32 = file_val["functions"]["covered"].as_u64().unwrap_or(0) as u32;
426 #[allow(clippy::cast_possible_truncation)]
427 let br_total: u32 = file_val["branches"]["total"].as_u64().unwrap_or(0) as u32;
428 #[allow(clippy::cast_possible_truncation)]
429 let br_covered: u32 = file_val["branches"]["covered"].as_u64().unwrap_or(0) as u32;
430
431 result.insert(
432 PathBuf::from(path_str.replace('\\', "/")),
433 FileCoverage {
434 lines_found: lines_total,
435 lines_hit: lines_covered,
436 functions_found: fn_total,
437 functions_hit: fn_covered,
438 branches_found: br_total,
439 branches_hit: br_covered,
440 },
441 );
442 }
443
444 result
445}
446
447#[must_use]
453pub fn parse_coverage_py(content: &str) -> HashMap<PathBuf, FileCoverage> {
454 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
455
456 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
457 return result;
458 };
459 let Some(files) = root.get("files").and_then(serde_json::Value::as_object) else {
460 return result;
461 };
462
463 for (path_str, file_val) in files {
464 let summary = &file_val["summary"];
465 #[allow(clippy::cast_possible_truncation)]
467 let lines_found: u32 = summary["num_statements"].as_u64().unwrap_or(0) as u32;
468 #[allow(clippy::cast_possible_truncation)]
469 let lines_hit: u32 = summary["covered_lines"].as_u64().unwrap_or(0) as u32;
470 #[allow(clippy::cast_possible_truncation)]
471 let br_found: u32 = summary["num_branches"].as_u64().unwrap_or(0) as u32;
472 #[allow(clippy::cast_possible_truncation)]
473 let br_hit: u32 = summary["covered_branches"].as_u64().unwrap_or(0) as u32;
474
475 result.insert(
476 PathBuf::from(path_str.replace('\\', "/")),
477 FileCoverage {
478 lines_found,
479 lines_hit,
480 functions_found: 0,
481 functions_hit: 0,
482 branches_found: br_found,
483 branches_hit: br_hit,
484 },
485 );
486 }
487
488 result
489}
490
491fn extract_attr(fragment: &str, attr: &str) -> Option<String> {
494 let needle = format!("{attr}=");
495 let pos = fragment.find(&needle)?;
496 let after = &fragment[pos + needle.len()..];
497 let quote = after.chars().next()?;
498 if quote == '"' || quote == '\'' {
499 let inner = &after[1..];
500 let end = inner.find(quote)?;
501 Some(inner[..end].to_string())
502 } else {
503 None
504 }
505}
506
507#[must_use]
510pub fn resolve_coverage_file(config_path: Option<&Path>) -> Option<PathBuf> {
511 if let Ok(env_path) = std::env::var("SLOC_COVERAGE_FILE") {
512 if !env_path.is_empty() {
513 return Some(PathBuf::from(env_path));
514 }
515 }
516 config_path.map(PathBuf::from)
517}