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()
247            || fp_meta.file_type().is_symlink()
248            || crate::util::is_reparse_point(&fp_meta)
249        {
250            continue; // finals (CACHEDIR.TAG …), locks, symlinks — no fingerprints here
251        }
252        for unit_entry in
253            fs::read_dir(&fp_root).with_context(|| format!("reading {}", fp_root.display()))?
254        {
255            let unit_entry = unit_entry?;
256            // `file_type()` does not follow symlinks: a unit dir must be real.
257            if !unit_entry.file_type()?.is_dir() {
258                continue;
259            }
260            let unit_dir = unit_entry.path();
261            if let Some(unit) = read_unit_dir(&profile, &unit_dir) {
262                units.push(unit);
263            }
264        }
265    }
266    Ok(units)
267}
268
269/// Memo hash → index of the unit that owns it. First wins on duplicates.
270pub fn memo_index(units: &[Unit]) -> HashMap<String, usize> {
271    let mut index = HashMap::new();
272    for (position, unit) in units.iter().enumerate() {
273        if let Some(hash) = unit.memo_hash.as_deref() {
274            index.entry(hash.to_string()).or_insert(position);
275        }
276    }
277    index
278}
279
280/// Resolve one dependency edge against a memo index.
281pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
282    index.get(dep.memo_hash()?.as_str()).copied()
283}
284
285/// Every unit's dependency edges, resolved to indices in `units`.
286///
287/// Unresolved edges — a dependency absent from this view, or one recorded
288/// against a secondary memo file of a shared dir — are dropped: a partial
289/// graph is useful, a wrong edge would be worse than a missing one.
290pub fn dep_edges(units: &[Unit]) -> Vec<Vec<usize>> {
291    let index = memo_index(units);
292    units
293        .iter()
294        .map(|unit| {
295            unit.json
296                .as_ref()
297                .map(|json| {
298                    json.deps
299                        .iter()
300                        .filter_map(|dep| resolve_dep(&index, dep))
301                        .collect()
302                })
303                .unwrap_or_default()
304        })
305        .collect()
306}
307
308fn read_unit_dir(profile: &str, unit_dir: &Path) -> Option<Unit> {
309    let dir_name = unit_dir.file_name()?.to_string_lossy().into_owned();
310    let (package, dir_suffix) = split_dir_name(&dir_name)?;
311
312    let mut candidates: Vec<(String, String)> = Vec::new(); // (name, content)
313    let mut dep_info: Option<PathBuf> = None;
314    let mut json_path: Option<PathBuf> = None;
315
316    for entry in fs::read_dir(unit_dir).ok()? {
317        let entry = entry.ok()?;
318        // Only regular files: never read a FIFO/device/symlink planted by a
319        // build script (harvest would otherwise hang or copy a secret).
320        let entry_meta = fs::symlink_metadata(entry.path()).ok()?;
321        if !entry_meta.is_file()
322            || entry_meta.file_type().is_symlink()
323            || crate::util::is_reparse_point(&entry_meta)
324        {
325            continue;
326        }
327        let name = entry.file_name().to_string_lossy().into_owned();
328        if name.starts_with("dep-") {
329            dep_info = Some(entry.path());
330            continue;
331        }
332        if name.ends_with(".json") {
333            json_path = Some(entry.path());
334            continue;
335        }
336        if name == "invoked.timestamp" {
337            continue;
338        }
339        if entry
340            .metadata()
341            .map(|meta| meta.len() > 4 * 1024 * 1024)
342            .unwrap_or(true)
343        {
344            continue;
345        }
346        let content = crate::util::read_small_text(&entry.path(), 4 * 1024 * 1024)
347            .ok()?
348            .trim()
349            .to_string();
350        candidates.push((name, content));
351    }
352
353    let ((memo_file_name, memo_hash), extra_files) = pick_memo(candidates)?;
354    let kind = classify_kind(&memo_file_name);
355
356    let json = json_path
357        .and_then(|path| crate::util::read_small_text(&path, 4 * 1024 * 1024).ok())
358        .and_then(|text| parse_fingerprint(&text));
359
360    Some(Unit {
361        dir_name,
362        package,
363        dir_suffix,
364        profile: profile.to_string(),
365        kind,
366        memo_file: memo_file_name,
367        memo_hash: Some(memo_hash),
368        json,
369        dep_info,
370        extra_files,
371    })
372}
373
374/// Choose the memo file among a unit dir's remaining files: the one whose
375/// content is a 16-hex hash. Cargo also keeps cached diagnostics in the same
376/// directory (`output-*`) — those are state, not fingerprints. When nothing
377/// looks like a hash the first candidate is used anyway, so a corrupted unit
378/// still shows up in forensics instead of disappearing.
379fn pick_memo(mut candidates: Vec<(String, String)>) -> Option<((String, String), Vec<String>)> {
380    if candidates.is_empty() {
381        return None;
382    }
383    let index = candidates
384        .iter()
385        .position(|(_, content)| looks_like_hash(content))
386        .unwrap_or(0);
387    let (name, hash) = candidates.remove(index);
388    let extras = candidates.into_iter().map(|(name, _)| name).collect();
389    Some(((name, hash), extras))
390}
391
392/// A fingerprint memo file holds exactly 16 hex characters.
393pub(crate) fn looks_like_hash(text: &str) -> bool {
394    text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
395}
396
397/// Candidate `build/<package>-<hex>` anchor dirs for a build-script unit
398/// (dashes may be underscore-normalized in artifact paths — check both
399/// plus the bare-hash fallback). Shared by the reader and by `evict`.
400pub(crate) fn build_anchor_dirs(build_root: &Path, unit: &Unit) -> Vec<PathBuf> {
401    let build_dir = build_root.join(&unit.profile).join("build");
402    let pkg_plain = &unit.package;
403    let pkg_underscored = unit.package.replace('-', "_");
404    let mut candidates = Vec::new();
405    candidates.push(build_dir.join(format!("{pkg_underscored}-{}", unit.dir_suffix)));
406    if pkg_plain != &pkg_underscored {
407        candidates.push(build_dir.join(format!("{pkg_plain}-{}", unit.dir_suffix)));
408    }
409    candidates.push(build_dir.join(&unit.dir_suffix));
410    candidates
411}
412
413/// Artifacts that belong to a unit, found by suffix (fingerprint dir hash
414/// == deps filename suffix) — `deps/*-{hex}.rlib|rmeta|so…` and, for build
415/// script units, the whole `build/{hex}/` subtree. Registry and workspace
416/// units look identical here.
417pub struct ArtifactInfo {
418    pub paths: Vec<(PathBuf, u64, Option<SystemTime>)>,
419    pub bytes: u64,
420    pub newest: Option<SystemTime>,
421}
422
423pub fn artifacts_for(build_root: &Path, unit: &Unit) -> ArtifactInfo {
424    let profile_dir = build_root.join(&unit.profile);
425    let mut paths = Vec::new();
426
427    let deps_dir = profile_dir.join("deps");
428    let deps_real = fs::symlink_metadata(&deps_dir)
429        .map(|meta| {
430            meta.is_dir() && !meta.file_type().is_symlink() && !crate::util::is_reparse_point(&meta)
431        })
432        .unwrap_or(false);
433    if let Ok(entries) = fs::read_dir(&deps_dir) {
434        if !deps_real {
435            return ArtifactInfo {
436                paths: Vec::new(),
437                bytes: 0,
438                newest: None,
439            };
440        }
441        let suffix = format!("-{}", unit.dir_suffix);
442        for entry in entries.flatten() {
443            // Regular files only; a symlink/FIFO here must not be copied.
444            if !entry
445                .file_type()
446                .map(|kind| kind.is_file())
447                .unwrap_or(false)
448            {
449                continue;
450            }
451            let name = entry.file_name().to_string_lossy().into_owned();
452            // Compare on the STEM so extensions don't hide the match:
453            // `libserde-102a….rlib` → stem `libserde-102a…` ends with `-102a…`.
454            let stem = Path::new(&name)
455                .file_stem()
456                .map(|s| s.to_string_lossy().into_owned())
457                .unwrap_or(name.clone());
458            if !stem.ends_with(&suffix) {
459                continue;
460            }
461            if let Ok(meta) = entry.metadata() {
462                paths.push((entry.path(), meta.len(), meta.modified().ok()));
463            }
464        }
465    }
466
467    if unit.kind != UnitKind::Lib {
468        for candidate in build_anchor_dirs(build_root, unit) {
469            let is_real_dir = fs::symlink_metadata(&candidate)
470                .map(|meta| {
471                    meta.is_dir()
472                        && !meta.file_type().is_symlink()
473                        && !crate::util::is_reparse_point(&meta)
474                })
475                .unwrap_or(false);
476            if !is_real_dir {
477                continue;
478            }
479            for entry in WalkDir::new(&candidate).min_depth(1) {
480                let Ok(entry) = entry else { continue };
481                if !entry.file_type().is_file() {
482                    continue;
483                }
484                if let Ok(meta) = entry.metadata() {
485                    paths.push((entry.path().into(), meta.len(), meta.modified().ok()));
486                }
487            }
488        }
489    }
490
491    let mut bytes = 0;
492    let mut newest: Option<SystemTime> = None;
493    for (_, size, mtime) in &paths {
494        bytes += size;
495        if let Some(m) = mtime {
496            if newest.map_or(true, |n| *m > n) {
497                newest = Some(*m);
498            }
499        }
500    }
501    ArtifactInfo {
502        paths,
503        bytes,
504        newest,
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    /// Verbatim sample from the live shared cache (1.97.1 generation).
513    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}"#;
514
515    #[test]
516    fn reads_and_parses_synthetic_unit() -> Result<()> {
517        let root = tempfile::tempdir()?;
518        let unit_dir = root
519            .path()
520            .join("debug/.fingerprint/serde-102a33f055a8d229");
521        fs::create_dir_all(&unit_dir)?;
522        // memo file: 16 hex chars, no trailing newline (verbatim format)
523        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
524        fs::write(unit_dir.join("lib-serde.json"), SAMPLE_JSON)?;
525        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 69])?;
526        fs::write(unit_dir.join("invoked.timestamp"), "")?;
527        fs::create_dir_all(root.path().join("debug/deps"))?;
528        fs::write(
529            root.path()
530                .join("debug/deps/libserde-102a33f055a8d229.rlib"),
531            [0u8; 10],
532        )?;
533
534        let units = read_units(root.path())?;
535        assert_eq!(units.len(), 1);
536
537        let unit = &units[0];
538        assert_eq!(unit.package, "serde");
539        assert_eq!(unit.dir_suffix, "102a33f055a8d229");
540        assert_eq!(unit.profile, "debug");
541        assert_eq!(unit.kind, UnitKind::Lib);
542        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
543        assert!(unit.dep_info.is_some());
544
545        let json = unit.json.as_ref().expect("sample json must parse");
546        assert_eq!(json.rustc, "12019306335353385202");
547        assert!(json.features.contains(&"derive".to_string()));
548        assert!(json.declared_features.contains(&"unstable".to_string()));
549        assert_eq!(json.deps.len(), 3);
550        assert_eq!(json.deps[1].name, "serde_core");
551        assert_eq!(json.dep_info_paths.len(), 1);
552
553        // Suffix-matching artifact attach.
554        let arts = artifacts_for(root.path(), unit);
555        assert_eq!(arts.paths.len(), 1);
556        assert_eq!(arts.bytes, 10);
557        assert!(arts.newest.is_some());
558        Ok(())
559    }
560
561    #[test]
562    fn run_units_attach_their_build_tree() -> Result<()> {
563        let root = tempfile::tempdir()?;
564        let unit_dir = root.path().join("debug/.fingerprint/x-1122334455667788");
565        fs::create_dir_all(&unit_dir)?;
566        fs::write(unit_dir.join("build-script-build"), "aaaabbbbccccdddd")?;
567        fs::create_dir_all(root.path().join("debug/build/1122334455667788"))?;
568        fs::write(
569            root.path().join("debug/build/1122334455667788/output"),
570            [0u8; 4],
571        )?;
572        fs::write(
573            root.path().join("debug/build/1122334455667788/stderr"),
574            [0u8; 6],
575        )?;
576
577        let units = read_units(root.path())?;
578        assert_eq!(units.len(), 1);
579        let unit = &units[0];
580        assert_eq!(unit.kind, UnitKind::BuildScriptCompile);
581        let arts = artifacts_for(root.path(), unit);
582        assert_eq!(arts.paths.len(), 2, "output + stderr attached");
583        assert_eq!(arts.bytes, 10);
584        assert!(arts.newest.is_some());
585        Ok(())
586    }
587
588    #[test]
589    fn cached_diagnostics_are_not_mistaken_for_the_memo() -> Result<()> {
590        let root = tempfile::tempdir()?;
591        let unit_dir = root
592            .path()
593            .join("debug/.fingerprint/serde-102a33f055a8d229");
594        fs::create_dir_all(&unit_dir)?;
595        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
596        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 8])?;
597        // Cargo's message cache: rendered diagnostics live in the same dir.
598        fs::write(
599            unit_dir.join("output-lib-serde"),
600            r#"{"$message_type":"diagnostic","message":"unused import"}"#,
601        )?;
602
603        let units = read_units(root.path())?;
604        assert_eq!(units.len(), 1);
605        let unit = &units[0];
606        assert_eq!(unit.memo_file, "lib-serde", "the hash file wins");
607        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
608        assert_eq!(unit.extra_files, vec!["output-lib-serde".to_string()]);
609        Ok(())
610    }
611
612    #[test]
613    fn dep_hashes_resolve_to_memo_hashes_little_endian() {
614        // Example: serde's fingerprint lists serde_core as 9416646317397223793,
615        // and serde_core-b468be6fa8c1f936/lib-serde_core contains 71a9f323efa5ae82.
616        let dep = DepRef {
617            name: "serde_core".into(),
618            unit_hash: "9416646317397223793".into(),
619        };
620        assert_eq!(dep.memo_hash().as_deref(), Some("71a9f323efa5ae82"));
621
622        let junk = DepRef {
623            name: "x".into(),
624            unit_hash: "not-a-number".into(),
625        };
626        assert!(junk.memo_hash().is_none());
627    }
628
629    #[test]
630    fn dep_edges_join_the_graph_by_memo_hash() -> Result<()> {
631        let root = tempfile::tempdir()?;
632        let core = root
633            .path()
634            .join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
635        fs::create_dir_all(&core)?;
636        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
637        fs::create_dir_all(root.path().join("debug/deps"))?;
638        fs::write(
639            root.path()
640                .join("debug/deps/libserde_core-b468be6fa8c1f936.rlib"),
641            [0u8; 4],
642        )?;
643
644        let serde = root
645            .path()
646            .join("debug/.fingerprint/serde-746d173581f1a314");
647        fs::create_dir_all(&serde)?;
648        fs::write(serde.join("lib-serde"), "5d82713a7f2d05e2")?;
649        fs::write(
650            serde.join("lib-serde.json"),
651            r#"{"deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
652        )?;
653        fs::write(
654            root.path()
655                .join("debug/deps/libserde-746d173581f1a314.rlib"),
656            [0u8; 4],
657        )?;
658
659        let units = read_units(root.path())?;
660        assert_eq!(units.len(), 2);
661        let edges = dep_edges(&units);
662        let serde_at = units.iter().position(|u| u.package == "serde").unwrap();
663        let core_at = units
664            .iter()
665            .position(|u| u.package == "serde_core")
666            .unwrap();
667        assert_eq!(edges[serde_at], vec![core_at]);
668        assert!(edges[core_at].is_empty());
669        Ok(())
670    }
671
672    #[test]
673    fn pick_memo_prefers_hash_content_and_falls_back() {
674        let candidates = vec![
675            (
676                "output-lib-x".to_string(),
677                r#"{"message":"warning"}"#.to_string(),
678            ),
679            ("lib-x".to_string(), "5d82713a7f2d05e2".to_string()),
680        ];
681        let ((name, hash), extras) = pick_memo(candidates).expect("a memo exists");
682        assert_eq!(name, "lib-x");
683        assert_eq!(hash, "5d82713a7f2d05e2");
684        assert_eq!(extras, vec!["output-lib-x".to_string()]);
685
686        // Nothing hash-shaped: the first candidate is the fallback, so a
687        // corrupted unit still shows up in forensics instead of vanishing.
688        let candidates = vec![("weird".to_string(), "not a hash".to_string())];
689        let ((name, _), extras) = pick_memo(candidates).expect("fallback");
690        assert_eq!(name, "weird");
691        assert!(extras.is_empty());
692
693        assert!(pick_memo(Vec::new()).is_none());
694    }
695}