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() || fp_meta.file_type().is_symlink() {
247 continue; }
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 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
266pub 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
277pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
279 index.get(dep.memo_hash()?.as_str()).copied()
280}
281
282pub 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(); 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 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
367fn 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
385pub(crate) fn looks_like_hash(text: &str) -> bool {
387 text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
388}
389
390pub(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
406pub 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 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 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 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 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 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 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 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 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}