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/// Per-record accumulator for a single `SF:` block while parsing LCOV.
49///
50/// LCOV files may carry the `LF/LH/FNF/FNH/BRF/BRH` summary records, the raw per-line
51/// `DA:`/`FNDA:`/`BRDA:` records, or both. Some producers (notably `llvm-cov export -format=lcov`
52/// and certain `geninfo` configurations) emit only the raw records and omit the summaries. We
53/// track both and prefer the explicit summary when present, falling back to counts derived from
54/// the raw records so coverage is never silently reported as zero.
55// The six `saw_*` flags track presence of each explicit summary record
56// independently of its value (0 is a valid count), so they are genuinely
57// distinct fields rather than a state that a bitfield/enum would model better.
58#[allow(clippy::struct_excessive_bools)]
59#[derive(Default)]
60struct LcovRecord {
61    // Explicit summary records (and whether each was seen).
62    lf: u32,
63    lh: u32,
64    fnf: u32,
65    fnh: u32,
66    brf: u32,
67    brh: u32,
68    saw_lf: bool,
69    saw_lh: bool,
70    saw_fnf: bool,
71    saw_fnh: bool,
72    saw_brf: bool,
73    saw_brh: bool,
74    // Counts derived from raw DA:/FN:/FNDA:/BRDA: records.
75    da_found: u32,
76    da_hit: u32,
77    fn_found: u32,
78    fnda_hit: u32,
79    brda_found: u32,
80    brda_hit: u32,
81}
82
83impl LcovRecord {
84    /// Resolve the final `FileCoverage`, preferring explicit summaries over derived counts.
85    const fn finalize(&self) -> FileCoverage {
86        FileCoverage {
87            lines_found: if self.saw_lf { self.lf } else { self.da_found },
88            lines_hit: if self.saw_lh { self.lh } else { self.da_hit },
89            functions_found: if self.saw_fnf {
90                self.fnf
91            } else {
92                self.fn_found
93            },
94            functions_hit: if self.saw_fnh {
95                self.fnh
96            } else {
97                self.fnda_hit
98            },
99            branches_found: if self.saw_brf {
100                self.brf
101            } else {
102                self.brda_found
103            },
104            branches_hit: if self.saw_brh {
105                self.brh
106            } else {
107                self.brda_hit
108            },
109        }
110    }
111
112    /// Apply one LCOV record line (a summary `LF/LH/…` or a raw `DA/FN/FNDA/BRDA`) to this
113    /// accumulator. `SF:` and `end_of_record` are structural and handled by the caller, so they
114    /// are not matched here.
115    fn apply(&mut self, line: &str) {
116        if let Some(val) = line.strip_prefix("LF:") {
117            self.lf = val.parse().unwrap_or(0);
118            self.saw_lf = true;
119        } else if let Some(val) = line.strip_prefix("LH:") {
120            self.lh = val.parse().unwrap_or(0);
121            self.saw_lh = true;
122        } else if let Some(val) = line.strip_prefix("FNF:") {
123            self.fnf = val.parse().unwrap_or(0);
124            self.saw_fnf = true;
125        } else if let Some(val) = line.strip_prefix("FNH:") {
126            self.fnh = val.parse().unwrap_or(0);
127            self.saw_fnh = true;
128        } else if let Some(val) = line.strip_prefix("BRF:") {
129            self.brf = val.parse().unwrap_or(0);
130            self.saw_brf = true;
131        } else if let Some(val) = line.strip_prefix("BRH:") {
132            self.brh = val.parse().unwrap_or(0);
133            self.saw_brh = true;
134        } else if let Some(val) = line.strip_prefix("DA:") {
135            self.record_da(val);
136        } else if line.starts_with("FN:") {
137            // FN:<line>,<name> — one function definition.
138            self.fn_found += 1;
139        } else if let Some(val) = line.strip_prefix("FNDA:") {
140            self.record_fnda(val);
141        } else if let Some(val) = line.strip_prefix("BRDA:") {
142            self.record_brda(val);
143        }
144    }
145
146    /// `DA:<line>,<hits>[,checksum]` — one instrumented line and whether it was executed.
147    fn record_da(&mut self, val: &str) {
148        self.da_found += 1;
149        if val.split(',').nth(1).is_some_and(lcov_count_nonzero) {
150            self.da_hit += 1;
151        }
152    }
153
154    /// `FNDA:<hits>,<name>` — execution count for a function.
155    fn record_fnda(&mut self, val: &str) {
156        if val.split(',').next().is_some_and(lcov_count_nonzero) {
157            self.fnda_hit += 1;
158        }
159    }
160
161    /// `BRDA:<line>,<block>,<branch>,<taken>` — `taken` is `-` when the branch was not reached.
162    fn record_brda(&mut self, val: &str) {
163        self.brda_found += 1;
164        if val.split(',').nth(3).is_some_and(lcov_count_nonzero) {
165            self.brda_hit += 1;
166        }
167    }
168}
169
170/// True when an LCOV `DA:`/`FNDA:` hit count field is non-zero (execution count > 0).
171fn lcov_count_nonzero(field: &str) -> bool {
172    !matches!(field.trim(), "" | "0" | "-")
173}
174
175/// Parse an LCOV `.info` file and return a map from source file path to coverage metrics.
176///
177/// Paths in the map are normalised to forward-slash separators and stored as-is from the
178/// `SF:` record — callers are responsible for matching against `FileRecord.relative_path`.
179///
180/// Both the summary records (`LF/LH/FNF/FNH/BRF/BRH`) and the raw per-line records
181/// (`DA:`/`FN:`/`FNDA:`/`BRDA:`) are understood. When a summary record is absent, the
182/// corresponding value is derived from the raw records so files that ship only raw data still
183/// report accurate coverage.
184#[must_use]
185pub fn parse_lcov(content: &str) -> HashMap<PathBuf, FileCoverage> {
186    let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
187
188    let mut current_path: Option<PathBuf> = None;
189    let mut rec = LcovRecord::default();
190
191    for line in content.lines() {
192        let line = line.trim();
193        if let Some(path_str) = line.strip_prefix("SF:") {
194            current_path = Some(PathBuf::from(path_str.replace('\\', "/")));
195            rec = LcovRecord::default();
196        } else if line == "end_of_record" {
197            if let Some(path) = current_path.take() {
198                result.insert(path, rec.finalize());
199            }
200            rec = LcovRecord::default();
201        } else {
202            rec.apply(line);
203        }
204    }
205
206    result
207}
208
209/// Attempt to match a `FileRecord`'s `relative_path` against the coverage map.
210///
211/// LCOV paths are typically absolute (`/path/to/repo/src/foo.c`) while oxide-sloc stores
212/// relative paths (`src/foo.c`). This function tries three strategies in order:
213/// 1. Direct `PathBuf` key lookup (exact match or already-relative paths)
214/// 2. Suffix match: find a coverage path whose components end with the relative path
215/// 3. Filename-only fallback when the relative path is a bare filename
216#[must_use]
217#[allow(clippy::implicit_hasher)] // public API; callers always use the default hasher
218pub fn lookup_coverage<'a>(
219    map: &'a HashMap<PathBuf, FileCoverage>,
220    relative_path: &str,
221) -> Option<&'a FileCoverage> {
222    let rel = PathBuf::from(relative_path.replace('\\', "/"));
223
224    // Strategy 1: exact key
225    if let Some(cov) = map.get(&rel) {
226        return Some(cov);
227    }
228
229    // Strategy 2: any coverage path whose tail matches the relative path
230    let rel_components: Vec<_> = rel.components().collect();
231    for (cov_path, cov) in map {
232        let cov_components: Vec<_> = cov_path.components().collect();
233        if cov_components.len() >= rel_components.len()
234            && cov_components[cov_components.len() - rel_components.len()..] == rel_components[..]
235        {
236            tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "suffix", "coverage matched");
237            return Some(cov);
238        }
239    }
240
241    // Strategy 3: filename-only fallback
242    let filename = rel.file_name()?;
243    for (cov_path, cov) in map {
244        if cov_path.file_name() == Some(filename) {
245            tracing::debug!(file = relative_path, matched_as = %cov_path.display(), strategy = "filename", "coverage matched (ambiguous)");
246            return Some(cov);
247        }
248    }
249
250    tracing::debug!(file = relative_path, "no coverage entry found");
251    None
252}
253
254/// Compute a weighted-average line coverage percentage across all files that have coverage data.
255/// Returns `None` if no files have coverage data or if total `lines_found` is zero.
256#[must_use]
257pub fn aggregate_line_coverage(records: &[&FileCoverage]) -> Option<f64> {
258    let total_found: u64 = records.iter().map(|c| u64::from(c.lines_found)).sum();
259    if total_found == 0 {
260        return None;
261    }
262    let total_hit: u64 = records.iter().map(|c| u64::from(c.lines_hit)).sum();
263    // ratio/percentage display, precision loss acceptable
264    #[allow(clippy::cast_precision_loss)]
265    Some((total_hit as f64 / total_found as f64) * 100.0)
266}
267
268/// Auto-detect coverage file format from path extension and content, then dispatch to the
269/// appropriate parser.
270///
271/// `.xml` is sniffed for Cobertura vs `JaCoCo`; `.json` is sniffed for coverage.py native JSON
272/// vs Istanbul `json-summary`. Falls back to LCOV for unknown extensions.
273#[must_use]
274pub fn parse_coverage_auto(path: &Path, content: &str) -> HashMap<PathBuf, FileCoverage> {
275    let ext = path
276        .extension()
277        .and_then(|e| e.to_str())
278        .unwrap_or("")
279        .to_ascii_lowercase();
280    let result = match ext.as_str() {
281        "xml" => {
282            let snip = &content[..content.len().min(512)];
283            if snip.contains("<coverage") {
284                tracing::debug!(path = %path.display(), format = "cobertura", bytes = content.len(), "parsing coverage file");
285                parse_cobertura(content)
286            } else if snip.contains("<report") {
287                tracing::debug!(path = %path.display(), format = "jacoco", bytes = content.len(), "parsing coverage file");
288                parse_jacoco(content)
289            } else {
290                tracing::warn!(path = %path.display(), "coverage XML file has unrecognised root element; skipping");
291                HashMap::new()
292            }
293        }
294        "json" => {
295            // coverage.py's native `coverage json` output nests files under a top-level `files`
296            // object alongside a `meta` block; Istanbul's `json-summary` keys files at the top
297            // level. Sniff for the coverage.py signature before falling back to Istanbul.
298            if content.contains("\"files\"") && content.contains("\"meta\"") {
299                tracing::debug!(path = %path.display(), format = "coverage.py", bytes = content.len(), "parsing coverage file");
300                parse_coverage_py(content)
301            } else {
302                tracing::debug!(path = %path.display(), format = "istanbul", bytes = content.len(), "parsing coverage file");
303                parse_istanbul(content)
304            }
305        }
306        _ => {
307            tracing::debug!(path = %path.display(), format = "lcov", bytes = content.len(), "parsing coverage file");
308            parse_lcov(content)
309        }
310    };
311    tracing::debug!(path = %path.display(), file_count = result.len(), "coverage parse complete");
312    result
313}
314
315/// Parse a Cobertura XML coverage file (`coverage.xml`) into a per-file coverage map.
316///
317/// Cobertura is produced by pytest-cov (`--cov-report xml`), Maven Cobertura plugin, and others.
318/// The `filename` attribute on `<class>` is already relative to the project root.
319#[must_use]
320pub fn parse_cobertura(content: &str) -> HashMap<PathBuf, FileCoverage> {
321    let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
322    let mut remaining = content;
323    while let Some(class_start) = remaining.find("<class ") {
324        remaining = &remaining[class_start + 7..];
325        let Some(filename) = extract_attr(remaining, "filename") else {
326            continue;
327        };
328        let class_end = remaining.find("</class>").unwrap_or(remaining.len());
329        let class_block = &remaining[..class_end];
330        let (lines_found, lines_hit, branch_found, branch_hit) = cobertura_scan_lines(class_block);
331        let (method_found, method_hit) = cobertura_scan_methods(class_block);
332        let entry = result
333            .entry(PathBuf::from(&filename))
334            .or_insert(FileCoverage {
335                lines_found: 0,
336                lines_hit: 0,
337                functions_found: 0,
338                functions_hit: 0,
339                branches_found: 0,
340                branches_hit: 0,
341            });
342        entry.lines_found += lines_found;
343        entry.lines_hit += lines_hit;
344        entry.functions_found += method_found;
345        entry.functions_hit += method_hit;
346        entry.branches_found += branch_found;
347        entry.branches_hit += branch_hit;
348    }
349    result
350}
351
352/// Count `<line>` hits and branch coverage within a Cobertura `<class>` block.
353fn cobertura_scan_lines(class_block: &str) -> (u32, u32, u32, u32) {
354    let mut lines_found: u32 = 0;
355    let mut lines_hit: u32 = 0;
356    let mut branch_found: u32 = 0;
357    let mut branch_hit: u32 = 0;
358    let mut scan = class_block;
359    while let Some(pos) = scan.find("<line ") {
360        scan = &scan[pos + 6..];
361        lines_found += 1;
362        if extract_attr(scan, "hits").is_some_and(|h| h.trim() != "0") {
363            lines_hit += 1;
364        }
365        if extract_attr(scan, "branch").as_deref() == Some("true") {
366            let (hit, found) = parse_cobertura_branch_fraction(scan);
367            branch_hit += hit;
368            branch_found += found;
369        }
370    }
371    (lines_found, lines_hit, branch_found, branch_hit)
372}
373
374/// Parse `condition-coverage="50% (1/2)"` → `(hit=1, found=2)`.
375fn parse_cobertura_branch_fraction(scan: &str) -> (u32, u32) {
376    let Some(cond) = extract_attr(scan, "condition-coverage") else {
377        return (0, 0);
378    };
379    let Some(frac_start) = cond.find('(') else {
380        return (0, 0);
381    };
382    let frac_str = &cond[frac_start + 1..];
383    let Some(slash) = frac_str.find('/') else {
384        return (0, 0);
385    };
386    let num: u32 = frac_str[..slash].trim().parse().unwrap_or(0);
387    let den_end = frac_str[slash + 1..].find(')').unwrap_or(0);
388    let den: u32 = frac_str[slash + 1..slash + 1 + den_end]
389        .trim()
390        .parse()
391        .unwrap_or(0);
392    (num, den)
393}
394
395/// Count `<method>` elements and how many have a non-zero line-rate in a Cobertura class block.
396fn cobertura_scan_methods(class_block: &str) -> (u32, u32) {
397    let mut method_found: u32 = 0;
398    let mut method_hit: u32 = 0;
399    let mut mscan = class_block;
400    while let Some(pos) = mscan.find("<method ") {
401        mscan = &mscan[pos + 8..];
402        method_found += 1;
403        let rate: f64 = extract_attr(mscan, "line-rate")
404            .and_then(|lr| lr.parse().ok())
405            .unwrap_or(0.0);
406        if rate > 0.0 {
407            method_hit += 1;
408        }
409    }
410    (method_found, method_hit)
411}
412
413/// Parse a `JaCoCo` XML report (`jacoco.xml`) into a per-file coverage map.
414///
415/// `JaCoCo` is produced by the Gradle `jacocoTestReport` task and the Maven `JaCoCo` plugin.
416/// Paths are reconstructed as `package/sourcefile` (e.g. `com/example/Main.java`).
417#[must_use]
418pub fn parse_jacoco(content: &str) -> HashMap<PathBuf, FileCoverage> {
419    let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
420    let mut scan = content;
421    while let Some(pkg_start) = scan.find("<package ") {
422        scan = &scan[pkg_start + 9..];
423        let pkg_name = extract_attr(scan, "name").unwrap_or_default();
424        let pkg_end = scan.find("</package>").unwrap_or(scan.len());
425        parse_jacoco_package(&scan[..pkg_end], &pkg_name, &mut result);
426        if pkg_end < scan.len() {
427            scan = &scan[pkg_end..];
428        } else {
429            break;
430        }
431    }
432    result
433}
434
435fn parse_jacoco_package(
436    pkg_block: &str,
437    pkg_name: &str,
438    result: &mut HashMap<PathBuf, FileCoverage>,
439) {
440    let mut sf_scan = pkg_block;
441    while let Some(sf_start) = sf_scan.find("<sourcefile ") {
442        sf_scan = &sf_scan[sf_start + 12..];
443        let Some(sf_name) = extract_attr(sf_scan, "name") else {
444            continue;
445        };
446        let sf_end = sf_scan.find("</sourcefile>").unwrap_or(sf_scan.len());
447        let cov = parse_jacoco_counters(&sf_scan[..sf_end]);
448        let path = if pkg_name.is_empty() {
449            PathBuf::from(&sf_name)
450        } else {
451            PathBuf::from(format!("{pkg_name}/{sf_name}"))
452        };
453        result.insert(path, cov);
454    }
455}
456
457fn parse_jacoco_counters(sf_block: &str) -> FileCoverage {
458    let mut lines_found: u32 = 0;
459    let mut lines_hit: u32 = 0;
460    let mut fn_found: u32 = 0;
461    let mut fn_hit: u32 = 0;
462    let mut br_found: u32 = 0;
463    let mut br_hit: u32 = 0;
464    let mut cscan = sf_block;
465    while let Some(cpos) = cscan.find("<counter ") {
466        cscan = &cscan[cpos + 9..];
467        let ctype = extract_attr(cscan, "type").unwrap_or_default();
468        let missed: u32 = extract_attr(cscan, "missed")
469            .and_then(|v| v.parse().ok())
470            .unwrap_or(0);
471        let covered: u32 = extract_attr(cscan, "covered")
472            .and_then(|v| v.parse().ok())
473            .unwrap_or(0);
474        match ctype.as_str() {
475            "LINE" => {
476                lines_found = missed + covered;
477                lines_hit = covered;
478            }
479            "METHOD" => {
480                fn_found = missed + covered;
481                fn_hit = covered;
482            }
483            "BRANCH" => {
484                br_found = missed + covered;
485                br_hit = covered;
486            }
487            _ => {}
488        }
489    }
490    FileCoverage {
491        lines_found,
492        lines_hit,
493        functions_found: fn_found,
494        functions_hit: fn_hit,
495        branches_found: br_found,
496        branches_hit: br_hit,
497    }
498}
499
500/// Parse an Istanbul/NYC `coverage-summary.json` file into a per-file coverage map.
501///
502/// Istanbul is produced by `nyc --reporter=json-summary` and by many Jest configurations.
503/// The top-level keys are absolute file paths.
504#[must_use]
505pub fn parse_istanbul(content: &str) -> HashMap<PathBuf, FileCoverage> {
506    let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
507
508    let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
509        return result;
510    };
511    let Some(obj) = root.as_object() else {
512        return result;
513    };
514
515    for (path_str, file_val) in obj {
516        // Skip the top-level "total" key
517        if path_str == "total" {
518            continue;
519        }
520        // Line/function/branch counts are always small; truncation is not possible in practice.
521        #[allow(clippy::cast_possible_truncation)]
522        let lines_total: u32 = file_val["lines"]["total"].as_u64().unwrap_or(0) as u32;
523        #[allow(clippy::cast_possible_truncation)]
524        let lines_covered: u32 = file_val["lines"]["covered"].as_u64().unwrap_or(0) as u32;
525        #[allow(clippy::cast_possible_truncation)]
526        let fn_total: u32 = file_val["functions"]["total"].as_u64().unwrap_or(0) as u32;
527        #[allow(clippy::cast_possible_truncation)]
528        let fn_covered: u32 = file_val["functions"]["covered"].as_u64().unwrap_or(0) as u32;
529        #[allow(clippy::cast_possible_truncation)]
530        let br_total: u32 = file_val["branches"]["total"].as_u64().unwrap_or(0) as u32;
531        #[allow(clippy::cast_possible_truncation)]
532        let br_covered: u32 = file_val["branches"]["covered"].as_u64().unwrap_or(0) as u32;
533
534        result.insert(
535            PathBuf::from(path_str.replace('\\', "/")),
536            FileCoverage {
537                lines_found: lines_total,
538                lines_hit: lines_covered,
539                functions_found: fn_total,
540                functions_hit: fn_covered,
541                branches_found: br_total,
542                branches_hit: br_covered,
543            },
544        );
545    }
546
547    result
548}
549
550/// Parse a coverage.py native JSON report (`coverage json`) into a per-file coverage map.
551///
552/// coverage.py's own JSON schema differs from Istanbul's `json-summary`: it nests per-file data
553/// under a top-level `files` object, with line/branch counts in each file's `summary` block.
554/// coverage.py does not track function-level coverage, so `functions_*` are always zero.
555#[must_use]
556pub fn parse_coverage_py(content: &str) -> HashMap<PathBuf, FileCoverage> {
557    let mut result: HashMap<PathBuf, FileCoverage> = HashMap::new();
558
559    let Ok(root) = serde_json::from_str::<serde_json::Value>(content) else {
560        return result;
561    };
562    let Some(files) = root.get("files").and_then(serde_json::Value::as_object) else {
563        return result;
564    };
565
566    for (path_str, file_val) in files {
567        let summary = &file_val["summary"];
568        // Line/branch counts are always small; truncation is not possible in practice.
569        #[allow(clippy::cast_possible_truncation)]
570        let lines_found: u32 = summary["num_statements"].as_u64().unwrap_or(0) as u32;
571        #[allow(clippy::cast_possible_truncation)]
572        let lines_hit: u32 = summary["covered_lines"].as_u64().unwrap_or(0) as u32;
573        #[allow(clippy::cast_possible_truncation)]
574        let br_found: u32 = summary["num_branches"].as_u64().unwrap_or(0) as u32;
575        #[allow(clippy::cast_possible_truncation)]
576        let br_hit: u32 = summary["covered_branches"].as_u64().unwrap_or(0) as u32;
577
578        result.insert(
579            PathBuf::from(path_str.replace('\\', "/")),
580            FileCoverage {
581                lines_found,
582                lines_hit,
583                functions_found: 0,
584                functions_hit: 0,
585                branches_found: br_found,
586                branches_hit: br_hit,
587            },
588        );
589    }
590
591    result
592}
593
594/// Extract the value of a named XML attribute from a fragment of XML text.
595/// Handles both `attr="value"` and `attr='value'` quoting.
596fn extract_attr(fragment: &str, attr: &str) -> Option<String> {
597    let needle = format!("{attr}=");
598    let pos = fragment.find(&needle)?;
599    let after = &fragment[pos + needle.len()..];
600    let quote = after.chars().next()?;
601    if quote == '"' || quote == '\'' {
602        let inner = &after[1..];
603        let end = inner.find(quote)?;
604        Some(inner[..end].to_string())
605    } else {
606        None
607    }
608}
609
610/// Resolve a coverage file path from the environment variable `SLOC_COVERAGE_FILE` or the
611/// provided config path, normalising to an absolute `PathBuf`.
612#[must_use]
613pub fn resolve_coverage_file(config_path: Option<&Path>) -> Option<PathBuf> {
614    if let Ok(env_path) = std::env::var("SLOC_COVERAGE_FILE") {
615        if !env_path.is_empty() {
616            return Some(PathBuf::from(env_path));
617        }
618    }
619    config_path.map(PathBuf::from)
620}