candor-query 0.15.0

candor's read-only report queries (show/where/callers/map/diff/containment/…) in Rust — used by cargo-candor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! Report/sidecar loading: the prefix globs, the report entries, the callgraph
//! and hierarchy sidecars, and the backend/provenance probes.

use crate::*;

// ── report loading ──────────────────────────────────────────────────────────────────────────────

/// Report file PATHS for a prefix — the `<base>.<crate>.<type>.json` reports, sorted, excluding the
/// `.calibrated`/`.encountered-*` sidecars. Thin wrapper over `candor_report::report_files`, which
/// owns the ONE discrimination rule shared with the lint (so the two can't disagree).
pub(crate) fn glob_reports(prefix: &str) -> Vec<PathBuf> {
    report_files(prefix).into_iter().map(|r| r.path).collect()
}

/// All report entries across the matching files. A report file that is present but won't parse is
/// DISCLOSED on stderr (not silently skipped): dropping it silently would make its effectful functions
/// read as "no effect" — a silent under-report against the never-silently-pure promise. Still tolerant
/// (one corrupt file doesn't kill the merged query), but the blind spot is now visible. With atomic
/// report writes (candor_report::write_atomic) this only fires on genuine corruption, not a mid-write race.
pub(crate) fn load_entries(prefix: &str) -> Vec<ReportEntry> {
    load_entries_inner(prefix).0
}

/// The shared loader. Returns the merged entries AND whether a matched file wholly FAILED to read or
/// parse (`hard_fail`) — the loud wrapper needs that bit to tell "an empty-but-valid report" apart from
/// "every report we found was corrupt", which must never read as an empty (all-clear) answer.
/// pub(crate): diff.rs's `load_fninfo_loud` threads the same bit through its fn-info map.
pub(crate) fn load_entries_inner(prefix: &str) -> (Vec<ReportEntry>, bool) {
    let mut out = Vec::new();
    let mut hard_fail = false;
    for path in glob_reports(prefix) {
        let Ok(text) = std::fs::read_to_string(&path) else {
            eprintln!("candor: report {} could not be read — its functions are OMITTED from this query", path.display());
            hard_fail = true;
            continue;
        };
        match report_entries_counted(&text) {
            Some((es, dropped)) => {
                if dropped > 0 {
                    // A per-entry drop is the same under-report as a whole-file failure — disclose it,
                    // never let a corrupt function silently vanish (and read as pure) from the query.
                    eprintln!(
                        "candor: report {}{dropped} function entr{} could not be parsed and {} OMITTED from this query (corrupt or mid-write); re-run the scan",
                        path.display(),
                        if dropped == 1 { "y" } else { "ies" },
                        if dropped == 1 { "is" } else { "are" },
                    );
                    // A file that parsed but whose entries were ALL junk (es empty, dropped > 0) yielded no
                    // trustworthy functions — the same corruption as a whole-file parse failure. Mark it so
                    // the loud wrapper won't let a net-empty result read as an all-clear (e.g. a bare `[1,2,3]`
                    // array-shaped report). A CLEAN-empty report (dropped == 0) stays a non-fatal empty.
                    if es.is_empty() {
                        hard_fail = true;
                    }
                }
                out.extend(es);
            }
            None => {
                eprintln!(
                    "candor: report {} failed to parse — its functions are OMITTED from this query (corrupt or mid-write); re-run the scan",
                    path.display()
                );
                hard_fail = true;
            }
        }
    }
    (out, hard_fail)
}

/// `load_entries`, but a prefix matching NO report files fails LOUD (exit 2) instead of reading as an
/// empty report. A typo'd prefix otherwise yields an authoritative-looking empty answer — `map`/`where`
/// printed `{}` and exited 0, which an agent (or a gate built on the output) reads as "no effects"
/// rather than "wrong path" (found by dogfooding the umbrella AGENTS.md route; `diff`/`rewire` were
/// already guarded). A legitimately effect-free crate still writes a report file, so "no files" is
/// always an error, never an empty answer.
pub(crate) fn load_entries_loud(prefix: &str) -> Result<Vec<ReportEntry>, i32> {
    if glob_reports(prefix).is_empty() {
        eprintln!("candor: no report files at prefix `{prefix}` — check the path.");
        return Err(2);
    }
    let (entries, hard_fail) = load_entries_inner(prefix);
    // A report file was FOUND but yielded no usable entries because it failed to read/parse — that is a
    // corrupt report, NOT an effect-free crate. Returning `Ok(empty)` here would let a caller read the
    // emptiness as "no effects": `tour` prints "nothing hidden", a policy `map`/gate PASSES — the §4
    // cardinal-sin false all-clear. A legitimately effect-free crate still writes a report that LISTS its
    // functions, so empty-after-a-parse-failure is always the corrupt case. Fail loud (the disclosure
    // above already named the file). One corrupt file among several still merges — that stays tolerant
    // (entries non-empty → Ok), only a net-empty parse failure is fatal.
    if entries.is_empty() && hard_fail {
        eprintln!("candor: every report found at prefix `{prefix}` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan.");
        return Err(2);
    }
    Ok(entries)
}

