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 return Some(cov);
130 }
131 }
132
133 let filename = rel.file_name()?;
135 for (cov_path, cov) in map {
136 if cov_path.file_name() == Some(filename) {
137 return Some(cov);
138 }
139 }
140
141 None
142}
143
144#[must_use]
147pub fn aggregate_line_coverage(records: &[&FileCoverage]) -> Option<f64> {
148 let total_found: u64 = records.iter().map(|c| u64::from(c.lines_found)).sum();
149 if total_found == 0 {
150 return None;
151 }
152 let total_hit: u64 = records.iter().map(|c| u64::from(c.lines_hit)).sum();
153 #[allow(clippy::cast_precision_loss)]
155 Some((total_hit as f64 / total_found as f64) * 100.0)
156}
157
158#[must_use]
161pub fn parse_coverage_auto(path: &Path, content: &str) -> HashMap<PathBuf, FileCoverage> {
162 let ext = path
163 .extension()
164 .and_then(|e| e.to_str())
165 .unwrap_or("")
166 .to_ascii_lowercase();
167 match ext.as_str() {
168 "xml" => {
169 let snip = &content[..content.len().min(512)];
170 if snip.contains("<coverage") {
171 parse_cobertura(content)
172 } else if snip.contains("<report") {
173 parse_jacoco(content)
174 } else {
175 HashMap::new()
176 }
177 }
178 "json" => parse_istanbul(content),
179 _ => parse_lcov(content),
180 }
181}
182
183#[must_use]
188pub fn parse_cobertura(content: &str) -> HashMap<PathBuf, FileCoverage> {
189 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
190 let mut remaining = content;
191 while let Some(class_start) = remaining.find("<class ") {
192 remaining = &remaining[class_start + 7..];
193 let Some(filename) = extract_attr(remaining, "filename") else {
194 continue;
195 };
196 let class_end = remaining.find("</class>").unwrap_or(remaining.len());
197 let class_block = &remaining[..class_end];
198 let (lines_found, lines_hit, branch_found, branch_hit) = cobertura_scan_lines(class_block);
199 let (method_found, method_hit) = cobertura_scan_methods(class_block);
200 let entry = result
201 .entry(PathBuf::from(&filename))
202 .or_insert(FileCoverage {
203 lines_found: 0,
204 lines_hit: 0,
205 functions_found: 0,
206 functions_hit: 0,
207 branches_found: 0,
208 branches_hit: 0,
209 });
210 entry.lines_found += lines_found;
211 entry.lines_hit += lines_hit;
212 entry.functions_found += method_found;
213 entry.functions_hit += method_hit;
214 entry.branches_found += branch_found;
215 entry.branches_hit += branch_hit;
216 }
217 result
218}
219
220fn cobertura_scan_lines(class_block: &str) -> (u32, u32, u32, u32) {
222 let mut lines_found: u32 = 0;
223 let mut lines_hit: u32 = 0;
224 let mut branch_found: u32 = 0;
225 let mut branch_hit: u32 = 0;
226 let mut scan = class_block;
227 while let Some(pos) = scan.find("<line ") {
228 scan = &scan[pos + 6..];
229 lines_found += 1;
230 if extract_attr(scan, "hits").is_some_and(|h| h.trim() != "0") {
231 lines_hit += 1;
232 }
233 if extract_attr(scan, "branch").as_deref() == Some("true") {
234 let (hit, found) = parse_cobertura_branch_fraction(scan);
235 branch_hit += hit;
236 branch_found += found;
237 }
238 }
239 (lines_found, lines_hit, branch_found, branch_hit)
240}
241
242fn parse_cobertura_branch_fraction(scan: &str) -> (u32, u32) {
244 let Some(cond) = extract_attr(scan, "condition-coverage") else {
245 return (0, 0);
246 };
247 let Some(frac_start) = cond.find('(') else {
248 return (0, 0);
249 };
250 let frac_str = &cond[frac_start + 1..];
251 let Some(slash) = frac_str.find('/') else {
252 return (0, 0);
253 };
254 let num: u32 = frac_str[..slash].trim().parse().unwrap_or(0);
255 let den_end = frac_str[slash + 1..].find(')').unwrap_or(0);
256 let den: u32 = frac_str[slash + 1..slash + 1 + den_end]
257 .trim()
258 .parse()
259 .unwrap_or(0);
260 (num, den)
261}
262
263fn cobertura_scan_methods(class_block: &str) -> (u32, u32) {
265 let mut method_found: u32 = 0;
266 let mut method_hit: u32 = 0;
267 let mut mscan = class_block;
268 while let Some(pos) = mscan.find("<method ") {
269 mscan = &mscan[pos + 8..];
270 method_found += 1;
271 let rate: f64 = extract_attr(mscan, "line-rate")
272 .and_then(|lr| lr.parse().ok())
273 .unwrap_or(0.0);
274 if rate > 0.0 {
275 method_hit += 1;
276 }
277 }
278 (method_found, method_hit)
279}
280
281#[must_use]
286pub fn parse_jacoco(content: &str) -> HashMap<PathBuf, FileCoverage> {
287 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
288 let mut scan = content;
289 while let Some(pkg_start) = scan.find("<package ") {
290 scan = &scan[pkg_start + 9..];
291 let pkg_name = extract_attr(scan, "name").unwrap_or_default();
292 let pkg_end = scan.find("</package>").unwrap_or(scan.len());
293 parse_jacoco_package(&scan[..pkg_end], &pkg_name, &mut result);
294 if pkg_end < scan.len() {
295 scan = &scan[pkg_end..];
296 } else {
297 break;
298 }
299 }
300 result
301}
302
303fn parse_jacoco_package(
304 pkg_block: &str,
305 pkg_name: &str,
306 result: &mut HashMap<PathBuf, FileCoverage>,
307) {
308 let mut sf_scan = pkg_block;
309 while let Some(sf_start) = sf_scan.find("<sourcefile ") {
310 sf_scan = &sf_scan[sf_start + 12..];
311 let Some(sf_name) = extract_attr(sf_scan, "name") else {
312 continue;
313 };
314 let sf_end = sf_scan.find("</sourcefile>").unwrap_or(sf_scan.len());
315 let cov = parse_jacoco_counters(&sf_scan[..sf_end]);
316 let path = if pkg_name.is_empty() {
317 PathBuf::from(&sf_name)
318 } else {
319 PathBuf::from(format!("{pkg_name}/{sf_name}"))
320 };
321 result.insert(path, cov);
322 }
323}
324
325fn parse_jacoco_counters(sf_block: &str) -> FileCoverage {
326 let mut lines_found: u32 = 0;
327 let mut lines_hit: u32 = 0;
328 let mut fn_found: u32 = 0;
329 let mut fn_hit: u32 = 0;
330 let mut br_found: u32 = 0;
331 let mut br_hit: u32 = 0;
332 let mut cscan = sf_block;
333 while let Some(cpos) = cscan.find("<counter ") {
334 cscan = &cscan[cpos + 9..];
335 let ctype = extract_attr(cscan, "type").unwrap_or_default();
336 let missed: u32 = extract_attr(cscan, "missed")
337 .and_then(|v| v.parse().ok())
338 .unwrap_or(0);
339 let covered: u32 = extract_attr(cscan, "covered")
340 .and_then(|v| v.parse().ok())
341 .unwrap_or(0);
342 match ctype.as_str() {
343 "LINE" => {
344 lines_found = missed + covered;
345 lines_hit = covered;
346 }
347 "METHOD" => {
348 fn_found = missed + covered;
349 fn_hit = covered;
350 }
351 "BRANCH" => {
352 br_found = missed + covered;
353 br_hit = covered;
354 }
355 _ => {}
356 }
357 }
358 FileCoverage {
359 lines_found,
360 lines_hit,
361 functions_found: fn_found,
362 functions_hit: fn_hit,
363 branches_found: br_found,
364 branches_hit: br_hit,
365 }
366}
367
368#[must_use]
373pub fn parse_istanbul(content: &str) -> HashMap<PathBuf, FileCoverage> {
374 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
375
376 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
377 return result;
378 };
379 let Some(obj) = root.as_object() else {
380 return result;
381 };
382
383 for (path_str, file_val) in obj {
384 if path_str == "total" {
386 continue;
387 }
388 #[allow(clippy::cast_possible_truncation)]
390 let lines_total: u32 = file_val["lines"]["total"].as_u64().unwrap_or(0) as u32;
391 #[allow(clippy::cast_possible_truncation)]
392 let lines_covered: u32 = file_val["lines"]["covered"].as_u64().unwrap_or(0) as u32;
393 #[allow(clippy::cast_possible_truncation)]
394 let fn_total: u32 = file_val["functions"]["total"].as_u64().unwrap_or(0) as u32;
395 #[allow(clippy::cast_possible_truncation)]
396 let fn_covered: u32 = file_val["functions"]["covered"].as_u64().unwrap_or(0) as u32;
397 #[allow(clippy::cast_possible_truncation)]
398 let br_total: u32 = file_val["branches"]["total"].as_u64().unwrap_or(0) as u32;
399 #[allow(clippy::cast_possible_truncation)]
400 let br_covered: u32 = file_val["branches"]["covered"].as_u64().unwrap_or(0) as u32;
401
402 result.insert(
403 PathBuf::from(path_str.replace('\\', "/")),
404 FileCoverage {
405 lines_found: lines_total,
406 lines_hit: lines_covered,
407 functions_found: fn_total,
408 functions_hit: fn_covered,
409 branches_found: br_total,
410 branches_hit: br_covered,
411 },
412 );
413 }
414
415 result
416}
417
418fn extract_attr(fragment: &str, attr: &str) -> Option<String> {
421 let needle = format!("{attr}=");
422 let pos = fragment.find(&needle)?;
423 let after = &fragment[pos + needle.len()..];
424 let quote = after.chars().next()?;
425 if quote == '"' || quote == '\'' {
426 let inner = &after[1..];
427 let end = inner.find(quote)?;
428 Some(inner[..end].to_string())
429 } else {
430 None
431 }
432}
433
434#[must_use]
437pub fn resolve_coverage_file(config_path: Option<&Path>) -> Option<PathBuf> {
438 if let Ok(env_path) = std::env::var("SLOC_COVERAGE_FILE") {
439 if !env_path.is_empty() {
440 return Some(PathBuf::from(env_path));
441 }
442 }
443 config_path.map(PathBuf::from)
444}