Skip to main content

epicenter_cli/
fingerprint.rs

1//! Read-only parser for cargo's `.fingerprint/` state: parses each unit
2//! directory into a [`Unit`] (kind, hashes, fingerprint JSON, dep-info) and
3//! attaches the artifacts that belong to it.
4//!
5//! Directory layout as written by cargo:
6//! ```text
7//! <build>/<profile>/.fingerprint/<package>-<16hex>/
8//!   <memo-file>            ← TEXT, 16 hex chars = the authoritative memo hash
9//!   <memo-file>.json       ← serialized Fingerprint (schema below)
10//!   dep-<memo-file>        ← binary EncodedDepInfo (not parsed here)
11//!   invoked.timestamp      ← optional, ignorable
12//! ```
13//! `<memo-file>` names the target kind: `lib-serde` (lib),
14//! `build-script-build` (compile build script), `run-build-script-build`
15//! (run build script), or the bin name.
16//!
17//! JSON schema — mostly opaque HASH NUMBERS plus two human facts:
18//! `{rustc, features, declared_features, target, profile, path, deps,
19//!  local, rustflags, config, compile_kind}` where `features` /
20//! `declared_features` are STRINGS containing serialized JSON arrays.
21
22use std::collections::HashMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::time::SystemTime;
26
27use anyhow::{Context, Result};
28use serde_json::Value;
29use walkdir::WalkDir;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum UnitKind {
33    Lib,
34    BuildScriptCompile,
35    BuildScriptRun,
36    Other,
37}
38
39impl UnitKind {
40    pub fn label(self) -> &'static str {
41        match self {
42            UnitKind::Lib => "lib",
43            UnitKind::BuildScriptCompile => "build.rs",
44            UnitKind::BuildScriptRun => "build-run",
45            UnitKind::Other => "other",
46        }
47    }
48}
49
50/// One parsed `.fingerprint/<package>-<16hex>/` directory.
51#[derive(Debug, Clone)]
52pub struct Unit {
53    /// `serde-102a33f055a8d229`
54    pub dir_name: String,
55    /// `serde` (dir name minus the 16-hex suffix)
56    pub package: String,
57    /// `102a33f055a8d229` — == the deps/ filename suffix
58    pub dir_suffix: String,
59    /// `debug` / `release` …
60    pub profile: String,
61    pub kind: UnitKind,
62    /// Name of the memo file (`lib-serde`, `build-script-build`, …)
63    pub memo_file: String,
64    /// Content of the memo file — the authoritative hash
65    pub memo_hash: Option<String>,
66    pub json: Option<FingerprintJson>,
67    pub dep_info: Option<PathBuf>,
68    /// Files inside the unit dir (for forensics completeness).
69    pub extra_files: Vec<String>,
70}
71
72/// Typed view of the serialized Fingerprint schema.
73#[derive(Debug, Clone, Default)]
74pub struct FingerprintJson {
75    /// Toolchain identity — the #1 cache-key invalidator.
76    pub rustc: String,
77    pub features: Vec<String>,
78    pub declared_features: Vec<String>,
79    /// `[dep-id, dep-name, flag, unit-hash]` triples, simplified.
80    pub deps: Vec<DepRef>,
81    /// Opaque hashes (reported as-is).
82    pub target: String,
83    pub profile: String,
84    pub path: String,
85    pub config: String,
86    pub compile_kind: String,
87    pub rustflags: Vec<String>,
88    /// `local[].CheckDepInfo.dep_info` paths (relative to the build dir).
89    pub dep_info_paths: Vec<String>,
90}
91
92#[derive(Debug, Clone)]
93pub struct DepRef {
94    pub name: String,
95    pub unit_hash: String,
96}
97
98impl DepRef {
99    /// The dependency's **memo hash** — the join key between a dependency
100    /// edge and the unit that owns it.
101    ///
102    /// Cargo stores a dependency's fingerprint hash as a u64 in the
103    /// fingerprint JSON, and writes the same value into the dependency's
104    /// memo file as hex of its **little-endian** bytes (cargo's `to_hex`).
105    /// Example: serde's `serde_core` entry `9416646317397223793`
106    /// resolves to `serde_core-…/lib-serde_core` = `71a9f323efa5ae82`
107    /// (the same number, in opposite byte order).
108    pub fn memo_hash(&self) -> Option<String> {
109        let value: u64 = self.unit_hash.parse().ok()?;
110        let mut out = String::with_capacity(16);
111        for byte in value.to_le_bytes() {
112            out.push_str(&format!("{byte:02x}"));
113        }
114        Some(out)
115    }
116}
117
118/// Parse `<package>-<16hex>` into (package, hex).
119fn split_dir_name(dir_name: &str) -> Option<(String, String)> {
120    let (pkg, hex) = dir_name.rsplit_once('-')?;
121    Some((pkg.to_string(), hex.to_string()))
122}
123
124/// The target kind a memo file name implies (`lib-serde`, `build-script-build`, …).
125pub fn classify_kind(memo_file: &str) -> UnitKind {
126    if memo_file.starts_with("lib") {
127        UnitKind::Lib
128    } else if memo_file.starts_with("run") {
129        UnitKind::BuildScriptRun
130    } else if memo_file.contains("build-script") || memo_file.starts_with("build") {
131        UnitKind::BuildScriptCompile
132    } else {
133        UnitKind::Other
134    }
135}
136
137/// `features`/`declared_features` are strings holding a JSON array — unwrap.
138fn parse_string_array(value: &Value) -> Vec<String> {
139    match value {
140        Value::Array(items) => items
141            .iter()
142            .filter_map(|v| v.as_str().map(str::to_string))
143            .collect(),
144        Value::String(text) => serde_json::from_str::<Vec<String>>(text)
145            .unwrap_or_else(|_| text.split_whitespace().map(str::to_string).collect()),
146        _ => Vec::new(),
147    }
148}
149
150fn parse_u64_field(json: &Value, key: &str) -> String {
151    match json.get(key) {
152        Some(Value::Number(n)) => n.to_string(),
153        Some(Value::String(s)) => s.clone(),
154        Some(other) if !other.is_null() => other.to_string(),
155        _ => String::new(),
156    }
157}
158
159fn parse_fingerprint_json(json: &Value) -> FingerprintJson {
160    let deps = json
161        .get("deps")
162        .and_then(Value::as_array)
163        .map(|items| {
164            items
165                .iter()
166                .filter_map(|item| {
167                    let arr = item.as_array()?;
168                    let unit_hash = match arr.get(3)? {
169                        Value::Number(number) => number.to_string(),
170                        Value::String(text) => text.clone(),
171                        _ => return None,
172                    };
173                    Some(DepRef {
174                        name: arr.get(1)?.as_str()?.to_string(),
175                        unit_hash,
176                    })
177                })
178                .collect()
179        })
180        .unwrap_or_default();
181
182    let dep_info_paths = json
183        .get("local")
184        .and_then(Value::as_array)
185        .map(|items| {
186            items
187                .iter()
188                .filter_map(|item| {
189                    item.get("CheckDepInfo")?
190                        .get("dep_info")?
191                        .as_str()
192                        .map(str::to_string)
193                })
194                .collect()
195        })
196        .unwrap_or_default();
197
198    FingerprintJson {
199        rustc: parse_u64_field(json, "rustc"),
200        features: json
201            .get("features")
202            .map(parse_string_array)
203            .unwrap_or_default(),
204        declared_features: json
205            .get("declared_features")
206            .map(parse_string_array)
207            .unwrap_or_default(),
208        deps,
209        target: parse_u64_field(json, "target"),
210        profile: parse_u64_field(json, "profile"),
211        path: parse_u64_field(json, "path"),
212        config: parse_u64_field(json, "config"),
213        compile_kind: parse_u64_field(json, "compile_kind"),
214        rustflags: json
215            .get("rustflags")
216            .map(parse_string_array)
217            .unwrap_or_default(),
218        dep_info_paths,
219    }
220}
221
222/// Parse a serialized `<memo>.json` fingerprint — from a build dir or from
223/// the vault's stored copy; both hold the same cargo-written bytes.
224pub fn parse_fingerprint(text: &str) -> Option<FingerprintJson> {
225    serde_json::from_str::<Value>(text)
226        .ok()
227        .map(|value| parse_fingerprint_json(&value))
228}
229
230/// Read every unit directory under all `.fingerprint/` roots inside the build dir.
231pub fn read_units(build_root: &Path) -> Result<Vec<Unit>> {
232    let mut units = Vec::new();
233    for profile_entry in fs::read_dir(build_root)
234        .with_context(|| format!("reading build dir {}", build_root.display()))?
235    {
236        let profile_entry = profile_entry?;
237        if !profile_entry.file_type()?.is_dir() {
238            continue;
239        }
240        let profile = profile_entry.file_name().to_string_lossy().into_owned();
241        let fp_root = build_root.join(&profile).join(".fingerprint");
242        let fp_meta = match fs::symlink_metadata(&fp_root) {
243            Ok(meta) => meta,
244            Err(_) => continue,
245        };
246        if !fp_meta.is_dir() || fp_meta.file_type().is_symlink() {
247            continue; // finals (CACHEDIR.TAG …), locks, symlinks — no fingerprints here
248        }
249        for unit_entry in
250            fs::read_dir(&fp_root).with_context(|| format!("reading {}", fp_root.display()))?
251        {
252            let unit_entry = unit_entry?;
253            // `file_type()` does not follow symlinks: a unit dir must be real.
254            if !unit_entry.file_type()?.is_dir() {
255                continue;
256            }
257            let unit_dir = unit_entry.path();
258            if let Some(unit) = read_unit_dir(&profile, &unit_dir) {
259                units.push(unit);
260            }
261        }
262    }
263    Ok(units)
264}
265
266/// Memo hash → index of the unit that owns it. First wins on duplicates.
267pub fn memo_index(units: &[Unit]) -> HashMap<String, usize> {
268    let mut index = HashMap::new();
269    for (position, unit) in units.iter().enumerate() {
270        if let Some(hash) = unit.memo_hash.as_deref() {
271            index.entry(hash.to_string()).or_insert(position);
272        }
273    }
274    index
275}
276
277/// Resolve one dependency edge against a memo index.
278pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
279    index.get(dep.memo_hash()?.as_str()).copied()
280}
281
282/// Every unit's dependency edges, resolved to indices in `units`.
283///
284/// Unresolved edges — a dependency absent from this view, or one recorded
285/// against a secondary memo file of a shared dir — are dropped: a partial
286/// graph is useful, a wrong edge would be worse than a missing one.
287pub fn dep_edges(units: &[Unit]) -> Vec<Vec<usize>> {
288    let index = memo_index(units);
289    units
290        .iter()
291        .map(|unit| {
292            unit.json
293                .as_ref()
294                .map(|json| {
295                    json.deps
296                        .iter()
297                        .filter_map(|dep| resolve_dep(&index, dep))
298                        .collect()
299                })
300                .unwrap_or_default()
301        })
302        .collect()
303}
304
305fn read_unit_dir(profile: &str, unit_dir: &Path) -> Option<Unit> {
306    let dir_name = unit_dir.file_name()?.to_string_lossy().into_owned();
307    let (package, dir_suffix) = split_dir_name(&dir_name)?;
308
309    let mut candidates: Vec<(String, String)> = Vec::new(); // (name, content)
310    let mut dep_info: Option<PathBuf> = None;
311    let mut json_path: Option<PathBuf> = None;
312
313    for entry in fs::read_dir(unit_dir).ok()? {
314        let entry = entry.ok()?;
315        // Only regular files: never read a FIFO/device/symlink planted by a
316        // build script (harvest would otherwise hang or copy a secret).
317        if !entry.file_type().ok()?.is_file() {
318            continue;
319        }
320        let name = entry.file_name().to_string_lossy().into_owned();
321        if name.starts_with("dep-") {
322            dep_info = Some(entry.path());
323            continue;
324        }
325        if name.ends_with(".json") {
326            json_path = Some(entry.path());
327            continue;
328        }
329        if name == "invoked.timestamp" {
330            continue;
331        }
332        if entry
333            .metadata()
334            .map(|meta| meta.len() > 4 * 1024 * 1024)
335            .unwrap_or(true)
336        {
337            continue;
338        }
339        let content = crate::util::read_small_text(&entry.path(), 4 * 1024 * 1024)
340            .ok()?
341            .trim()
342            .to_string();
343        candidates.push((name, content));
344    }
345
346    let ((memo_file_name, memo_hash), extra_files) = pick_memo(candidates)?;
347    let kind = classify_kind(&memo_file_name);
348
349    let json = json_path
350        .and_then(|path| crate::util::read_small_text(&path, 4 * 1024 * 1024).ok())
351        .and_then(|text| parse_fingerprint(&text));
352
353    Some(Unit {
354        dir_name,
355        package,
356        dir_suffix,
357        profile: profile.to_string(),
358        kind,
359        memo_file: memo_file_name,
360        memo_hash: Some(memo_hash),
361        json,
362        dep_info,
363        extra_files,
364    })
365}
366
367/// Choose the memo file among a unit dir's remaining files: the one whose
368/// content is a 16-hex hash. Cargo also keeps cached diagnostics in the same
369/// directory (`output-*`) — those are state, not fingerprints. When nothing
370/// looks like a hash the first candidate is used anyway, so a corrupted unit
371/// still shows up in forensics instead of disappearing.
372fn pick_memo(mut candidates: Vec<(String, String)>) -> Option<((String, String), Vec<String>)> {
373    if candidates.is_empty() {
374        return None;
375    }
376    let index = candidates
377        .iter()
378        .position(|(_, content)| looks_like_hash(content))
379        .unwrap_or(0);
380    let (name, hash) = candidates.remove(index);
381    let extras = candidates.into_iter().map(|(name, _)| name).collect();
382    Some(((name, hash), extras))
383}
384
385/// A fingerprint memo file holds exactly 16 hex characters.
386pub(crate) fn looks_like_hash(text: &str) -> bool {
387    text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
388}
389
390/// Candidate `build/<package>-<hex>` anchor dirs for a build-script unit
391/// (dashes may be underscore-normalized in artifact paths — check both
392/// plus the bare-hash fallback). Shared by the reader and by `evict`.
393pub(crate) fn build_anchor_dirs(build_root: &Path, unit: &Unit) -> Vec<PathBuf> {
394    let build_dir = build_root.join(&unit.profile).join("build");
395    let pkg_plain = &unit.package;
396    let pkg_underscored = unit.package.replace('-', "_");
397    let mut candidates = Vec::new();
398    candidates.push(build_dir.join(format!("{pkg_underscored}-{}", unit.dir_suffix)));
399    if pkg_plain != &pkg_underscored {
400        candidates.push(build_dir.join(format!("{pkg_plain}-{}", unit.dir_suffix)));
401    }
402    candidates.push(build_dir.join(&unit.dir_suffix));
403    candidates
404}
405
406/// Artifacts that belong to a unit, found by suffix (fingerprint dir hash
407/// == deps filename suffix) — `deps/*-{hex}.rlib|rmeta|so…` and, for build
408/// script units, the whole `build/{hex}/` subtree. Registry and workspace
409/// units look identical here.
410pub struct ArtifactInfo {
411    pub paths: Vec<(PathBuf, u64, Option<SystemTime>)>,
412    pub bytes: u64,
413    pub newest: Option<SystemTime>,
414}
415
416pub fn artifacts_for(build_root: &Path, unit: &Unit) -> ArtifactInfo {
417    let profile_dir = build_root.join(&unit.profile);
418    let mut paths = Vec::new();
419
420    let deps_dir = profile_dir.join("deps");
421    let deps_real = fs::symlink_metadata(&deps_dir)
422        .map(|meta| meta.is_dir() && !meta.file_type().is_symlink())
423        .unwrap_or(false);
424    if let Ok(entries) = fs::read_dir(&deps_dir) {
425        if !deps_real {
426            return ArtifactInfo {
427                paths: Vec::new(),
428                bytes: 0,
429                newest: None,
430            };
431        }
432        let suffix = format!("-{}", unit.dir_suffix);
433        for entry in entries.flatten() {
434            // Regular files only; a symlink/FIFO here must not be copied.
435            if !entry
436                .file_type()
437                .map(|kind| kind.is_file())
438                .unwrap_or(false)
439            {
440                continue;
441            }
442            let name = entry.file_name().to_string_lossy().into_owned();
443            // Compare on the STEM so extensions don't hide the match:
444            // `libserde-102a….rlib` → stem `libserde-102a…` ends with `-102a…`.
445            let stem = Path::new(&name)
446                .file_stem()
447                .map(|s| s.to_string_lossy().into_owned())
448                .unwrap_or(name.clone());
449            if !stem.ends_with(&suffix) {
450                continue;
451            }
452            if let Ok(meta) = entry.metadata() {
453                paths.push((entry.path(), meta.len(), meta.modified().ok()));
454            }
455        }
456    }
457
458    if unit.kind != UnitKind::Lib {
459        for candidate in build_anchor_dirs(build_root, unit) {
460            let is_real_dir = fs::symlink_metadata(&candidate)
461                .map(|meta| meta.is_dir() && !meta.file_type().is_symlink())
462                .unwrap_or(false);
463            if !is_real_dir {
464                continue;
465            }
466            for entry in WalkDir::new(&candidate).min_depth(1) {
467                let Ok(entry) = entry else { continue };
468                if !entry.file_type().is_file() {
469                    continue;
470                }
471                if let Ok(meta) = entry.metadata() {
472                    paths.push((entry.path().into(), meta.len(), meta.modified().ok()));
473                }
474            }
475        }
476    }
477
478    let mut bytes = 0;
479    let mut newest: Option<SystemTime> = None;
480    for (_, size, mtime) in &paths {
481        bytes += size;
482        if let Some(m) = mtime {
483            if newest.map_or(true, |n| *m > n) {
484                newest = Some(*m);
485            }
486        }
487    }
488    ArtifactInfo {
489        paths,
490        bytes,
491        newest,
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    /// Verbatim sample from the live shared cache (1.97.1 generation).
500    const SAMPLE_JSON: &str = r#"{"rustc":12019306335353385202,"features":"[\"default\", \"derive\", \"serde_derive\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"derive\", \"rc\", \"serde_derive\", \"std\", \"unstable\"]","target":11327258112168116673,"profile":15657897354478470176,"path":14081935628356765094,"deps":[[6557439603276904804,"build_script_build",false,14091436140065671243],[11029742160753049355,"serde_core",false,8824494307375987869],[13312204359551525516,"serde_derive",false,16721730783038555939]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\serde-102a33f055a8d229\\dep-lib-serde","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}"#;
501
502    #[test]
503    fn reads_and_parses_synthetic_unit() -> Result<()> {
504        let root = tempfile::tempdir()?;
505        let unit_dir = root
506            .path()
507            .join("debug/.fingerprint/serde-102a33f055a8d229");
508        fs::create_dir_all(&unit_dir)?;
509        // memo file: 16 hex chars, no trailing newline (verbatim format)
510        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
511        fs::write(unit_dir.join("lib-serde.json"), SAMPLE_JSON)?;
512        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 69])?;
513        fs::write(unit_dir.join("invoked.timestamp"), "")?;
514        fs::create_dir_all(root.path().join("debug/deps"))?;
515        fs::write(
516            root.path()
517                .join("debug/deps/libserde-102a33f055a8d229.rlib"),
518            [0u8; 10],
519        )?;
520
521        let units = read_units(root.path())?;
522        assert_eq!(units.len(), 1);
523
524        let unit = &units[0];
525        assert_eq!(unit.package, "serde");
526        assert_eq!(unit.dir_suffix, "102a33f055a8d229");
527        assert_eq!(unit.profile, "debug");
528        assert_eq!(unit.kind, UnitKind::Lib);
529        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
530        assert!(unit.dep_info.is_some());
531
532        let json = unit.json.as_ref().expect("sample json must parse");
533        assert_eq!(json.rustc, "12019306335353385202");
534        assert!(json.features.contains(&"derive".to_string()));
535        assert!(json.declared_features.contains(&"unstable".to_string()));
536        assert_eq!(json.deps.len(), 3);
537        assert_eq!(json.deps[1].name, "serde_core");
538        assert_eq!(json.dep_info_paths.len(), 1);
539
540        // Suffix-matching artifact attach.
541        let arts = artifacts_for(root.path(), unit);
542        assert_eq!(arts.paths.len(), 1);
543        assert_eq!(arts.bytes, 10);
544        assert!(arts.newest.is_some());
545        Ok(())
546    }
547
548    #[test]
549    fn run_units_attach_their_build_tree() -> Result<()> {
550        let root = tempfile::tempdir()?;
551        let unit_dir = root.path().join("debug/.fingerprint/x-1122334455667788");
552        fs::create_dir_all(&unit_dir)?;
553        fs::write(unit_dir.join("build-script-build"), "aaaabbbbccccdddd")?;
554        fs::create_dir_all(root.path().join("debug/build/1122334455667788"))?;
555        fs::write(
556            root.path().join("debug/build/1122334455667788/output"),
557            [0u8; 4],
558        )?;
559        fs::write(
560            root.path().join("debug/build/1122334455667788/stderr"),
561            [0u8; 6],
562        )?;
563
564        let units = read_units(root.path())?;
565        assert_eq!(units.len(), 1);
566        let unit = &units[0];
567        assert_eq!(unit.kind, UnitKind::BuildScriptCompile);
568        let arts = artifacts_for(root.path(), unit);
569        assert_eq!(arts.paths.len(), 2, "output + stderr attached");
570        assert_eq!(arts.bytes, 10);
571        assert!(arts.newest.is_some());
572        Ok(())
573    }
574
575    #[test]
576    fn cached_diagnostics_are_not_mistaken_for_the_memo() -> Result<()> {
577        let root = tempfile::tempdir()?;
578        let unit_dir = root
579            .path()
580            .join("debug/.fingerprint/serde-102a33f055a8d229");
581        fs::create_dir_all(&unit_dir)?;
582        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
583        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 8])?;
584        // Cargo's message cache: rendered diagnostics live in the same dir.
585        fs::write(
586            unit_dir.join("output-lib-serde"),
587            r#"{"$message_type":"diagnostic","message":"unused import"}"#,
588        )?;
589
590        let units = read_units(root.path())?;
591        assert_eq!(units.len(), 1);
592        let unit = &units[0];
593        assert_eq!(unit.memo_file, "lib-serde", "the hash file wins");
594        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
595        assert_eq!(unit.extra_files, vec!["output-lib-serde".to_string()]);
596        Ok(())
597    }
598
599    #[test]
600    fn dep_hashes_resolve_to_memo_hashes_little_endian() {
601        // Example: serde's fingerprint lists serde_core as 9416646317397223793,
602        // and serde_core-b468be6fa8c1f936/lib-serde_core contains 71a9f323efa5ae82.
603        let dep = DepRef {
604            name: "serde_core".into(),
605            unit_hash: "9416646317397223793".into(),
606        };
607        assert_eq!(dep.memo_hash().as_deref(), Some("71a9f323efa5ae82"));
608
609        let junk = DepRef {
610            name: "x".into(),
611            unit_hash: "not-a-number".into(),
612        };
613        assert!(junk.memo_hash().is_none());
614    }
615
616    #[test]
617    fn dep_edges_join_the_graph_by_memo_hash() -> Result<()> {
618        let root = tempfile::tempdir()?;
619        let core = root
620            .path()
621            .join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
622        fs::create_dir_all(&core)?;
623        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
624        fs::create_dir_all(root.path().join("debug/deps"))?;
625        fs::write(
626            root.path()
627                .join("debug/deps/libserde_core-b468be6fa8c1f936.rlib"),
628            [0u8; 4],
629        )?;
630
631        let serde = root
632            .path()
633            .join("debug/.fingerprint/serde-746d173581f1a314");
634        fs::create_dir_all(&serde)?;
635        fs::write(serde.join("lib-serde"), "5d82713a7f2d05e2")?;
636        fs::write(
637            serde.join("lib-serde.json"),
638            r#"{"deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
639        )?;
640        fs::write(
641            root.path()
642                .join("debug/deps/libserde-746d173581f1a314.rlib"),
643            [0u8; 4],
644        )?;
645
646        let units = read_units(root.path())?;
647        assert_eq!(units.len(), 2);
648        let edges = dep_edges(&units);
649        let serde_at = units.iter().position(|u| u.package == "serde").unwrap();
650        let core_at = units
651            .iter()
652            .position(|u| u.package == "serde_core")
653            .unwrap();
654        assert_eq!(edges[serde_at], vec![core_at]);
655        assert!(edges[core_at].is_empty());
656        Ok(())
657    }
658
659    #[test]
660    fn pick_memo_prefers_hash_content_and_falls_back() {
661        let candidates = vec![
662            (
663                "output-lib-x".to_string(),
664                r#"{"message":"warning"}"#.to_string(),
665            ),
666            ("lib-x".to_string(), "5d82713a7f2d05e2".to_string()),
667        ];
668        let ((name, hash), extras) = pick_memo(candidates).expect("a memo exists");
669        assert_eq!(name, "lib-x");
670        assert_eq!(hash, "5d82713a7f2d05e2");
671        assert_eq!(extras, vec!["output-lib-x".to_string()]);
672
673        // Nothing hash-shaped: the first candidate is the fallback, so a
674        // corrupted unit still shows up in forensics instead of vanishing.
675        let candidates = vec![("weird".to_string(), "not a hash".to_string())];
676        let ((name, _), extras) = pick_memo(candidates).expect("fallback");
677        assert_eq!(name, "weird");
678        assert!(extras.is_empty());
679
680        assert!(pick_memo(Vec::new()).is_none());
681    }
682}