// ── audit ───────────────────────────────────────────────────────────────────────────────────────

/// The basename of a prefix (`.candor/report` → `report`), used to label per-crate report files.
pub(crate) fn prefix_base(prefix: &str) -> String {
    Path::new(prefix).file_name().and_then(|s| s.to_str()).unwrap_or("").to_string()
}

/// Encountered-crate sidecars: `<base>.encountered-*.json` (not matched by the `.*.*.json` report
/// glob — only two dot-segments). Each holds a JSON array of crate names candor *saw called*.
pub(crate) fn glob_encountered(prefix: &str) -> Vec<PathBuf> {
    // Strip a `.json` direct-file locator (§3.3.1) so the encountered-crate sidecars glob matches
    // `<stem>.encountered-*.json` — otherwise audit/receipt κ-coverage silently drops them.
    let prefix = prefix.strip_suffix(".json").unwrap_or(prefix);
    let dir = match Path::new(prefix).parent() {
        Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
        _ => PathBuf::from("."),
    };
    let base = prefix_base(prefix);
    let needle = format!("{base}.encountered-");
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for ent in rd.flatten() {
            if let Some(n) = ent.file_name().to_str()
                && let Some(mid) = n.strip_prefix(&needle).and_then(|m| m.strip_suffix(".json")) {
                    // an encountered sidecar is `<base>.encountered-<crate>-<kind>.json` — a SINGLE
                    // dotless segment. Excluding any further dot avoids mis-claiming the REPORT of a
                    // crate literally named `encountered-…` (`<base>.encountered-foo.lib.json`).
                    if !mid.contains('.') {
                        out.push(ent.path());
                    }
                }
        }
    }
    out
}

/// The external crates candor saw resolved calls into — the union of the `<prefix>.encountered-*.json`
/// sidecars. Shared by `audit` (coverage gaps) and `receipt`.
pub(crate) fn encountered_set(prefix: &str) -> BTreeSet<String> {
    let mut seen: BTreeSet<String> = BTreeSet::new();
    for path in glob_encountered(prefix) {
        if let Ok(text) = std::fs::read_to_string(&path)
            && let Ok(arr) = serde_json::from_str::<Vec<String>>(&text) {
                seen.extend(arr);
            }
    }
    seen
}

/// The calibrated-coverage sidecar `<dir>/<base>.calibrated.json` → (crates, prefixes, path_crates).
/// `path_crates` are crates the engine matches by path-prefix (tokio/async_std/mio) — covered, but
/// absent from the crate-name list — so the coverage check doesn't mislabel them as blind spots.
pub(crate) fn load_calibrated(prefix: &str, base: &str) -> (BTreeSet<String>, BTreeSet<String>, BTreeSet<String>) {
    let dir = Path::new(prefix).parent().filter(|d| !d.as_os_str().is_empty()).map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
    let path = dir.join(format!("{base}.calibrated.json"));
    let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() };
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { return Default::default() };
    let pick = |key: &str| -> BTreeSet<String> {
        v.get(key)
            .and_then(|a| a.as_array())
            .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
            .unwrap_or_default()
    };
    (pick("crates"), pick("prefixes"), pick("path_crates"))
}

/// Load the type-hierarchy sidecar(s) (`<prefix>.*.hierarchy.json`, 0.7), or empty if absent (→ the
/// frontier falls back to a simple-name match, which over-lists — the safe lower-bound direction).
pub(crate) fn load_hierarchy(prefix: &str) -> BTreeMap<String, Vec<String>> {
    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
    // A `--report <file>.json` locator arrives here verbatim (§3.3.1 direct-file load); its sidecars are
    // named `<stem>.hierarchy.json`, NOT `<stem>.json.*.hierarchy.json`, so strip the `.json` before
    // forming the glob prefix — otherwise the sidecar is silently dropped (a report loaded by `.json` path
    // would lose its type hierarchy / call graph, under-reporting transitive queries at exit 0).
    let prefix = prefix.strip_suffix(".json").unwrap_or(prefix);
    let p = Path::new(prefix);
    let dir = p
        .parent()
        .filter(|d| !d.as_os_str().is_empty())
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("."));
    let Some(base) = p.file_name().and_then(|s| s.to_str()) else {
        return out;
    };
    let pfx = format!("{base}.");
    let Ok(rd) = std::fs::read_dir(&dir) else {
        return out;
    };
    for ent in rd.flatten() {
        let name = ent.file_name();
        let Some(name) = name.to_str() else { continue };
        if !name.starts_with(&pfx) || !name.ends_with(".hierarchy.json") {
            continue;
        }
        if let Ok(text) = std::fs::read_to_string(ent.path())
            && let Ok(map) = serde_json::from_str::<BTreeMap<String, Vec<String>>>(&text)
        {
            for (k, v) in map {
                out.entry(k).or_default().extend(v);
            }
        }
    }
    out
}

