1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
6pub struct FileCoverage {
7 pub lines_found: u32,
8 pub lines_hit: u32,
9 pub functions_found: u32,
10 pub functions_hit: u32,
11 pub branches_found: u32,
12 pub branches_hit: u32,
13}
14
15impl FileCoverage {
16 #[must_use]
17 pub fn line_pct(&self) -> f64 {
18 if self.lines_found == 0 {
19 0.0
20 } else {
21 (f64::from(self.lines_hit) / f64::from(self.lines_found)) * 100.0
22 }
23 }
24
25 #[must_use]
26 pub fn function_pct(&self) -> f64 {
27 if self.functions_found == 0 {
28 0.0
29 } else {
30 (f64::from(self.functions_hit) / f64::from(self.functions_found)) * 100.0
31 }
32 }
33
34 #[must_use]
35 pub fn branch_pct(&self) -> f64 {
36 if self.branches_found == 0 {
37 0.0
38 } else {
39 (f64::from(self.branches_hit) / f64::from(self.branches_found)) * 100.0
40 }
41 }
42}
43
44#[must_use]
49pub fn parse_lcov(content: &str) -> HashMap<PathBuf, FileCoverage> {
50 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
51
52 let mut current_path: Option<PathBuf> = None;
53 let mut lf: u32 = 0;
54 let mut lh: u32 = 0;
55 let mut fnf: u32 = 0;
56 let mut fnh: u32 = 0;
57 let mut brf: u32 = 0;
58 let mut brh: u32 = 0;
59
60 for line in content.lines() {
61 let line = line.trim();
62 if let Some(path_str) = line.strip_prefix("SF:") {
63 current_path = Some(PathBuf::from(path_str.replace('\\', "/")));
64 lf = 0;
65 lh = 0;
66 fnf = 0;
67 fnh = 0;
68 brf = 0;
69 brh = 0;
70 } else if line == "end_of_record" {
71 if let Some(path) = current_path.take() {
72 result.insert(
73 path,
74 FileCoverage {
75 lines_found: lf,
76 lines_hit: lh,
77 functions_found: fnf,
78 functions_hit: fnh,
79 branches_found: brf,
80 branches_hit: brh,
81 },
82 );
83 }
84 } else if let Some(val) = line.strip_prefix("LF:") {
85 lf = val.parse().unwrap_or(0);
86 } else if let Some(val) = line.strip_prefix("LH:") {
87 lh = val.parse().unwrap_or(0);
88 } else if let Some(val) = line.strip_prefix("FNF:") {
89 fnf = val.parse().unwrap_or(0);
90 } else if let Some(val) = line.strip_prefix("FNH:") {
91 fnh = val.parse().unwrap_or(0);
92 } else if let Some(val) = line.strip_prefix("BRF:") {
93 brf = val.parse().unwrap_or(0);
94 } else if let Some(val) = line.strip_prefix("BRH:") {
95 brh = val.parse().unwrap_or(0);
96 }
97 }
98
99 result
100}
101
102#[must_use]
110#[allow(clippy::implicit_hasher)] pub fn lookup_coverage<'a>(
112 map: &'a HashMap<PathBuf, FileCoverage>,
113 relative_path: &str,
114) -> Option<&'a FileCoverage> {
115 let rel = PathBuf::from(relative_path.replace('\\', "/"));
116
117 if let Some(cov) = map.get(&rel) {
119 return Some(cov);
120 }
121
122 let rel_components: Vec<_> = rel.components().collect();
124 for (cov_path, cov) in map {
125 let cov_components: Vec<_> = cov_path.components().collect();
126 if cov_components.len() >= rel_components.len()
127 && cov_components[cov_components.len() - rel_components.len()..] == rel_components[..]
128 {
129 tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "suffix", "coverage matched");
130 return Some(cov);
131 }
132 }
133
134 let filename = rel.file_name()?;
136 for (cov_path, cov) in map {
137 if cov_path.file_name() == Some(filename) {
138 tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "filename", "coverage matched (ambiguous)");
139 return Some(cov);
140 }
141 }
142
143 tracing::debug!(file = relative_path, "no coverage entry found");
144 None
145}
146
147#[must_use]
150pub fn aggregate_line_coverage(records: &[&FileCoverage]) -> Option<f64> {
151 let total_found: u64 = records.iter().map(|c| u64::from(c.lines_found)).sum();
152 if total_found == 0 {
153 return None;
154 }
155 let total_hit: u64 = records.iter().map(|c| u64::from(c.lines_hit)).sum();
156 #[allow(clippy::cast_precision_loss)]
158 Some((total_hit as f64 / total_found as f64) * 100.0)
159}
160
161#[must_use]
164pub fn parse_coverage_auto(path: &Path, content: &str) -> HashMap<PathBuf, FileCoverage> {
165 let ext = path
166 .extension()
167 .and_then(|e| e.to_str())
168 .unwrap_or("")
169 .to_ascii_lowercase();
170 let result = match ext.as_str() {
171 "xml" => {
172 let snip = &content[..content.len().min(512)];
173 if snip.contains("<coverage") {
174 tracing::debug!(path = %path.display(), format = "cobertura", bytes = content.len(), "parsing coverage file");
175 parse_cobertura(content)
176 } else if snip.contains("<report") {
177 tracing::debug!(path = %path.display(), format = "jacoco", bytes = content.len(), "parsing coverage file");
178 parse_jacoco(content)
179 } else {
180 tracing::warn!(path = %path.display(), "coverage XML file has unrecognised root element; skipping");
181 HashMap::new()
182 }
183 }
184 "json" => {
185 tracing::debug!(path = %path.display(), format = "istanbul", bytes = content.len(), "parsing coverage file");
186 parse_istanbul(content)
187 }
188 _ => {
189 tracing::debug!(path = %path.display(), format = "lcov", bytes = content.len(), "parsing coverage file");
190 parse_lcov(content)
191 }
192 };
193 tracing::debug!(path = %path.display(), file_count = result.len(), "coverage parse complete");
194 result
195}
196
197#[must_use]
202pub fn parse_cobertura(content: &str) -> HashMap<PathBuf, FileCoverage> {
203 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
204 let mut remaining = content;
205 while let Some(class_start) = remaining.find("<class ") {
206 remaining = &remaining[class_start + 7..];
207 let Some(filename) = extract_attr(remaining, "filename") else {
208 continue;
209 };
210 let class_end = remaining.find("</class>").unwrap_or(remaining.len());
211 let class_block = &remaining[..class_end];
212 let (lines_found, lines_hit, branch_found, branch_hit) = cobertura_scan_lines(class_block);
213 let (method_found, method_hit) = cobertura_scan_methods(class_block);
214 let entry = result
215 .entry(PathBuf::from(&filename))
216 .or_insert(FileCoverage {
217 lines_found: 0,
218 lines_hit: 0,
219 functions_found: 0,
220 functions_hit: 0,
221 branches_found: 0,
222 branches_hit: 0,
223 });
224 entry.lines_found += lines_found;
225 entry.lines_hit += lines_hit;
226 entry.functions_found += method_found;
227 entry.functions_hit += method_hit;
228 entry.branches_found += branch_found;
229 entry.branches_hit += branch_hit;
230 }
231 result
232}
233
234fn cobertura_scan_lines(class_block: &str) -> (u32, u32, u32, u32) {
236 let mut lines_found: u32 = 0;
237 let mut lines_hit: u32 = 0;
238 let mut branch_found: u32 = 0;
239 let mut branch_hit: u32 = 0;
240 let mut scan = class_block;
241 while let Some(pos) = scan.find("<line ") {
242 scan = &scan[pos + 6..];
243 lines_found += 1;
244 if extract_attr(scan, "hits").is_some_and(|h| h.trim() != "0") {
245 lines_hit += 1;
246 }
247 if extract_attr(scan, "branch").as_deref() == Some("true") {
248 let (hit, found) = parse_cobertura_branch_fraction(scan);
249 branch_hit += hit;
250 branch_found += found;
251 }
252 }
253 (lines_found, lines_hit, branch_found, branch_hit)
254}
255
256fn parse_cobertura_branch_fraction(scan: &str) -> (u32, u32) {
258 let Some(cond) = extract_attr(scan, "condition-coverage") else {
259 return (0, 0);
260 };
261 let Some(frac_start) = cond.find('(') else {
262 return (0, 0);
263 };
264 let frac_str = &cond[frac_start + 1..];
265 let Some(slash) = frac_str.find('/') else {
266 return (0, 0);
267 };
268 let num: u32 = frac_str[..slash].trim().parse().unwrap_or(0);
269 let den_end = frac_str[slash + 1..].find(')').unwrap_or(0);
270 let den: u32 = frac_str[slash + 1..slash + 1 + den_end]
271 .trim()
272 .parse()
273 .unwrap_or(0);
274 (num, den)
275}
276
277fn cobertura_scan_methods(class_block: &str) -> (u32, u32) {
279 let mut method_found: u32 = 0;
280 let mut method_hit: u32 = 0;
281 let mut mscan = class_block;
282 while let Some(pos) = mscan.find("<method ") {
283 mscan = &mscan[pos + 8..];
284 method_found += 1;
285 let rate: f64 = extract_attr(mscan, "line-rate")
286 .and_then(|lr| lr.parse().ok())
287 .unwrap_or(0.0);
288 if rate > 0.0 {
289 method_hit += 1;
290 }
291 }
292 (method_found, method_hit)
293}
294
295#[must_use]
300pub fn parse_jacoco(content: &str) -> HashMap<PathBuf, FileCoverage> {
301 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
302 let mut scan = content;
303 while let Some(pkg_start) = scan.find("<package ") {
304 scan = &scan[pkg_start + 9..];
305 let pkg_name = extract_attr(scan, "name").unwrap_or_default();
306 let pkg_end = scan.find("</package>").unwrap_or(scan.len());
307 parse_jacoco_package(&scan[..pkg_end], &pkg_name, &mut result);
308 if pkg_end < scan.len() {
309 scan = &scan[pkg_end..];
310 } else {
311 break;
312 }
313 }
314 result
315}
316
317fn parse_jacoco_package(
318 pkg_block: &str,
319 pkg_name: &str,
320 result: &mut HashMap<PathBuf, FileCoverage>,
321) {
322 let mut sf_scan = pkg_block;
323 while let Some(sf_start) = sf_scan.find("<sourcefile ") {
324 sf_scan = &sf_scan[sf_start + 12..];
325 let Some(sf_name) = extract_attr(sf_scan, "name") else {
326 continue;
327 };
328 let sf_end = sf_scan.find("</sourcefile>").unwrap_or(sf_scan.len());
329 let cov = parse_jacoco_counters(&sf_scan[..sf_end]);
330 let path = if pkg_name.is_empty() {
331 PathBuf::from(&sf_name)
332 } else {
333 PathBuf::from(format!("{pkg_name}/{sf_name}"))
334 };
335 result.insert(path, cov);
336 }
337}
338
339fn parse_jacoco_counters(sf_block: &str) -> FileCoverage {
340 let mut lines_found: u32 = 0;
341 let mut lines_hit: u32 = 0;
342 let mut fn_found: u32 = 0;
343 let mut fn_hit: u32 = 0;
344 let mut br_found: u32 = 0;
345 let mut br_hit: u32 = 0;
346 let mut cscan = sf_block;
347 while let Some(cpos) = cscan.find("<counter ") {
348 cscan = &cscan[cpos + 9..];
349 let ctype = extract_attr(cscan, "type").unwrap_or_default();
350 let missed: u32 = extract_attr(cscan, "missed")
351 .and_then(|v| v.parse().ok())
352 .unwrap_or(0);
353 let covered: u32 = extract_attr(cscan, "covered")
354 .and_then(|v| v.parse().ok())
355 .unwrap_or(0);
356 match ctype.as_str() {
357 "LINE" => {
358 lines_found = missed + covered;
359 lines_hit = covered;
360 }
361 "METHOD" => {
362 fn_found = missed + covered;
363 fn_hit = covered;
364 }
365 "BRANCH" => {
366 br_found = missed + covered;
367 br_hit = covered;
368 }
369 _ => {}
370 }
371 }
372 FileCoverage {
373 lines_found,
374 lines_hit,
375 functions_found: fn_found,
376 functions_hit: fn_hit,
377 branches_found: br_found,
378 branches_hit: br_hit,
379 }
380}
381
382#[must_use]
387pub fn parse_istanbul(content: &str) -> HashMap<PathBuf, FileCoverage> {
388 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
389
390 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
391 return result;
392 };
393 let Some(obj) = root.as_object() else {
394 return result;
395 };
396
397 for (path_str, file_val) in obj {
398 if path_str == "total" {
400 continue;
401 }
402 #[allow(clippy::cast_possible_truncation)]
404 let lines_total: u32 = file_val["lines"]["total"].as_u64().unwrap_or(0) as u32;
405 #[allow(clippy::cast_possible_truncation)]
406 let lines_covered: u32 = file_val["lines"]["covered"].as_u64().unwrap_or(0) as u32;
407 #[allow(clippy::cast_possible_truncation)]
408 let fn_total: u32 = file_val["functions"]["total"].as_u64().unwrap_or(0) as u32;
409 #[allow(clippy::cast_possible_truncation)]
410 let fn_covered: u32 = file_val["functions"]["covered"].as_u64().unwrap_or(0) as u32;
411 #[allow(clippy::cast_possible_truncation)]
412 let br_total: u32 = file_val["branches"]["total"].as_u64().unwrap_or(0) as u32;
413 #[allow(clippy::cast_possible_truncation)]
414 let br_covered: u32 = file_val["branches"]["covered"].as_u64().unwrap_or(0) as u32;
415
416 result.insert(
417 PathBuf::from(path_str.replace('\\', "/")),
418 FileCoverage {
419 lines_found: lines_total,
420 lines_hit: lines_covered,
421 functions_found: fn_total,
422 functions_hit: fn_covered,
423 branches_found: br_total,
424 branches_hit: br_covered,
425 },
426 );
427 }
428
429 result
430}
431
432fn extract_attr(fragment: &str, attr: &str) -> Option<String> {
435 let needle = format!("{attr}=");
436 let pos = fragment.find(&needle)?;
437 let after = &fragment[pos + needle.len()..];
438 let quote = after.chars().next()?;
439 if quote == '"' || quote == '\'' {
440 let inner = &after[1..];
441 let end = inner.find(quote)?;
442 Some(inner[..end].to_string())
443 } else {
444 None
445 }
446}
447
448#[must_use]
451pub fn resolve_coverage_file(config_path: Option<&Path>) -> Option<PathBuf> {
452 if let Ok(env_path) = std::env::var("SLOC_COVERAGE_FILE") {
453 if !env_path.is_empty() {
454 return Some(PathBuf::from(env_path));
455 }
456 }
457 config_path.map(PathBuf::from)
458}