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(
287 content: &str,
289) -> HashMap<PathBuf, FileCoverage> {
290 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
291
292 let mut scan = content;
293 while let Some(pkg_start) = scan.find("<package ") {
294 scan = &scan[pkg_start + 9..];
295 let pkg_name = extract_attr(scan, "name").unwrap_or_default();
296 let pkg_end = scan.find("</package>").unwrap_or(scan.len());
297 let pkg_block = &scan[..pkg_end];
298
299 let mut sf_scan = pkg_block;
300 while let Some(sf_start) = sf_scan.find("<sourcefile ") {
301 sf_scan = &sf_scan[sf_start + 12..];
302 let Some(sf_name) = extract_attr(sf_scan, "name") else {
303 continue;
304 };
305 let sf_end = sf_scan.find("</sourcefile>").unwrap_or(sf_scan.len());
306 let sf_block = &sf_scan[..sf_end];
307
308 let mut lines_found: u32 = 0;
309 let mut lines_hit: u32 = 0;
310 let mut fn_found: u32 = 0;
311 let mut fn_hit: u32 = 0;
312 let mut br_found: u32 = 0;
313 let mut br_hit: u32 = 0;
314
315 let mut cscan = sf_block;
316 while let Some(cpos) = cscan.find("<counter ") {
317 cscan = &cscan[cpos + 9..];
318 let ctype = extract_attr(cscan, "type").unwrap_or_default();
319 let missed: u32 = extract_attr(cscan, "missed")
320 .and_then(|v| v.parse().ok())
321 .unwrap_or(0);
322 let covered: u32 = extract_attr(cscan, "covered")
323 .and_then(|v| v.parse().ok())
324 .unwrap_or(0);
325 match ctype.as_str() {
326 "LINE" => {
327 lines_found = missed + covered;
328 lines_hit = covered;
329 }
330 "METHOD" => {
331 fn_found = missed + covered;
332 fn_hit = covered;
333 }
334 "BRANCH" => {
335 br_found = missed + covered;
336 br_hit = covered;
337 }
338 _ => {}
339 }
340 }
341
342 let path = if pkg_name.is_empty() {
343 PathBuf::from(&sf_name)
344 } else {
345 PathBuf::from(format!("{pkg_name}/{sf_name}"))
346 };
347
348 result.insert(
349 path,
350 FileCoverage {
351 lines_found,
352 lines_hit,
353 functions_found: fn_found,
354 functions_hit: fn_hit,
355 branches_found: br_found,
356 branches_hit: br_hit,
357 },
358 );
359 }
360
361 if pkg_end < scan.len() {
362 scan = &scan[pkg_end..];
363 } else {
364 break;
365 }
366 }
367
368 result
369}
370
371#[must_use]
376pub fn parse_istanbul(content: &str) -> HashMap<PathBuf, FileCoverage> {
377 let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
378
379 let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
380 return result;
381 };
382 let Some(obj) = root.as_object() else {
383 return result;
384 };
385
386 for (path_str, file_val) in obj {
387 if path_str == "total" {
389 continue;
390 }
391 #[allow(clippy::cast_possible_truncation)]
393 let lines_total: u32 = file_val["lines"]["total"].as_u64().unwrap_or(0) as u32;
394 #[allow(clippy::cast_possible_truncation)]
395 let lines_covered: u32 = file_val["lines"]["covered"].as_u64().unwrap_or(0) as u32;
396 #[allow(clippy::cast_possible_truncation)]
397 let fn_total: u32 = file_val["functions"]["total"].as_u64().unwrap_or(0) as u32;
398 #[allow(clippy::cast_possible_truncation)]
399 let fn_covered: u32 = file_val["functions"]["covered"].as_u64().unwrap_or(0) as u32;
400 #[allow(clippy::cast_possible_truncation)]
401 let br_total: u32 = file_val["branches"]["total"].as_u64().unwrap_or(0) as u32;
402 #[allow(clippy::cast_possible_truncation)]
403 let br_covered: u32 = file_val["branches"]["covered"].as_u64().unwrap_or(0) as u32;
404
405 result.insert(
406 PathBuf::from(path_str.replace('\\', "/")),
407 FileCoverage {
408 lines_found: lines_total,
409 lines_hit: lines_covered,
410 functions_found: fn_total,
411 functions_hit: fn_covered,
412 branches_found: br_total,
413 branches_hit: br_covered,
414 },
415 );
416 }
417
418 result
419}
420
421fn extract_attr(fragment: &str, attr: &str) -> Option<String> {
424 let needle = format!("{attr}=");
425 let pos = fragment.find(&needle)?;
426 let after = &fragment[pos + needle.len()..];
427 let quote = after.chars().next()?;
428 if quote == '"' || quote == '\'' {
429 let inner = &after[1..];
430 let end = inner.find(quote)?;
431 Some(inner[..end].to_string())
432 } else {
433 None
434 }
435}
436
437#[must_use]
440pub fn resolve_coverage_file(config_path: Option<&Path>) -> Option<PathBuf> {
441 if let Ok(env_path) = std::env::var("SLOC_COVERAGE_FILE") {
442 if !env_path.is_empty() {
443 return Some(PathBuf::from(env_path));
444 }
445 }
446 config_path.map(PathBuf::from)
447}