/// Load + merge every `<prefix>.*.callgraph.json` sidecar into one `caller -> [callees]` map (by path).
pub(crate) fn load_callgraph(prefix: &str) -> BTreeMap<String, Vec<String>> {
    load_callgraph_flagged(prefix).0
}

/// `load_callgraph`, also returning whether the merged graph is PARTIAL: `true` iff a MATCHED sidecar
/// failed to read or parse (the two disclosed drop arms below), so the graph is missing edges we KNOW
/// existed. A genuinely absent sidecar is NOT partial — that's the ordinary empty case. gains' origin
/// classification needs the bit: a fn absent from a PARTIAL baseline graph may have lived in the dropped
/// file, so calling it "new" would downgrade the supply-chain attack signal (an EXISTING fn gaining an
/// effect) to a feature ("a new fn does Net"). Partial-or-absent must read "unknown", never "new".
pub(crate) fn load_callgraph_flagged(prefix: &str) -> (BTreeMap<String, Vec<String>>, bool) {
    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut partial = false;
    // Strip a `.json` (a §3.3.1 direct-file locator) so the sidecar glob matches `<stem>.callgraph.json`
    // and never silently drops the call graph — see load_hierarchy for the full note.
    let prefix = prefix.strip_suffix(".json").unwrap_or(prefix);
    let p = Path::new(prefix);
    let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
    let Some(base) = p.file_name().and_then(|s| s.to_str()) else { return (out, partial) };
    let pfx = format!("{base}.");
    let Ok(rd) = std::fs::read_dir(&dir) else { return (out, partial) };
    for ent in rd.flatten() {
        let name = ent.file_name();
        let Some(name) = name.to_str() else { continue };
        if !name.starts_with(&pfx) || !name.ends_with(".callgraph.json") {
            continue;
        }
        // A corrupt/unreadable callgraph file silently shrinks the graph — so a blast-radius query
        // (whatif/rewire/callers) UNDER-reports, and `whatif` can return a false GREEN on the security
        // boundary (an in-scope caller whose edge lived in the dropped file vanishes). Sweep-2 fixed this
        // asymmetry on the REPORT path (load_entries discloses) but not here. Disclose both failure arms,
        // mirroring that fix — never silently drop graph edges a gate verdict depends on.
        let path = ent.path();
        let Ok(text) = std::fs::read_to_string(&path) else {
            eprintln!("candor: callgraph {} could not be read — its edges are OMITTED, so blast-radius queries (whatif/rewire/callers) UNDER-report", path.display());
            partial = true;
            continue;
        };
        match serde_json::from_str::<BTreeMap<String, Vec<String>>>(&text) {
            Ok(map) => {
                for (k, v) in map {
                    out.entry(k).or_default().extend(v);
                }
            }
            Err(_) => {
                eprintln!("candor: callgraph {} failed to parse — its edges are OMITTED, so blast-radius queries (whatif/rewire/callers) UNDER-report (corrupt or mid-write); re-run the scan", path.display());
                partial = true;
            }
        }
    }
    (out, partial)
}

/// `reports <prefix> [--exists]` — the canonical report-file discovery for a prefix, via
/// `candor_report::report_files` (the SAME `<prefix>.<crate>.<type>.json` shape, with the exact
/// sidecar exclusion: `.calibrated.json` / `.encountered-*` / `.layerreach.json` are NOT reports).
/// Default: print one report path per line. `--exists`: print nothing, exit 0 if any report exists
/// else 1 — a drop-in for the `ls "$prefix".*.*.json >/dev/null` existence checks in the wrapper, so
/// "what counts as a report" is defined once (here) instead of approximated by a shell glob.
/// Which backend produced the report(s) at <prefix>: `scan` (the stable scanner writes `.<crate>.scan`),
/// `lint` (the nightly lint writes `.<crate>.<Rlib|Executable|Cdylib|…>`), or `none`. The single owner
/// of "what backend is this report" — replaces a filename glob duplicated across both bash orchestrators.
pub(crate) fn report_backend(prefix: &str) -> &'static str {
    let files = report_files(prefix);
    if files.is_empty() {
        "none"
    } else if files.iter().any(|f| f.kind == "scan") {
        "scan"
    } else {
        "lint"
    }
}

