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            return Some(cov);
130        }
131    }
132
133    // Strategy 3: filename-only fallback
134    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/// Compute a weighted-average line coverage percentage across all files that have coverage data.
145/// Returns `None` if no files have coverage data or if total `lines_found` is zero.
146#[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    // ratio/percentage display, precision loss acceptable
154    #[allow(clippy::cast_precision_loss)]
155    Some((total_hit as f64 / total_found as f64) * 100.0)
156}
157
158/// Auto-detect coverage file format from path extension and content, then dispatch to the
159/// appropriate parser. Falls back to LCOV for unknown extensions.
160#[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/// Parse a Cobertura XML coverage file (`coverage.xml`) into a per-file coverage map.
184///
185/// Cobertura is produced by pytest-cov (`--cov-report xml`), Maven Cobertura plugin, and others.
186/// The `filename` attribute on `<class>` is already relative to the project root.
187#[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
220/// Count `<line>` hits and branch coverage within a Cobertura `<class>` block.
221fn 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
242/// Parse `condition-coverage="50% (1/2)"` → `(hit=1, found=2)`.
243fn 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
263/// Count `<method>` elements and how many have a non-zero line-rate in a Cobertura class block.
264fn 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/// Parse a `JaCoCo` XML report (`jacoco.xml`) into a per-file coverage map.
282///
283/// `JaCoCo` is produced by the Gradle `jacocoTestReport` task and the Maven `JaCoCo` plugin.
284/// Paths are reconstructed as `package/sourcefile` (e.g. `com/example/Main.java`).
285#[must_use]
286pub fn parse_jacoco(
287    // NOSONAR(rust:S3776)
288    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/// Parse an Istanbul/NYC `coverage-summary.json` file into a per-file coverage map.
372///
373/// Istanbul is produced by `nyc --reporter=json-summary` and by many Jest configurations.
374/// The top-level keys are absolute file paths.
375#[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        // Skip the top-level "total" key
388        if path_str == "total" {
389            continue;
390        }
391        // Line/function/branch counts are always small; truncation is not possible in practice.
392        #[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
421/// Extract the value of a named XML attribute from a fragment of XML text.
422/// Handles both `attr="value"` and `attr='value'` quoting.
423fn 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/// Resolve a coverage file path from the environment variable `SLOC_COVERAGE_FILE` or the
438/// provided config path, normalising to an absolute `PathBuf`.
439#[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}