Skip to main content

sloc_core/
coverage.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4/// Per-file coverage metrics parsed from a coverage report.
5///
6/// Supported source formats (see `parse_coverage_auto`): LCOV `.info` (lcov, gcov,
7/// cargo-llvm-cov), Cobertura XML, `JaCoCo` XML, Istanbul/NYC `json-summary`, and coverage.py
8/// native JSON (`coverage json`).
9#[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/// Parse an LCOV `.info` file and return a map from source file path to coverage metrics.
49///
50/// Paths in the map are normalised to forward-slash separators and stored as-is from the
51/// `SF:` record — callers are responsible for matching against `FileRecord.relative_path`.
52#[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/// Attempt to match a `FileRecord`'s `relative_path` against the coverage map.
107///
108/// LCOV paths are typically absolute (`/path/to/repo/src/foo.c`) while oxide-sloc stores
109/// relative paths (`src/foo.c`). This function tries three strategies in order:
110/// 1. Direct `PathBuf` key lookup (exact match or already-relative paths)
111/// 2. Suffix match: find a coverage path whose components end with the relative path
112/// 3. Filename-only fallback when the relative path is a bare filename
113#[must_use]
114#[allow(clippy::implicit_hasher)] // public API; callers always use the default hasher
115pub 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    // Strategy 1: exact key
122    if let Some(cov) = map.get(&rel) {
123        return Some(cov);
124    }
125
126    // Strategy 2: any coverage path whose tail matches the relative path
127    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    // Strategy 3: filename-only fallback
139    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/// Compute a weighted-average line coverage percentage across all files that have coverage data.
152/// Returns `None` if no files have coverage data or if total `lines_found` is zero.
153#[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    // ratio/percentage display, precision loss acceptable
161    #[allow(clippy::cast_precision_loss)]
162    Some((total_hit as f64 / total_found as f64) * 100.0)
163}
164
165/// Auto-detect coverage file format from path extension and content, then dispatch to the
166/// appropriate parser.
167///
168/// `.xml` is sniffed for Cobertura vs `JaCoCo`; `.json` is sniffed for coverage.py native JSON
169/// vs Istanbul `json-summary`. Falls back to LCOV for unknown extensions.
170#[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            // coverage.py's native `coverage json` output nests files under a top-level `files`
193            // object alongside a `meta` block; Istanbul's `json-summary` keys files at the top
194            // level. Sniff for the coverage.py signature before falling back to Istanbul.
195            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/// Parse a Cobertura XML coverage file (`coverage.xml`) into a per-file coverage map.
213///
214/// Cobertura is produced by pytest-cov (`--cov-report xml`), Maven Cobertura plugin, and others.
215/// The `filename` attribute on `<class>` is already relative to the project root.
216#[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
249/// Count `<line>` hits and branch coverage within a Cobertura `<class>` block.
250fn 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
271/// Parse `condition-coverage="50% (1/2)"` → `(hit=1, found=2)`.
272fn 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
292/// Count `<method>` elements and how many have a non-zero line-rate in a Cobertura class block.
293fn 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/// Parse a `JaCoCo` XML report (`jacoco.xml`) into a per-file coverage map.
311///
312/// `JaCoCo` is produced by the Gradle `jacocoTestReport` task and the Maven `JaCoCo` plugin.
313/// Paths are reconstructed as `package/sourcefile` (e.g. `com/example/Main.java`).
314#[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/// Parse an Istanbul/NYC `coverage-summary.json` file into a per-file coverage map.
398///
399/// Istanbul is produced by `nyc --reporter=json-summary` and by many Jest configurations.
400/// The top-level keys are absolute file paths.
401#[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        // Skip the top-level "total" key
414        if path_str == "total" {
415            continue;
416        }
417        // Line/function/branch counts are always small; truncation is not possible in practice.
418        #[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/// Parse a coverage.py native JSON report (`coverage json`) into a per-file coverage map.
448///
449/// coverage.py's own JSON schema differs from Istanbul's `json-summary`: it nests per-file data
450/// under a top-level `files` object, with line/branch counts in each file's `summary` block.
451/// coverage.py does not track function-level coverage, so `functions_*` are always zero.
452#[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        // Line/branch counts are always small; truncation is not possible in practice.
466        #[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
491/// Extract the value of a named XML attribute from a fragment of XML text.
492/// Handles both `attr="value"` and `attr='value'` quoting.
493fn 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/// Resolve a coverage file path from the environment variable `SLOC_COVERAGE_FILE` or the
508/// provided config path, normalising to an absolute `PathBuf`.
509#[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}