/// The producing-build version stamped in the FIRST report file at a prefix: the §2 envelope's
/// `candor.version` (via the shared `candor_report::report_version`), falling back to `meta.version`
/// for older report shapes; `""` when absent or unreadable. Feeds the gains provenance fields
/// (`baseline_version`/`engine_version`) and the §2.1 producing-build mismatch disclosure — parity
/// with candor-ts's `reportVersion` and candor-java's `reportVersion` (both also return empty-ish
/// when unknown; the JSON emits `""`, never a guess).
pub(crate) fn report_build_version(prefix: &str) -> String {
    let Some(path) = glob_reports(prefix).into_iter().next() else { return String::new() };
    let Ok(text) = std::fs::read_to_string(&path) else { return String::new() };
    if let Some(v) = candor_report::report_version(&text) {
        return v;
    }
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { return String::new() };
    v.get("meta")
        .and_then(|m| m.get("version"))
        .and_then(|s| s.as_str())
        .unwrap_or("")
        .to_string()
}

/// ⟨0.15 staged⟩ The merged `coverage` envelope field (spec §2) across every report file at a
/// prefix: `None` when no file carries one (a fully-covered scan, or any pre-⟨0.15⟩ report —
/// absence is never an error). A multi-crate prefix (workspace members under one base) merges the
/// ledgers by package name, summing calls, sorted count-desc then name — the scanner's own ledger
/// order. Feeds `gains --json`'s coverage disclosure.
pub(crate) fn load_coverage(prefix: &str) -> Option<candor_report::Coverage> {
    let mut merged: BTreeMap<String, usize> = BTreeMap::new();
    let mut any = false;
    for path in glob_reports(prefix) {
        let Ok(text) = std::fs::read_to_string(&path) else { continue };
        if let Some(cov) = candor_report::report_coverage(&text) {
            any = true;
            for e in cov.uncovered {
                *merged.entry(e.name).or_insert(0) += e.calls;
            }
        }
    }
    if !any {
        return None;
    }
    let mut uncovered: Vec<candor_report::CoverageEntry> =
        merged.into_iter().map(|(name, calls)| candor_report::CoverageEntry { name, calls }).collect();
    uncovered.sort_by(|a, b| b.calls.cmp(&a.calls).then(a.name.cmp(&b.name)));
    Some(candor_report::Coverage { uncovered })
}

