1use std::collections::HashMap;
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::time::SystemTime;
39
40use anyhow::{Context, Result};
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43use walkdir::WalkDir;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum UnitKind {
47 Lib,
48 BuildScriptCompile,
49 BuildScriptRun,
50 Other,
51}
52
53impl UnitKind {
54 pub fn label(self) -> &'static str {
55 match self {
56 UnitKind::Lib => "lib",
57 UnitKind::BuildScriptCompile => "build.rs",
58 UnitKind::BuildScriptRun => "build-run",
59 UnitKind::Other => "other",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum UnitLayout {
68 Classic,
71 New,
74}
75
76impl UnitLayout {
77 pub fn label(self) -> &'static str {
78 match self {
79 UnitLayout::Classic => "classic",
80 UnitLayout::New => "new",
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
93pub struct NewUnitLayout {
94 profile: String,
96 package: String,
98 hash: String,
100}
101
102impl NewUnitLayout {
103 pub fn new(profile: &str, package: &str, hash: &str) -> Self {
104 Self {
105 profile: profile.to_string(),
106 package: package.to_string(),
107 hash: hash.to_string(),
108 }
109 }
110
111 pub fn profile(&self) -> &str {
112 &self.profile
113 }
114
115 pub fn package(&self) -> &str {
116 &self.package
117 }
118
119 pub fn hash(&self) -> &str {
120 &self.hash
121 }
122
123 pub fn unit_dir(&self, build_root: &Path) -> PathBuf {
125 build_root
126 .join(&self.profile)
127 .join("build")
128 .join(&self.package)
129 .join(&self.hash)
130 }
131
132 pub fn fingerprint_dir(&self, build_root: &Path) -> PathBuf {
134 self.unit_dir(build_root).join("fingerprint")
135 }
136
137 pub fn out_dir(&self, build_root: &Path) -> PathBuf {
139 self.unit_dir(build_root).join("out")
140 }
141}
142
143pub fn layout_for_cargo(version: &str, args: &[String]) -> UnitLayout {
153 let Some((major, minor)) = parse_cargo_version(version) else {
154 return UnitLayout::Classic;
155 };
156 let nightly = version.contains("-nightly");
157 if major > 1 || (major == 1 && minor >= 100) {
158 return UnitLayout::New;
159 }
160 if nightly && minor >= 99 {
161 return UnitLayout::New;
162 }
163 if nightly && has_new_layout_flag(args) {
164 return UnitLayout::New;
165 }
166 UnitLayout::Classic
167}
168
169pub fn parse_cargo_version(text: &str) -> Option<(u64, u64)> {
171 let token = text
172 .split_whitespace()
173 .find(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
174 let mut parts = token.split('.');
175 let major = parts.next()?.parse().ok()?;
176 let minor = parts.next()?.parse().ok()?;
177 Some((major, minor))
178}
179
180pub fn has_new_layout_flag(args: &[String]) -> bool {
182 let mut iter = args.iter();
183 while let Some(arg) = iter.next() {
184 if arg == "--" {
185 break;
186 }
187 if arg == "-Z" {
188 if let Some(next) = iter.next() {
189 if next == "build-dir-new-layout" || next.starts_with("build-dir-new-layout=") {
190 return true;
191 }
192 }
193 } else if let Some(rest) = arg.strip_prefix("-Z") {
194 if rest == "build-dir-new-layout" || rest.starts_with("build-dir-new-layout=") {
195 return true;
196 }
197 }
198 }
199 false
200}
201
202#[derive(Debug, Clone)]
204pub struct Unit {
205 pub dir_name: String,
207 pub package: String,
209 pub dir_suffix: String,
211 pub profile: String,
213 pub kind: UnitKind,
214 pub layout: UnitLayout,
216 pub fingerprint_dir: PathBuf,
219 pub unit_dir: PathBuf,
222 pub memo_file: String,
224 pub memo_hash: Option<String>,
226 pub json: Option<FingerprintJson>,
227 pub dep_info: Option<PathBuf>,
228 pub extra_files: Vec<String>,
230}
231
232impl Unit {
233 pub fn fingerprint_rel(&self) -> String {
235 match self.layout {
236 UnitLayout::Classic => format!(".fingerprint/{}", self.dir_name),
237 UnitLayout::New => {
238 format!("build/{}/{}/fingerprint", self.package, self.dir_suffix)
239 }
240 }
241 }
242}
243
244#[derive(Debug, Clone, Default)]
246pub struct FingerprintJson {
247 pub rustc: String,
249 pub features: Vec<String>,
250 pub declared_features: Vec<String>,
251 pub deps: Vec<DepRef>,
253 pub target: String,
255 pub profile: String,
256 pub path: String,
257 pub config: String,
258 pub compile_kind: String,
259 pub rustflags: Vec<String>,
260 pub dep_info_paths: Vec<String>,
262}
263
264#[derive(Debug, Clone)]
265pub struct DepRef {
266 pub name: String,
267 pub unit_hash: String,
268}
269
270impl DepRef {
271 pub fn memo_hash(&self) -> Option<String> {
281 let value: u64 = self.unit_hash.parse().ok()?;
282 let mut out = String::with_capacity(16);
283 for byte in value.to_le_bytes() {
284 out.push_str(&format!("{byte:02x}"));
285 }
286 Some(out)
287 }
288}
289
290fn split_dir_name(dir_name: &str) -> Option<(String, String)> {
292 let (pkg, hex) = dir_name.rsplit_once('-')?;
293 Some((pkg.to_string(), hex.to_string()))
294}
295
296pub fn classify_kind(memo_file: &str) -> UnitKind {
298 if memo_file.starts_with("lib") {
299 UnitKind::Lib
300 } else if memo_file.starts_with("run") {
301 UnitKind::BuildScriptRun
302 } else if memo_file.contains("build-script") || memo_file.starts_with("build") {
303 UnitKind::BuildScriptCompile
304 } else {
305 UnitKind::Other
306 }
307}
308
309fn parse_string_array(value: &Value) -> Vec<String> {
311 match value {
312 Value::Array(items) => items
313 .iter()
314 .filter_map(|v| v.as_str().map(str::to_string))
315 .collect(),
316 Value::String(text) => serde_json::from_str::<Vec<String>>(text)
317 .unwrap_or_else(|_| text.split_whitespace().map(str::to_string).collect()),
318 _ => Vec::new(),
319 }
320}
321
322fn parse_u64_field(json: &Value, key: &str) -> String {
323 match json.get(key) {
324 Some(Value::Number(n)) => n.to_string(),
325 Some(Value::String(s)) => s.clone(),
326 Some(other) if !other.is_null() => other.to_string(),
327 _ => String::new(),
328 }
329}
330
331fn parse_fingerprint_json(json: &Value) -> FingerprintJson {
332 let deps = json
333 .get("deps")
334 .and_then(Value::as_array)
335 .map(|items| {
336 items
337 .iter()
338 .filter_map(|item| {
339 let arr = item.as_array()?;
340 let unit_hash = match arr.get(3)? {
341 Value::Number(number) => number.to_string(),
342 Value::String(text) => text.clone(),
343 _ => return None,
344 };
345 Some(DepRef {
346 name: arr.get(1)?.as_str()?.to_string(),
347 unit_hash,
348 })
349 })
350 .collect()
351 })
352 .unwrap_or_default();
353
354 let dep_info_paths = json
355 .get("local")
356 .and_then(Value::as_array)
357 .map(|items| {
358 items
359 .iter()
360 .filter_map(|item| {
361 item.get("CheckDepInfo")?
362 .get("dep_info")?
363 .as_str()
364 .map(str::to_string)
365 })
366 .collect()
367 })
368 .unwrap_or_default();
369
370 FingerprintJson {
371 rustc: parse_u64_field(json, "rustc"),
372 features: json
373 .get("features")
374 .map(parse_string_array)
375 .unwrap_or_default(),
376 declared_features: json
377 .get("declared_features")
378 .map(parse_string_array)
379 .unwrap_or_default(),
380 deps,
381 target: parse_u64_field(json, "target"),
382 profile: parse_u64_field(json, "profile"),
383 path: parse_u64_field(json, "path"),
384 config: parse_u64_field(json, "config"),
385 compile_kind: parse_u64_field(json, "compile_kind"),
386 rustflags: json
387 .get("rustflags")
388 .map(parse_string_array)
389 .unwrap_or_default(),
390 dep_info_paths,
391 }
392}
393
394pub fn parse_fingerprint(text: &str) -> Option<FingerprintJson> {
397 serde_json::from_str::<Value>(text)
398 .ok()
399 .map(|value| parse_fingerprint_json(&value))
400}
401
402pub fn read_units(build_root: &Path) -> Result<Vec<Unit>> {
407 let mut units = Vec::new();
408 for profile_entry in fs::read_dir(build_root)
409 .with_context(|| format!("reading build dir {}", build_root.display()))?
410 {
411 let profile_entry = profile_entry?;
412 if !profile_entry.file_type()?.is_dir() {
413 continue;
414 }
415 let profile = profile_entry.file_name().to_string_lossy().into_owned();
416 let profile_dir = profile_entry.path();
417
418 let fp_root = profile_dir.join(".fingerprint");
420 if is_real_dir(&fp_root) {
421 for unit_entry in
422 fs::read_dir(&fp_root).with_context(|| format!("reading {}", fp_root.display()))?
423 {
424 let unit_entry = unit_entry?;
425 if !unit_entry.file_type()?.is_dir() {
426 continue;
427 }
428 if let Some(unit) = read_classic_unit(&profile, &unit_entry.path()) {
429 units.push(unit);
430 }
431 }
432 }
433
434 let build_dir = profile_dir.join("build");
436 if is_real_dir(&build_dir) {
437 for package_entry in fs::read_dir(&build_dir)
438 .with_context(|| format!("reading {}", build_dir.display()))?
439 {
440 let package_entry = package_entry?;
441 if !package_entry.file_type()?.is_dir() {
442 continue;
443 }
444 let package = package_entry.file_name().to_string_lossy().into_owned();
445 for hash_entry in fs::read_dir(package_entry.path())
446 .with_context(|| format!("reading {}", package_entry.path().display()))?
447 {
448 let hash_entry = hash_entry?;
449 if !hash_entry.file_type()?.is_dir() {
450 continue;
451 }
452 let hash = hash_entry.file_name().to_string_lossy().into_owned();
453 if !looks_like_hash(&hash) {
454 continue;
455 }
456 if !is_real_dir(&hash_entry.path().join("fingerprint")) {
457 continue;
458 }
459 if let Some(unit) = read_new_unit(build_root, &profile, &package, &hash) {
460 units.push(unit);
461 }
462 }
463 }
464 }
465 }
466 Ok(units)
467}
468
469pub fn memo_index(units: &[Unit]) -> HashMap<String, usize> {
471 let mut index = HashMap::new();
472 for (position, unit) in units.iter().enumerate() {
473 if let Some(hash) = unit.memo_hash.as_deref() {
474 index.entry(hash.to_string()).or_insert(position);
475 }
476 }
477 index
478}
479
480pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
482 index.get(dep.memo_hash()?.as_str()).copied()
483}
484
485pub fn dep_edges(units: &[Unit]) -> Vec<Vec<usize>> {
491 let index = memo_index(units);
492 units
493 .iter()
494 .map(|unit| {
495 unit.json
496 .as_ref()
497 .map(|json| {
498 json.deps
499 .iter()
500 .filter_map(|dep| resolve_dep(&index, dep))
501 .collect()
502 })
503 .unwrap_or_default()
504 })
505 .collect()
506}
507
508fn is_real_dir(path: &Path) -> bool {
510 fs::symlink_metadata(path)
511 .map(|meta| {
512 meta.is_dir() && !meta.file_type().is_symlink() && !crate::util::is_reparse_point(&meta)
513 })
514 .unwrap_or(false)
515}
516
517fn read_classic_unit(profile: &str, unit_dir: &Path) -> Option<Unit> {
518 let dir_name = unit_dir.file_name()?.to_string_lossy().into_owned();
519 let (package, dir_suffix) = split_dir_name(&dir_name)?;
520 let parsed = parse_unit_files(unit_dir)?;
521 Some(Unit {
522 dir_name,
523 package,
524 dir_suffix,
525 profile: profile.to_string(),
526 kind: parsed.kind,
527 layout: UnitLayout::Classic,
528 fingerprint_dir: unit_dir.to_path_buf(),
529 unit_dir: unit_dir.to_path_buf(),
530 memo_file: parsed.memo_file,
531 memo_hash: Some(parsed.memo_hash),
532 json: parsed.json,
533 dep_info: parsed.dep_info,
534 extra_files: parsed.extra_files,
535 })
536}
537
538fn read_new_unit(build_root: &Path, profile: &str, package: &str, hash: &str) -> Option<Unit> {
539 let layout = NewUnitLayout::new(profile, package, hash);
540 let fingerprint_dir = layout.fingerprint_dir(build_root);
541 let parsed = parse_unit_files(&fingerprint_dir)?;
542 Some(Unit {
543 dir_name: format!("{package}-{hash}"),
544 package: package.to_string(),
545 dir_suffix: hash.to_string(),
546 profile: profile.to_string(),
547 kind: parsed.kind,
548 layout: UnitLayout::New,
549 unit_dir: layout.unit_dir(build_root),
550 fingerprint_dir,
551 memo_file: parsed.memo_file,
552 memo_hash: Some(parsed.memo_hash),
553 json: parsed.json,
554 dep_info: parsed.dep_info,
555 extra_files: parsed.extra_files,
556 })
557}
558
559struct ParsedUnit {
560 kind: UnitKind,
561 memo_file: String,
562 memo_hash: String,
563 json: Option<FingerprintJson>,
564 dep_info: Option<PathBuf>,
565 extra_files: Vec<String>,
566}
567
568fn parse_unit_files(unit_dir: &Path) -> Option<ParsedUnit> {
570 let mut candidates: Vec<(String, String)> = Vec::new(); let mut dep_info: Option<PathBuf> = None;
572 let mut json_path: Option<PathBuf> = None;
573
574 for entry in fs::read_dir(unit_dir).ok()? {
575 let entry = entry.ok()?;
576 let entry_meta = fs::symlink_metadata(entry.path()).ok()?;
579 if !entry_meta.is_file()
580 || entry_meta.file_type().is_symlink()
581 || crate::util::is_reparse_point(&entry_meta)
582 {
583 continue;
584 }
585 let name = entry.file_name().to_string_lossy().into_owned();
586 if name.starts_with("dep-") {
587 dep_info = Some(entry.path());
588 continue;
589 }
590 if name.ends_with(".json") {
591 json_path = Some(entry.path());
592 continue;
593 }
594 if name == "invoked.timestamp" {
595 continue;
596 }
597 if entry
598 .metadata()
599 .map(|meta| meta.len() > 4 * 1024 * 1024)
600 .unwrap_or(true)
601 {
602 continue;
603 }
604 let content = crate::util::read_small_text(&entry.path(), 4 * 1024 * 1024)
605 .ok()?
606 .trim()
607 .to_string();
608 candidates.push((name, content));
609 }
610
611 let ((memo_file, memo_hash), extra_files) = pick_memo(candidates)?;
612 let kind = classify_kind(&memo_file);
613 let json = json_path
614 .and_then(|path| crate::util::read_small_text(&path, 4 * 1024 * 1024).ok())
615 .and_then(|text| parse_fingerprint(&text));
616 Some(ParsedUnit {
617 kind,
618 memo_file,
619 memo_hash,
620 json,
621 dep_info,
622 extra_files,
623 })
624}
625
626fn pick_memo(mut candidates: Vec<(String, String)>) -> Option<((String, String), Vec<String>)> {
632 if candidates.is_empty() {
633 return None;
634 }
635 let index = candidates
636 .iter()
637 .position(|(_, content)| looks_like_hash(content))
638 .unwrap_or(0);
639 let (name, hash) = candidates.remove(index);
640 let extras = candidates.into_iter().map(|(name, _)| name).collect();
641 Some(((name, hash), extras))
642}
643
644pub(crate) fn looks_like_hash(text: &str) -> bool {
646 text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
647}
648
649pub(crate) fn build_anchor_dirs(build_root: &Path, unit: &Unit) -> Vec<PathBuf> {
653 if unit.layout == UnitLayout::New {
654 return vec![unit.unit_dir.clone()];
655 }
656 let build_dir = build_root.join(&unit.profile).join("build");
657 let pkg_plain = &unit.package;
658 let pkg_underscored = unit.package.replace('-', "_");
659 let mut candidates = Vec::new();
660 candidates.push(build_dir.join(format!("{pkg_underscored}-{}", unit.dir_suffix)));
661 if pkg_plain != &pkg_underscored {
662 candidates.push(build_dir.join(format!("{pkg_plain}-{}", unit.dir_suffix)));
663 }
664 candidates.push(build_dir.join(&unit.dir_suffix));
665 candidates
666}
667
668pub struct ArtifactInfo {
673 pub paths: Vec<(PathBuf, u64, Option<SystemTime>)>,
674 pub bytes: u64,
675 pub newest: Option<SystemTime>,
676}
677
678pub fn artifacts_for(build_root: &Path, unit: &Unit) -> ArtifactInfo {
679 let mut paths = Vec::new();
680 match unit.layout {
681 UnitLayout::Classic => collect_classic_artifacts(build_root, unit, &mut paths),
682 UnitLayout::New => collect_new_artifacts(unit, &mut paths),
683 }
684
685 let mut bytes = 0;
686 let mut newest: Option<SystemTime> = None;
687 for (_, size, mtime) in &paths {
688 bytes += size;
689 if let Some(m) = mtime {
690 if newest.map_or(true, |n| *m > n) {
691 newest = Some(*m);
692 }
693 }
694 }
695 ArtifactInfo {
696 paths,
697 bytes,
698 newest,
699 }
700}
701
702fn collect_classic_artifacts(
703 build_root: &Path,
704 unit: &Unit,
705 paths: &mut Vec<(PathBuf, u64, Option<SystemTime>)>,
706) {
707 let profile_dir = build_root.join(&unit.profile);
708 let deps_dir = profile_dir.join("deps");
709 if is_real_dir(&deps_dir) {
710 let suffix = format!("-{}", unit.dir_suffix);
711 if let Ok(entries) = fs::read_dir(&deps_dir) {
712 for entry in entries.flatten() {
713 if !entry
715 .file_type()
716 .map(|kind| kind.is_file())
717 .unwrap_or(false)
718 {
719 continue;
720 }
721 let name = entry.file_name().to_string_lossy().into_owned();
722 let stem = Path::new(&name)
725 .file_stem()
726 .map(|s| s.to_string_lossy().into_owned())
727 .unwrap_or(name.clone());
728 if !stem.ends_with(&suffix) {
729 continue;
730 }
731 if let Ok(meta) = entry.metadata() {
732 paths.push((entry.path(), meta.len(), meta.modified().ok()));
733 }
734 }
735 }
736 }
737
738 if unit.kind != UnitKind::Lib {
739 for candidate in build_anchor_dirs(build_root, unit) {
740 if !is_real_dir(&candidate) {
741 continue;
742 }
743 for entry in WalkDir::new(&candidate).min_depth(1) {
744 let Ok(entry) = entry else { continue };
745 if !entry.file_type().is_file() {
746 continue;
747 }
748 if let Ok(meta) = entry.metadata() {
749 paths.push((entry.path().to_path_buf(), meta.len(), meta.modified().ok()));
750 }
751 }
752 }
753 }
754}
755
756fn collect_new_artifacts(unit: &Unit, paths: &mut Vec<(PathBuf, u64, Option<SystemTime>)>) {
761 for sub in ["out", "run"] {
762 let dir = unit.unit_dir.join(sub);
763 if !is_real_dir(&dir) {
764 continue;
765 }
766 for entry in WalkDir::new(&dir).min_depth(1) {
767 let Ok(entry) = entry else { continue };
768 if !entry.file_type().is_file() {
769 continue;
770 }
771 if let Ok(meta) = entry.metadata() {
772 paths.push((entry.path().to_path_buf(), meta.len(), meta.modified().ok()));
773 }
774 }
775 }
776 let Ok(entries) = fs::read_dir(&unit.unit_dir) else {
777 return;
778 };
779 for entry in entries.flatten() {
780 if !entry
781 .file_type()
782 .map(|kind| kind.is_file())
783 .unwrap_or(false)
784 {
785 continue;
786 }
787 if let Ok(meta) = entry.metadata() {
788 paths.push((entry.path(), meta.len(), meta.modified().ok()));
789 }
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 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}"#;
799
800 #[test]
801 fn reads_and_parses_synthetic_unit() -> Result<()> {
802 let root = tempfile::tempdir()?;
803 let unit_dir = root
804 .path()
805 .join("debug/.fingerprint/serde-102a33f055a8d229");
806 fs::create_dir_all(&unit_dir)?;
807 fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
809 fs::write(unit_dir.join("lib-serde.json"), SAMPLE_JSON)?;
810 fs::write(unit_dir.join("dep-lib-serde"), [0u8; 69])?;
811 fs::write(unit_dir.join("invoked.timestamp"), "")?;
812 fs::create_dir_all(root.path().join("debug/deps"))?;
813 fs::write(
814 root.path()
815 .join("debug/deps/libserde-102a33f055a8d229.rlib"),
816 [0u8; 10],
817 )?;
818
819 let units = read_units(root.path())?;
820 assert_eq!(units.len(), 1);
821
822 let unit = &units[0];
823 assert_eq!(unit.package, "serde");
824 assert_eq!(unit.dir_suffix, "102a33f055a8d229");
825 assert_eq!(unit.profile, "debug");
826 assert_eq!(unit.kind, UnitKind::Lib);
827 assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
828 assert!(unit.dep_info.is_some());
829
830 let json = unit.json.as_ref().expect("sample json must parse");
831 assert_eq!(json.rustc, "12019306335353385202");
832 assert!(json.features.contains(&"derive".to_string()));
833 assert!(json.declared_features.contains(&"unstable".to_string()));
834 assert_eq!(json.deps.len(), 3);
835 assert_eq!(json.deps[1].name, "serde_core");
836 assert_eq!(json.dep_info_paths.len(), 1);
837
838 let arts = artifacts_for(root.path(), unit);
840 assert_eq!(arts.paths.len(), 1);
841 assert_eq!(arts.bytes, 10);
842 assert!(arts.newest.is_some());
843 Ok(())
844 }
845
846 #[test]
847 fn reads_new_layout_units() -> Result<()> {
848 let root = tempfile::tempdir()?;
849 let unit_dir = root.path().join("debug/build/anyhow/9709d57b46b8d526");
850 fs::create_dir_all(unit_dir.join("fingerprint"))?;
851 fs::create_dir_all(unit_dir.join("out"))?;
852 fs::write(unit_dir.join("fingerprint/lib-anyhow"), "5d82713a7f2d05e2")?;
853 fs::write(unit_dir.join("fingerprint/lib-anyhow.json"), SAMPLE_JSON)?;
854 fs::write(unit_dir.join("fingerprint/dep-lib-anyhow"), [0u8; 69])?;
855 fs::write(unit_dir.join("fingerprint/invoked.timestamp"), "")?;
856 fs::write(
857 unit_dir.join("out/libanyhow-9709d57b46b8d526.rlib"),
858 [0u8; 10],
859 )?;
860 fs::write(
861 unit_dir.join("out/libanyhow-9709d57b46b8d526.rmeta"),
862 [0u8; 5],
863 )?;
864 fs::write(unit_dir.join("out/anyhow-9709d57b46b8d526.d"), [0u8; 3])?;
865
866 let units = read_units(root.path())?;
867 assert_eq!(units.len(), 1);
868 let unit = &units[0];
869 assert_eq!(unit.layout, UnitLayout::New);
870 assert_eq!(unit.package, "anyhow");
871 assert_eq!(unit.dir_suffix, "9709d57b46b8d526");
872 assert_eq!(unit.dir_name, "anyhow-9709d57b46b8d526");
873 assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
874 assert_eq!(unit.fingerprint_dir, unit_dir.join("fingerprint"));
875 assert_eq!(unit.unit_dir, unit_dir);
876 assert_eq!(
877 unit.fingerprint_rel(),
878 "build/anyhow/9709d57b46b8d526/fingerprint"
879 );
880
881 let arts = artifacts_for(root.path(), unit);
882 assert_eq!(arts.paths.len(), 3, "rlib + rmeta + d");
883 assert_eq!(arts.bytes, 18);
884 Ok(())
885 }
886
887 #[test]
888 fn reads_mixed_layouts_in_one_build_dir() -> Result<()> {
889 let root = tempfile::tempdir()?;
890 let classic = root
891 .path()
892 .join("debug/.fingerprint/serde-102a33f055a8d229");
893 fs::create_dir_all(&classic)?;
894 fs::write(classic.join("lib-serde"), "5d82713a7f2d05e2")?;
895 fs::create_dir_all(root.path().join("debug/deps"))?;
896 fs::write(
897 root.path()
898 .join("debug/deps/libserde-102a33f055a8d229.rlib"),
899 [0u8; 4],
900 )?;
901
902 let new = root.path().join("release/build/anyhow/9709d57b46b8d526");
903 fs::create_dir_all(new.join("fingerprint"))?;
904 fs::create_dir_all(new.join("out"))?;
905 fs::write(new.join("fingerprint/lib-anyhow"), "aaaaaaaaaaaaaaaa")?;
906 fs::write(new.join("out/libanyhow-9709d57b46b8d526.rlib"), [0u8; 4])?;
907
908 let units = read_units(root.path())?;
909 assert_eq!(units.len(), 2);
910 assert!(
911 units
912 .iter()
913 .any(|unit| unit.layout == UnitLayout::Classic && unit.profile == "debug")
914 );
915 assert!(
916 units
917 .iter()
918 .any(|unit| unit.layout == UnitLayout::New && unit.profile == "release")
919 );
920 Ok(())
921 }
922
923 #[test]
924 fn layout_decision_follows_cargo_version_and_flags() {
925 let none: Vec<String> = Vec::new();
926 let new_flag = vec!["-Zbuild-dir-new-layout".to_string()];
927 let split_flag = vec!["-Z".to_string(), "build-dir-new-layout".to_string()];
928
929 assert_eq!(
930 layout_for_cargo("cargo 1.91.0 (000000000 2026-01-01)", &none),
931 UnitLayout::Classic
932 );
933 assert_eq!(
934 layout_for_cargo("cargo 1.99.0 (000000000 2026-01-01)", &none),
935 UnitLayout::Classic
936 );
937 assert_eq!(
938 layout_for_cargo("cargo 1.99.0-nightly (7c83d4cc0 2026-07-29)", &none),
939 UnitLayout::New
940 );
941 assert_eq!(
942 layout_for_cargo("cargo 1.98.0-nightly (000000000 2026-01-01)", &none),
943 UnitLayout::Classic
944 );
945 assert_eq!(
946 layout_for_cargo("cargo 1.98.0-nightly (000000000 2026-01-01)", &new_flag),
947 UnitLayout::New
948 );
949 assert_eq!(
950 layout_for_cargo("cargo 1.98.0-nightly (000000000 2026-01-01)", &split_flag),
951 UnitLayout::New
952 );
953 assert_eq!(
954 layout_for_cargo("cargo 1.100.0 (000000000 2026-01-01)", &none),
955 UnitLayout::New
956 );
957 assert_eq!(layout_for_cargo("garbage", &none), UnitLayout::Classic);
958 }
959
960 #[test]
961 fn run_units_attach_their_build_tree() -> Result<()> {
962 let root = tempfile::tempdir()?;
963 let unit_dir = root.path().join("debug/.fingerprint/x-1122334455667788");
964 fs::create_dir_all(&unit_dir)?;
965 fs::write(unit_dir.join("build-script-build"), "aaaabbbbccccdddd")?;
966 fs::create_dir_all(root.path().join("debug/build/1122334455667788"))?;
967 fs::write(
968 root.path().join("debug/build/1122334455667788/output"),
969 [0u8; 4],
970 )?;
971 fs::write(
972 root.path().join("debug/build/1122334455667788/stderr"),
973 [0u8; 6],
974 )?;
975
976 let units = read_units(root.path())?;
977 assert_eq!(units.len(), 1);
978 let unit = &units[0];
979 assert_eq!(unit.kind, UnitKind::BuildScriptCompile);
980 let arts = artifacts_for(root.path(), unit);
981 assert_eq!(arts.paths.len(), 2, "output + stderr attached");
982 assert_eq!(arts.bytes, 10);
983 assert!(arts.newest.is_some());
984 Ok(())
985 }
986
987 #[test]
988 fn cached_diagnostics_are_not_mistaken_for_the_memo() -> Result<()> {
989 let root = tempfile::tempdir()?;
990 let unit_dir = root
991 .path()
992 .join("debug/.fingerprint/serde-102a33f055a8d229");
993 fs::create_dir_all(&unit_dir)?;
994 fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
995 fs::write(unit_dir.join("dep-lib-serde"), [0u8; 8])?;
996 fs::write(
998 unit_dir.join("output-lib-serde"),
999 r#"{"$message_type":"diagnostic","message":"unused import"}"#,
1000 )?;
1001
1002 let units = read_units(root.path())?;
1003 assert_eq!(units.len(), 1);
1004 let unit = &units[0];
1005 assert_eq!(unit.memo_file, "lib-serde", "the hash file wins");
1006 assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
1007 assert_eq!(unit.extra_files, vec!["output-lib-serde".to_string()]);
1008 Ok(())
1009 }
1010
1011 #[test]
1012 fn dep_hashes_resolve_to_memo_hashes_little_endian() {
1013 let dep = DepRef {
1016 name: "serde_core".into(),
1017 unit_hash: "9416646317397223793".into(),
1018 };
1019 assert_eq!(dep.memo_hash().as_deref(), Some("71a9f323efa5ae82"));
1020
1021 let junk = DepRef {
1022 name: "x".into(),
1023 unit_hash: "not-a-number".into(),
1024 };
1025 assert!(junk.memo_hash().is_none());
1026 }
1027
1028 #[test]
1029 fn dep_edges_join_the_graph_by_memo_hash() -> Result<()> {
1030 let root = tempfile::tempdir()?;
1031 let core = root
1032 .path()
1033 .join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
1034 fs::create_dir_all(&core)?;
1035 fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
1036 fs::create_dir_all(root.path().join("debug/deps"))?;
1037 fs::write(
1038 root.path()
1039 .join("debug/deps/libserde_core-b468be6fa8c1f936.rlib"),
1040 [0u8; 4],
1041 )?;
1042
1043 let serde = root
1044 .path()
1045 .join("debug/.fingerprint/serde-746d173581f1a314");
1046 fs::create_dir_all(&serde)?;
1047 fs::write(serde.join("lib-serde"), "5d82713a7f2d05e2")?;
1048 fs::write(
1049 serde.join("lib-serde.json"),
1050 r#"{"deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
1051 )?;
1052 fs::write(
1053 root.path()
1054 .join("debug/deps/libserde-746d173581f1a314.rlib"),
1055 [0u8; 4],
1056 )?;
1057
1058 let units = read_units(root.path())?;
1059 assert_eq!(units.len(), 2);
1060 let edges = dep_edges(&units);
1061 let serde_at = units.iter().position(|u| u.package == "serde").unwrap();
1062 let core_at = units
1063 .iter()
1064 .position(|u| u.package == "serde_core")
1065 .unwrap();
1066 assert_eq!(edges[serde_at], vec![core_at]);
1067 assert!(edges[core_at].is_empty());
1068 Ok(())
1069 }
1070
1071 #[test]
1072 fn pick_memo_prefers_hash_content_and_falls_back() {
1073 let candidates = vec![
1074 (
1075 "output-lib-x".to_string(),
1076 r#"{"message":"warning"}"#.to_string(),
1077 ),
1078 ("lib-x".to_string(), "5d82713a7f2d05e2".to_string()),
1079 ];
1080 let ((name, hash), extras) = pick_memo(candidates).expect("a memo exists");
1081 assert_eq!(name, "lib-x");
1082 assert_eq!(hash, "5d82713a7f2d05e2");
1083 assert_eq!(extras, vec!["output-lib-x".to_string()]);
1084
1085 let candidates = vec![("weird".to_string(), "not a hash".to_string())];
1088 let ((name, _), extras) = pick_memo(candidates).expect("fallback");
1089 assert_eq!(name, "weird");
1090 assert!(extras.is_empty());
1091
1092 assert!(pick_memo(Vec::new()).is_none());
1093 }
1094}