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.get("features").map(parse_string_array).unwrap_or_default(),
201 declared_features: json
202 .get("declared_features")
203 .map(parse_string_array)
204 .unwrap_or_default(),
205 deps,
206 target: parse_u64_field(json, "target"),
207 profile: parse_u64_field(json, "profile"),
208 path: parse_u64_field(json, "path"),
209 config: parse_u64_field(json, "config"),
210 compile_kind: parse_u64_field(json, "compile_kind"),
211 rustflags: json
212 .get("rustflags")
213 .map(parse_string_array)
214 .unwrap_or_default(),
215 dep_info_paths,
216 }
217}
218
219pub fn parse_fingerprint(text: &str) -> Option<FingerprintJson> {
222 serde_json::from_str::<Value>(text)
223 .ok()
224 .map(|value| parse_fingerprint_json(&value))
225}
226
227pub fn read_units(build_root: &Path) -> Result<Vec<Unit>> {
229 let mut units = Vec::new();
230 for profile_entry in fs::read_dir(build_root).with_context(|| {
231 format!("reading build dir {}", build_root.display())
232 })? {
233 let profile_entry = profile_entry?;
234 if !profile_entry.file_type()?.is_dir() {
235 continue;
236 }
237 let profile = profile_entry.file_name().to_string_lossy().into_owned();
238 let fp_root = build_root.join(&profile).join(".fingerprint");
239 if !fp_root.is_dir() {
240 continue; }
242 for unit_entry in fs::read_dir(&fp_root).with_context(|| {
243 format!("reading {}", fp_root.display())
244 })? {
245 let unit_dir = unit_entry?.path();
246 if !unit_dir.is_dir() {
247 continue;
248 }
249 if let Some(unit) = read_unit_dir(&profile, &unit_dir) {
250 units.push(unit);
251 }
252 }
253 }
254 Ok(units)
255}
256
257pub fn memo_index(units: &[Unit]) -> HashMap<String, usize> {
259 let mut index = HashMap::new();
260 for (position, unit) in units.iter().enumerate() {
261 if let Some(hash) = unit.memo_hash.as_deref() {
262 index.entry(hash.to_string()).or_insert(position);
263 }
264 }
265 index
266}
267
268pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
270 index.get(dep.memo_hash()?.as_str()).copied()
271}
272
273pub fn dep_edges(units: &[Unit]) -> Vec<Vec<usize>> {
279 let index = memo_index(units);
280 units
281 .iter()
282 .map(|unit| {
283 unit.json
284 .as_ref()
285 .map(|json| {
286 json.deps
287 .iter()
288 .filter_map(|dep| resolve_dep(&index, dep))
289 .collect()
290 })
291 .unwrap_or_default()
292 })
293 .collect()
294}
295
296fn read_unit_dir(profile: &str, unit_dir: &Path) -> Option<Unit> {
297 let dir_name = unit_dir.file_name()?.to_string_lossy().into_owned();
298 let (package, dir_suffix) = split_dir_name(&dir_name)?;
299
300 let mut candidates: Vec<(String, String)> = Vec::new(); let mut dep_info: Option<PathBuf> = None;
302 let mut json_path: Option<PathBuf> = None;
303
304 for entry in fs::read_dir(unit_dir).ok()? {
305 let entry = entry.ok()?;
306 let name = entry.file_name().to_string_lossy().into_owned();
307 if name.starts_with("dep-") {
308 dep_info = Some(entry.path());
309 continue;
310 }
311 if name.ends_with(".json") {
312 json_path = Some(entry.path());
313 continue;
314 }
315 if name == "invoked.timestamp" {
316 continue;
317 }
318 let content = fs::read_to_string(entry.path()).ok()?.trim().to_string();
319 candidates.push((name, content));
320 }
321
322 let ((memo_file_name, memo_hash), extra_files) = pick_memo(candidates)?;
323 let kind = classify_kind(&memo_file_name);
324
325 let json = json_path
326 .and_then(|path| fs::read_to_string(path).ok())
327 .and_then(|text| parse_fingerprint(&text));
328
329 Some(Unit {
330 dir_name,
331 package,
332 dir_suffix,
333 profile: profile.to_string(),
334 kind,
335 memo_file: memo_file_name,
336 memo_hash: Some(memo_hash),
337 json,
338 dep_info,
339 extra_files,
340 })
341}
342
343fn pick_memo(
349 mut candidates: Vec<(String, String)>,
350) -> Option<((String, String), Vec<String>)> {
351 if candidates.is_empty() {
352 return None;
353 }
354 let index = candidates
355 .iter()
356 .position(|(_, content)| looks_like_hash(content))
357 .unwrap_or(0);
358 let (name, hash) = candidates.remove(index);
359 let extras = candidates.into_iter().map(|(name, _)| name).collect();
360 Some(((name, hash), extras))
361}
362
363pub(crate) fn looks_like_hash(text: &str) -> bool {
365 text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
366}
367
368pub(crate) fn build_anchor_dirs(build_root: &Path, unit: &Unit) -> Vec<PathBuf> {
372 let build_dir = build_root.join(&unit.profile).join("build");
373 let pkg_plain = &unit.package;
374 let pkg_underscored = unit.package.replace('-', "_");
375 let mut candidates = Vec::new();
376 candidates.push(build_dir.join(format!("{pkg_underscored}-{}", unit.dir_suffix)));
377 if pkg_plain != &pkg_underscored {
378 candidates.push(build_dir.join(format!("{pkg_plain}-{}", unit.dir_suffix)));
379 }
380 candidates.push(build_dir.join(&unit.dir_suffix));
381 candidates
382}
383
384pub struct ArtifactInfo {
389 pub paths: Vec<(PathBuf, u64, Option<SystemTime>)>,
390 pub bytes: u64,
391 pub newest: Option<SystemTime>,
392}
393
394pub fn artifacts_for(build_root: &Path, unit: &Unit) -> ArtifactInfo {
395 let profile_dir = build_root.join(&unit.profile);
396 let mut paths = Vec::new();
397
398 let deps_dir = profile_dir.join("deps");
399 if let Ok(entries) = fs::read_dir(&deps_dir) {
400 let suffix = format!("-{}", unit.dir_suffix);
401 for entry in entries.flatten() {
402 let name = entry.file_name().to_string_lossy().into_owned();
403 let stem = Path::new(&name)
406 .file_stem()
407 .map(|s| s.to_string_lossy().into_owned())
408 .unwrap_or(name.clone());
409 if !stem.ends_with(&suffix) {
410 continue;
411 }
412 if let Ok(meta) = entry.metadata() {
413 paths.push((
414 entry.path(),
415 meta.len(),
416 meta.modified().ok(),
417 ));
418 }
419 }
420 }
421
422 if unit.kind != UnitKind::Lib {
423 for candidate in build_anchor_dirs(build_root, unit) {
424 if !candidate.is_dir() {
425 continue;
426 }
427 for entry in WalkDir::new(&candidate).min_depth(1) {
428 let Ok(entry) = entry else { continue };
429 if !entry.file_type().is_file() {
430 continue;
431 }
432 if let Ok(meta) = entry.metadata() {
433 paths.push((entry.path().into(), meta.len(), meta.modified().ok()));
434 }
435 }
436 }
437 }
438
439 let mut bytes = 0;
440 let mut newest: Option<SystemTime> = None;
441 for (_, size, mtime) in &paths {
442 bytes += size;
443 if let Some(m) = mtime {
444 if newest.map_or(true, |n| *m > n) {
445 newest = Some(*m);
446 }
447 }
448 }
449 ArtifactInfo { paths, bytes, newest }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 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}"#;
458
459 #[test]
460 fn reads_and_parses_synthetic_unit() -> Result<()> {
461 let root = tempfile::tempdir()?;
462 let unit_dir = root.path().join("debug/.fingerprint/serde-102a33f055a8d229");
463 fs::create_dir_all(&unit_dir)?;
464 fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
466 fs::write(unit_dir.join("lib-serde.json"), SAMPLE_JSON)?;
467 fs::write(unit_dir.join("dep-lib-serde"), [0u8; 69])?;
468 fs::write(unit_dir.join("invoked.timestamp"), "")?;
469 fs::create_dir_all(root.path().join("debug/deps"))?;
470 fs::write(
471 root.path().join("debug/deps/libserde-102a33f055a8d229.rlib"),
472 [0u8; 10],
473 )?;
474
475 let units = read_units(root.path())?;
476 assert_eq!(units.len(), 1);
477
478 let unit = &units[0];
479 assert_eq!(unit.package, "serde");
480 assert_eq!(unit.dir_suffix, "102a33f055a8d229");
481 assert_eq!(unit.profile, "debug");
482 assert_eq!(unit.kind, UnitKind::Lib);
483 assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
484 assert!(unit.dep_info.is_some());
485
486 let json = unit.json.as_ref().expect("sample json must parse");
487 assert_eq!(json.rustc, "12019306335353385202");
488 assert!(json.features.contains(&"derive".to_string()));
489 assert!(json.declared_features.contains(&"unstable".to_string()));
490 assert_eq!(json.deps.len(), 3);
491 assert_eq!(json.deps[1].name, "serde_core");
492 assert_eq!(json.dep_info_paths.len(), 1);
493
494 let arts = artifacts_for(root.path(), unit);
496 assert_eq!(arts.paths.len(), 1);
497 assert_eq!(arts.bytes, 10);
498 assert!(arts.newest.is_some());
499 Ok(())
500 }
501
502 #[test]
503 fn run_units_attach_their_build_tree() -> Result<()> {
504 let root = tempfile::tempdir()?;
505 let unit_dir = root.path().join("debug/.fingerprint/x-1122334455667788");
506 fs::create_dir_all(&unit_dir)?;
507 fs::write(unit_dir.join("build-script-build"), "aaaabbbbccccdddd")?;
508 fs::create_dir_all(root.path().join("debug/build/1122334455667788"))?;
509 fs::write(
510 root.path().join("debug/build/1122334455667788/output"),
511 [0u8; 4],
512 )?;
513 fs::write(
514 root.path().join("debug/build/1122334455667788/stderr"),
515 [0u8; 6],
516 )?;
517
518 let units = read_units(root.path())?;
519 assert_eq!(units.len(), 1);
520 let unit = &units[0];
521 assert_eq!(unit.kind, UnitKind::BuildScriptCompile);
522 let arts = artifacts_for(root.path(), unit);
523 assert_eq!(arts.paths.len(), 2, "output + stderr attached");
524 assert_eq!(arts.bytes, 10);
525 assert!(arts.newest.is_some());
526 Ok(())
527 }
528
529 #[test]
530 fn cached_diagnostics_are_not_mistaken_for_the_memo() -> Result<()> {
531 let root = tempfile::tempdir()?;
532 let unit_dir = root.path().join("debug/.fingerprint/serde-102a33f055a8d229");
533 fs::create_dir_all(&unit_dir)?;
534 fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
535 fs::write(unit_dir.join("dep-lib-serde"), [0u8; 8])?;
536 fs::write(
538 unit_dir.join("output-lib-serde"),
539 r#"{"$message_type":"diagnostic","message":"unused import"}"#,
540 )?;
541
542 let units = read_units(root.path())?;
543 assert_eq!(units.len(), 1);
544 let unit = &units[0];
545 assert_eq!(unit.memo_file, "lib-serde", "the hash file wins");
546 assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
547 assert_eq!(unit.extra_files, vec!["output-lib-serde".to_string()]);
548 Ok(())
549 }
550
551 #[test]
552 fn dep_hashes_resolve_to_memo_hashes_little_endian() {
553 let dep = DepRef {
556 name: "serde_core".into(),
557 unit_hash: "9416646317397223793".into(),
558 };
559 assert_eq!(dep.memo_hash().as_deref(), Some("71a9f323efa5ae82"));
560
561 let junk = DepRef {
562 name: "x".into(),
563 unit_hash: "not-a-number".into(),
564 };
565 assert!(junk.memo_hash().is_none());
566 }
567
568 #[test]
569 fn dep_edges_join_the_graph_by_memo_hash() -> Result<()> {
570 let root = tempfile::tempdir()?;
571 let core = root.path().join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
572 fs::create_dir_all(&core)?;
573 fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
574 fs::create_dir_all(root.path().join("debug/deps"))?;
575 fs::write(
576 root.path().join("debug/deps/libserde_core-b468be6fa8c1f936.rlib"),
577 [0u8; 4],
578 )?;
579
580 let serde = root.path().join("debug/.fingerprint/serde-746d173581f1a314");
581 fs::create_dir_all(&serde)?;
582 fs::write(serde.join("lib-serde"), "5d82713a7f2d05e2")?;
583 fs::write(
584 serde.join("lib-serde.json"),
585 r#"{"deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
586 )?;
587 fs::write(
588 root.path().join("debug/deps/libserde-746d173581f1a314.rlib"),
589 [0u8; 4],
590 )?;
591
592 let units = read_units(root.path())?;
593 assert_eq!(units.len(), 2);
594 let edges = dep_edges(&units);
595 let serde_at = units.iter().position(|u| u.package == "serde").unwrap();
596 let core_at = units.iter().position(|u| u.package == "serde_core").unwrap();
597 assert_eq!(edges[serde_at], vec![core_at]);
598 assert!(edges[core_at].is_empty());
599 Ok(())
600 }
601
602 #[test]
603 fn pick_memo_prefers_hash_content_and_falls_back() {
604 let candidates = vec![
605 (
606 "output-lib-x".to_string(),
607 r#"{"message":"warning"}"#.to_string(),
608 ),
609 ("lib-x".to_string(), "5d82713a7f2d05e2".to_string()),
610 ];
611 let ((name, hash), extras) = pick_memo(candidates).expect("a memo exists");
612 assert_eq!(name, "lib-x");
613 assert_eq!(hash, "5d82713a7f2d05e2");
614 assert_eq!(extras, vec!["output-lib-x".to_string()]);
615
616 let candidates = vec![("weird".to_string(), "not a hash".to_string())];
619 let ((name, _), extras) = pick_memo(candidates).expect("fallback");
620 assert_eq!(name, "weird");
621 assert!(extras.is_empty());
622
623 assert!(pick_memo(Vec::new()).is_none());
624 }
625}