/// Is a filename (relative to the prefix's directory) a STABLE-backend artifact for <base>? True for
/// `<base>.<crate>.scan.json` and its `.scan.callgraph.json` sidecar — i.e. a `.scan.` segment.
pub(crate) fn is_scan_artifact(base: &str, name: &str) -> bool {
    name.strip_prefix(base)
        .and_then(|r| r.strip_prefix('.'))
        .is_some_and(|rest| rest.contains(".scan.") || rest.ends_with(".scan.json"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    /// A report file that is FOUND but wholly fails to parse must make `load_entries_loud` return
    /// Err(2) — NOT Ok(empty). Ok(empty) would let a caller (tour, a policy map/gate) read the emptiness
    /// as "no effects": the §4 cardinal-sin false all-clear over a corrupt report. A valid report always
    /// lists its functions, so empty-after-a-parse-failure is always the corrupt case (dogfood find).
    #[test]
    fn corrupt_report_fails_loud_not_empty() {
        let dir = std::env::temp_dir().join(format!("candor-loud-test-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        let prefix = dir.join("report");
        let prefix_s = prefix.to_string_lossy().into_owned();

        // (a) no report files at all → loud (the pre-existing guard).
        assert_eq!(load_entries_loud(&prefix_s).err(), Some(2), "no files must fail loud");

        // (b) a FOUND but truncated/garbage report → loud (the fix): parse fails, entries empty.
        let corrupt = dir.join("report.demo.scan.json");
        std::fs::File::create(&corrupt).unwrap()
            .write_all(br#"{ "candor": {}, "functions": [ { "fn": "x."#).unwrap();
        assert_eq!(load_entries_loud(&prefix_s).err(), Some(2), "a corrupt report must fail loud, never Ok(empty)");

        // (c) a SEMANTIC corruption — a bare array whose entries are all junk (no `fn`). It parses as a
        // legacy bare array, every entry is dropped, and the net-empty result must fail loud too (not read
        // as an effect-free crate) — the dogfood find that also bit the JVM engine.
        std::fs::write(&corrupt, "[1, 2, 3]").unwrap();
        assert_eq!(load_entries_loud(&prefix_s).err(), Some(2), "an all-junk array report must fail loud");

        // (d) a VALID report → Ok with entries (the tolerant path is unchanged).
        let valid = serde_json::json!({
            "meta": { "version": "t", "toolchain": "stable", "spec": candor_report::SPEC_VERSION },
            "crate": "demo",
            "functions": [ { "fn": "a::f", "inferred": ["Fs"], "direct": ["Fs"] } ]
        });
        std::fs::write(&corrupt, serde_json::to_string(&valid).unwrap()).unwrap();
        let got = load_entries_loud(&prefix_s).expect("a valid report loads");
        assert!(!got.is_empty(), "a valid report yields entries");

        // (e) a WELL-FORMED EMPTY report (functions: []) is NOT corruption — it stays a non-fatal empty
        // (Ok), so the loud rule never over-fires on a genuinely effect-free crate (parity with the ports).
        let empty = serde_json::json!({
            "meta": { "version": "t", "toolchain": "stable", "spec": candor_report::SPEC_VERSION },
            "crate": "demo", "functions": []
        });
        std::fs::write(&corrupt, serde_json::to_string(&empty).unwrap()).unwrap();
        assert!(load_entries_loud(&prefix_s).is_ok(), "a well-formed empty report is not corrupt — Ok(empty)");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// ⟨0.15 staged⟩ `load_coverage` reads the §2 `coverage` envelope field: absent everywhere →
    /// None (a fully-covered scan or any pre-⟨0.15⟩ report — never an error); a multi-crate prefix
    /// merges by package name summing calls, sorted count-desc then name (the scanner's ledger
    /// order). Also pins §2 forward-compatibility end-to-end: a coverage-carrying report still
    /// loads its entries through the ordinary query path.
    #[test]
    fn load_coverage_merges_the_envelope_ledger_and_entries_still_load() {
        let dir = std::env::temp_dir().join(format!("candor-loadcov-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let _ = std::fs::create_dir_all(&dir);
        let prefix = dir.join("report").to_string_lossy().into_owned();
        std::fs::write(
            dir.join("report.a.scan.json"),
            r#"{"candor":{"version":"v","toolchain":"t","spec":"0.15"},
                "coverage":{"uncovered":[{"name":"x","calls":2}]},
                "functions":[{"fn":"a::f","inferred":["Net"]}]}"#,
        )
        .unwrap();
        std::fs::write(
            dir.join("report.b.scan.json"),
            r#"{"candor":{"version":"v","toolchain":"t","spec":"0.15"},
                "coverage":{"uncovered":[{"name":"x","calls":1},{"name":"y","calls":5}]},
                "functions":[]}"#,
        )
        .unwrap();
        std::fs::write(
            dir.join("report.c.scan.json"),
            r#"{"candor":{"version":"v","toolchain":"t","spec":"0.15"},"functions":[]}"#,
        )
        .unwrap();
        let cov = load_coverage(&prefix).expect("a ledger-carrying prefix must load");
        let got: Vec<(String, usize)> = cov.uncovered.iter().map(|e| (e.name.clone(), e.calls)).collect();
        assert_eq!(
            got,
            vec![("y".to_string(), 5), ("x".to_string(), 3)],
            "merged by name (x: 2+1), sorted count-desc then name"
        );
        // Tolerance: the coverage-carrying reports' entries load through the ordinary query path.
        let entries = load_entries(&prefix);
        assert_eq!(entries.len(), 1, "a coverage-carrying report must still yield its functions");
        assert_eq!(entries[0].func, "a::f");
        // A coverage-free prefix → None (never an error, never a fabricated empty ledger).
        let plain = std::env::temp_dir().join(format!("candor-loadcov-none-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&plain);
        let _ = std::fs::create_dir_all(&plain);
        std::fs::write(
            plain.join("report.d.scan.json"),
            r#"{"candor":{"version":"v","toolchain":"t","spec":"0.15"},"functions":[]}"#,
        )
        .unwrap();
        assert!(load_coverage(&plain.join("report").to_string_lossy()).is_none());
        let _ = std::fs::remove_dir_all(&dir);
        let _ = std::fs::remove_dir_all(&plain);
    }
}