1use 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#[derive(Debug, Clone)]
52pub struct Unit {
53 pub dir_name: String,
55 pub package: String,
57 pub dir_suffix: String,
59 pub profile: String,
61 pub kind: UnitKind,
62 pub memo_file: String,
64 pub memo_hash: Option<String>,
66 pub json: Option<FingerprintJson>,
67 pub dep_info: Option<PathBuf>,
68 pub extra_files: Vec<String>,
70}
71
72#[derive(Debug, Clone, Default)]
74pub struct FingerprintJson {
75 pub rustc: String,
77 pub features: Vec<String>,
78 pub declared_features: Vec<String>,
79 pub deps: Vec<DepRef>,
81 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 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 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
118fn 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
124pub 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
137fn 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
222pub 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
230pub 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; }
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 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
269pub 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
280pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
282 index.get(dep.memo_hash()?.as_str()).copied()
283}
284
285pub 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(); 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 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
374fn 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
392pub(crate) fn looks_like_hash(text: &str) -> bool {
394 text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
395}
396
397pub(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
413pub 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 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 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 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 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 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 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 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 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}