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 an LCOV `.info` file.
5#[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/// Parse an LCOV `.info` file and return a map from source file path to coverage metrics.
45///
46/// Paths in the map are normalised to forward-slash separators and stored as-is from the
47/// `SF:` record — callers are responsible for matching against `FileRecord.relative_path`.
48#[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/// Attempt to match a `FileRecord`'s `relative_path` against the coverage map.
103///
104/// LCOV paths are typically absolute (`/path/to/repo/src/foo.c`) while oxide-sloc stores
105/// relative paths (`src/foo.c`). This function tries three strategies in order:
106/// 1. Direct `PathBuf` key lookup (exact match or already-relative paths)
107/// 2. Suffix match: find a coverage path whose components end with the relative path
108/// 3. Filename-only fallback when the relative path is a bare filename
109#[must_use]
110#[allow(clippy::implicit_hasher)] // public API; callers always use the default hasher
111pub 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    // Strategy 1: exact key
118    if let Some(cov) = map.get(&rel) {
119        return Some(cov);
120    }
121
122    // Strategy 2: any coverage path whose tail matches the relative path
123    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    // Strategy 3: filename-only fallback
135    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/// Compute a weighted-average line coverage percentage across all files that have coverage data.
148/// Returns `None` if no files have coverage data or if total `lines_found` is zero.
149#[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    // ratio/percentage display, precision loss acceptable
157    #[allow(clippy::cast_precision_loss)]
158    Some((total_hit as f64 / total_found as f64) * 100.0)
159}
160
161/// Auto-detect coverage file format from path extension and content, then dispatch to the
162/// appropriate parser. Falls back to LCOV for unknown extensions.
163#[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/// Parse a Cobertura XML coverage file (`coverage.xml`) into a per-file coverage map.
198///
199/// Cobertura is produced by pytest-cov (`--cov-report xml`), Maven Cobertura plugin, and others.
200/// The `filename` attribute on `<class>` is already relative to the project root.
201#[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
234/// Count `<line>` hits and branch coverage within a Cobertura `<class>` block.
235fn 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
256/// Parse `condition-coverage="50% (1/2)"` → `(hit=1, found=2)`.
257fn 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
277/// Count `<method>` elements and how many have a non-zero line-rate in a Cobertura class block.
278fn 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/// Parse a `JaCoCo` XML report (`jacoco.xml`) into a per-file coverage map.
296///
297/// `JaCoCo` is produced by the Gradle `jacocoTestReport` task and the Maven `JaCoCo` plugin.
298/// Paths are reconstructed as `package/sourcefile` (e.g. `com/example/Main.java`).
299#[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/// Parse an Istanbul/NYC `coverage-summary.json` file into a per-file coverage map.
383///
384/// Istanbul is produced by `nyc --reporter=json-summary` and by many Jest configurations.
385/// The top-level keys are absolute file paths.
386#[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        // Skip the top-level "total" key
399        if path_str == "total" {
400            continue;
401        }
402        // Line/function/branch counts are always small; truncation is not possible in practice.
403        #[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
432/// Extract the value of a named XML attribute from a fragment of XML text.
433/// Handles both `attr="value"` and `attr='value'` quoting.
434fn 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/// Resolve a coverage file path from the environment variable `SLOC_COVERAGE_FILE` or the
449/// provided config path, normalising to an absolute `PathBuf`.
450#[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}