1use std::collections::BTreeMap;
11use std::fs;
12use std::io::ErrorKind;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime};
15
16use anyhow::{Context, Result, bail};
17use walkdir::WalkDir;
18
19use crate::fingerprint::{self, ArtifactInfo, Unit};
20use crate::util::{self, human_age, human_bytes, parse_duration};
21use crate::who;
22
23pub struct Options<'a> {
25 pub unit: Option<&'a str>,
27 pub stale: bool,
29 pub rustc_key: Option<&'a str>,
31 pub generation: Option<&'a str>,
33 pub husks: bool,
35 pub older_than: Option<&'a str>,
37 pub work_dirs: bool,
40 pub work_dir: Option<&'a str>,
43 pub work: Option<&'a str>,
46 pub crate_name: Option<&'a str>,
48 pub tier: Tier,
50 pub force: bool,
52 pub delete: bool,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
59pub enum Tier {
60 All,
62 Store,
64 View,
66}
67
68impl Tier {
69 fn touches_store(self) -> bool {
70 matches!(self, Tier::All | Tier::Store)
71 }
72
73 fn touches_view(self) -> bool {
74 matches!(self, Tier::All | Tier::View)
75 }
76}
77
78pub const DEFAULT_WORK_MAX_AGE: &str = "7d";
80
81pub fn run(build_root: &Path, opts: &Options<'_>) -> Result<()> {
83 #[cfg(windows)]
84 crate::util::ensure_windows_user_path(&crate::exec::work_root(build_root), "work root")?;
85 for (name, value) in [
87 ("--unit", opts.unit),
88 ("--rustc-key", opts.rustc_key),
89 ("--generation", opts.generation),
90 ("--crate", opts.crate_name),
91 ] {
92 if value.is_some_and(|value| value.is_empty()) {
93 bail!("{name} must not be empty — use an explicit selector or a dry run");
94 }
95 }
96 if let Some(query) = opts.generation {
97 if opts.unit.is_some()
98 || opts.stale
99 || opts.rustc_key.is_some()
100 || opts.husks
101 || opts.older_than.is_some()
102 || opts.work_dirs
103 || opts.work_dir.is_some()
104 || opts.work.is_some()
105 || opts.crate_name.is_some()
106 {
107 bail!(
108 "--generation deletes whole parity-key generations — it takes no unit selectors:\n\
109 e.g. `epicenter evict --generation 1.97.1` (dry run unless --yes)"
110 );
111 }
112 if opts.tier == Tier::View {
113 bail!(
114 "--generation is a vault operation — use --tier store, or the default all to also unlink matching view units"
115 );
116 }
117 return evict_generations(build_root, opts, query);
118 }
119
120 if let Some(target) = opts.work_dir {
121 if opts.work_dirs || opts.work.is_some() {
122 bail!(
123 "one operation at a time — --work-dir removes ONE job dir:\n\
124 --work-dirs collects the whole tier by age, --work unlinks units inside a view"
125 );
126 }
127 if opts.unit.is_some()
128 || opts.crate_name.is_some()
129 || opts.stale
130 || opts.rustc_key.is_some()
131 || opts.husks
132 || opts.older_than.is_some()
133 {
134 bail!(
135 "--work-dir removes exactly the named job dir and takes no selectors:\n\
136 e.g. `epicenter evict --work-dir ci-1` (dry run) then --yes"
137 );
138 }
139 if opts.tier != Tier::All {
140 bail!("--tier does not apply to --work-dir — a job dir is one view, removed whole");
141 }
142 return evict_work_dir(build_root, target, opts.delete);
143 }
144
145 if opts.work_dirs {
146 if opts.work.is_some()
147 || opts.unit.is_some()
148 || opts.stale
149 || opts.rustc_key.is_some()
150 || opts.husks
151 {
152 bail!(
153 "--work-dirs collects whole stale job dirs — it takes no unit selectors:\n\
154 run `epicenter evict --work-dirs` on its own, or drop --work-dirs"
155 );
156 }
157 evict_work_dirs(build_root, opts.older_than, opts.delete)?;
158 return Ok(());
159 }
160
161 if let Some(work) = opts.work {
162 if opts.stale || opts.rustc_key.is_some() || opts.husks || opts.older_than.is_some() {
163 bail!(
164 "--work unlinks units from ONE view — only --crate / --unit apply:\n\
165 for bulk view work use `--tier view`, for whole job dirs use --work-dirs"
166 );
167 }
168 if opts.unit.is_none() && opts.crate_name.is_none() {
169 bail!(
170 "nothing selected — --work needs --crate <name> or --unit <hash>:\n\
171 e.g. `epicenter evict --work ci-1 --crate serde` (dry run unless --yes)"
172 );
173 }
174 if opts.tier != Tier::All {
175 bail!(
176 "--tier does not apply to --work — that operation is always view-only:\n\
177 the vault is never touched by it"
178 );
179 }
180 return evict_from_view(build_root, opts, work);
181 }
182
183 if opts.unit.is_none()
184 && !opts.stale
185 && opts.rustc_key.is_none()
186 && !opts.husks
187 && opts.older_than.is_none()
188 {
189 bail!(
190 "nothing selected — pass one selector:\n\
191 --unit <hash|package> a specific unit\n\
192 --stale every unit from an older toolchain\n\
193 --rustc-key <hash> units matching a toolchain identity\n\
194 --husks units whose artifacts are already gone\n\
195 --older-than <age> units untouched for longer (e.g. 90d)\n\
196 --work-dirs stale per-job working dirs (default 7d)\n\
197 dry run unless you pass --yes — full help: `epicenter evict --help`"
198 );
199 }
200
201 let store_root = crate::vault::store_root(build_root);
202 let active = crate::vault::parity_key().ok();
203 let age_limit = opts
204 .older_than
205 .map(parse_duration)
206 .transpose()
207 .context("invalid value for --older-than")?;
208 let now = SystemTime::now();
209
210 let mut candidates: BTreeMap<String, Candidate> = BTreeMap::new();
211 let mut view_labels: Vec<String> = Vec::new();
212 let mut skipped_views: Vec<String> = Vec::new();
213
214 if opts.tier.touches_store() {
217 for stored in crate::vault::stored_units(&store_root)? {
218 if !store_selectors_hit(opts, &stored, active.as_deref(), age_limit, now) {
219 continue;
220 }
221 let mut files = Vec::new();
222 collect_tree_files(&stored.dir, &mut files);
223 let current = active.as_deref() == Some(stored.generation.as_str());
224 let candidate = candidate_for(&mut candidates, &stored.entry.name);
225 candidate.kind = fingerprint::classify_kind(&stored.entry.memo)
226 .label()
227 .to_string();
228 candidate.current |= current;
229 candidate.newest = max_time(candidate.newest, stamp_time(stored.entry.stamp));
230 push_place(
231 candidate,
232 Place::Store {
233 generation: stored.generation.clone(),
234 files,
235 },
236 );
237 }
238 }
239
240 if opts.tier.touches_view() {
242 for (label, root) in known_views(build_root) {
243 if crate::exec::dir_locked(&root) {
244 skipped_views.push(label);
245 continue;
246 }
247 let rows = who::collect(&root).with_context(|| format!("reading view {label}"))?;
248 let current = active_cohort(&rows);
249 for (unit, arts) in rows {
250 if !view_selectors_hit(opts, &unit, &arts, ¤t, age_limit, now) {
251 continue;
252 }
253 if !view_labels.contains(&label) {
254 view_labels.push(label.clone());
255 }
256 let files = view_unit_files(&unit, &arts);
257 let artifacts: Vec<PathBuf> =
258 arts.paths.iter().map(|(path, _, _)| path.clone()).collect();
259 let candidate = candidate_for(&mut candidates, &unit.dir_name);
260 candidate.kind = unit.kind.label().to_string();
261 candidate.current |= !current.is_empty() && who::rustc_of(&unit) == current;
262 candidate.newest = max_time(candidate.newest, arts.newest);
263 push_place(
264 candidate,
265 Place::View {
266 label: label.clone(),
267 root: root.clone(),
268 unit_dir: unit.fingerprint_dir.clone(),
269 anchors: fingerprint::build_anchor_dirs(&root, &unit),
270 artifacts,
271 files,
272 },
273 );
274 }
275 }
276 }
277
278 if opts.delete {
279 println!("{}", util::heading("epicenter evict — removing"));
280 } else {
281 println!(
282 "{}",
283 util::heading("epicenter evict — DRY RUN (nothing is removed; pass --yes to delete)")
284 );
285 }
286
287 let explicit_unit = opts.unit.is_some()
290 && !opts.stale
291 && opts.rustc_key.is_none()
292 && !opts.husks
293 && opts.older_than.is_none();
294
295 let mut protected = 0usize;
296 let mut chosen: Vec<Candidate> = Vec::new();
297 for (_, candidate) in candidates {
298 if candidate.current && !opts.force && opts.tier.touches_view() && !explicit_unit {
301 protected += 1;
302 continue;
303 }
304 chosen.push(candidate);
305 }
306 chosen.sort_by_key(|candidate| std::cmp::Reverse(candidate.bytes));
307
308 if chosen.is_empty() {
309 if protected > 0 {
310 println!(
311 " {} {protected} current-generation unit(s) match but are protected — use --force to include them",
312 util::warn(util::GLYPH_WARN)
313 );
314 println!(
315 " {}",
316 util::dim("nothing else matches the given selectors")
317 );
318 } else {
319 println!(" nothing matches the given selectors");
320 }
321 return Ok(());
322 }
323
324 for candidate in &chosen {
325 let mut labels: Vec<&str> = candidate.places.iter().map(Place::label).collect();
326 labels.sort_unstable();
327 labels.dedup();
328 println!(
329 " {} {:<38} {:<9} {:>9} {:>10} old {}{}",
330 util::green(util::GLYPH_PASS),
331 candidate.name,
332 candidate.kind,
333 human_bytes(candidate.bytes),
334 candidate
335 .newest
336 .map(human_age)
337 .unwrap_or_else(|| "?".into()),
338 labels.join(" + "),
339 if candidate.current && opts.force {
340 util::dim(" (current — forced)")
341 } else if candidate.current {
342 util::dim(" (current)")
343 } else {
344 String::new()
345 },
346 );
347 }
348
349 let all_files: Vec<PathBuf> = chosen
350 .iter()
351 .flat_map(|candidate| candidate.places.iter())
352 .flat_map(|place| place.files().iter().cloned())
353 .collect();
354 let (nominal, freed, released, count) = account_files(&all_files);
355 let places: usize = chosen.iter().map(|candidate| candidate.places.len()).sum();
356
357 println!();
358 println!(
359 " {} {} unit(s) · {} place(s) · {} file(s) · {} nominal",
360 util::dim("selection:"),
361 chosen.len(),
362 places,
363 count,
364 util::green(&human_bytes(nominal))
365 );
366 println!(
367 " {} {} actually freed · {} links released",
368 util::dim("result: "),
369 human_bytes(freed),
370 human_bytes(released)
371 );
372 println!(
373 " {} {}",
374 util::dim("tier: "),
375 tier_summary(opts.tier, &view_labels)
376 );
377 if protected > 0 {
378 println!(
379 " {} {protected} current-generation unit(s) skipped — use --force to include them",
380 util::warn(util::GLYPH_WARN)
381 );
382 }
383 for label in &skipped_views {
384 println!(
385 " {} view {label} is in use by a live build — skipped",
386 util::warn(util::GLYPH_WARN)
387 );
388 }
389 if !opts.delete {
390 println!(
391 " {}",
392 util::dim("run again with -y or --yes to remove them")
393 );
394 return Ok(());
395 }
396
397 let mut errors = 0u64;
399
400 let mut per_generation: BTreeMap<String, Vec<String>> = BTreeMap::new();
401 for candidate in &chosen {
402 for place in &candidate.places {
403 if let Place::Store { generation, .. } = place {
404 per_generation
405 .entry(generation.clone())
406 .or_default()
407 .push(candidate.name.clone());
408 }
409 }
410 }
411 let mut store_dropped = 0usize;
412 for (generation, names) in &per_generation {
413 match crate::vault::drop_units(&store_root, generation, names) {
414 Ok(dropped) => store_dropped += dropped,
415 Err(err) => {
416 errors += 1;
417 eprintln!("could not prune the vault: {err}");
418 }
419 }
420 }
421 if opts.delete && opts.tier.touches_store() {
422 crate::vault::sweep_store_debris(&store_root);
424 }
425
426 let mut view_counts: BTreeMap<String, usize> = BTreeMap::new();
427 for candidate in &chosen {
428 for place in &candidate.places {
429 if let Place::View {
430 label,
431 root,
432 unit_dir,
433 anchors,
434 artifacts,
435 ..
436 } = place
437 {
438 let _job_lock = match label.strip_prefix("work/") {
441 Some(name) => match crate::util::FileLock::try_acquire(
442 &crate::exec::job_lock_path(
443 &crate::exec::work_root(&crate::vault::store_root(
444 &crate::config::trusted_cache_root()?,
445 )),
446 name,
447 ),
448 ) {
449 Ok(Some(lock)) => Some(lock),
450 Ok(None) => {
451 errors += 1;
452 eprintln!("view {label} is in use by an epicenter process — skipped");
453 continue;
454 }
455 Err(err) => {
456 errors += 1;
457 eprintln!("view {label} lock failed: {err:#} — skipped");
458 continue;
459 }
460 },
461 None => None,
462 };
463 if crate::exec::dir_locked(root) {
464 errors += 1;
465 eprintln!("view {label} is in use by a live build — skipped");
466 continue;
467 }
468 remove_dir(unit_dir, &mut errors);
469 for anchor in anchors {
470 if anchor.is_dir() {
471 remove_dir(anchor, &mut errors);
472 }
473 }
474 for artifact in artifacts {
475 remove_file(artifact, &mut errors);
476 }
477 *view_counts.entry(label.clone()).or_default() += 1;
478 }
479 }
480 }
481
482 println!();
483 println!(
484 " {} {} unit(s) evicted · {} actually freed · {} links released",
485 util::green(util::GLYPH_PASS),
486 chosen.len(),
487 human_bytes(freed),
488 human_bytes(released)
489 );
490 if store_dropped > 0 {
491 println!(
492 " {:<11} {} unit(s) dropped · manifest updated",
493 util::dim("store"),
494 store_dropped
495 );
496 }
497 for (label, count) in &view_counts {
498 println!(" {:<11} {} unit(s) unlinked", util::dim(label), count);
499 }
500 if errors > 0 {
501 println!(
502 " {} {} (see messages above)",
503 util::fail(util::GLYPH_FAIL),
504 errors
505 );
506 }
507 println!(
508 " {}",
509 util::dim("`epicenter vault` shows what the store holds now")
510 );
511 Ok(())
512}
513
514#[derive(Debug, Default)]
516pub struct WorkReport {
517 pub scanned: usize,
519 pub eligible: usize,
521 pub young: usize,
523 pub in_use: usize,
525 pub removed: usize,
527 pub nominal_bytes: u64,
529 pub unique_bytes: u64,
531 pub shared_bytes: u64,
533 pub errors: u64,
534}
535
536struct WorkDir {
538 name: String,
539 path: PathBuf,
540 newest: Option<SystemTime>,
541 locked: bool,
542 old_enough: bool,
543 bytes: u64,
544 unique: u64,
545 shared: u64,
546 files: u64,
547}
548
549fn evict_work_dir(build_root: &Path, target: &str, delete: bool) -> Result<()> {
555 let work_root = crate::exec::work_root(build_root);
556 let path = resolve_work_dir(&work_root, target)?;
557 let name = path
558 .file_name()
559 .map(|name| name.to_string_lossy().into_owned())
560 .unwrap_or_default();
561 let job_lock =
562 match crate::util::FileLock::try_acquire(&crate::exec::job_lock_path(&work_root, &name))? {
563 Some(lock) => lock,
564 None => bail!(
565 "job dir {} is in use by an epicenter process — remove it when it finishes",
566 path.display()
567 ),
568 };
569 let Some(_cargo_locks) = crate::exec::cargo_locks(&path)? else {
573 bail!(
574 "job dir {} is in use by a live build — remove it when it finishes",
575 path.display()
576 );
577 };
578 let newest = dir_newest(&path);
579 let (bytes, unique, shared, files) = dir_accounting(&path);
580
581 if delete {
582 println!(
583 "{}",
584 util::heading(&format!("epicenter evict --work-dir {name} — removing"))
585 );
586 } else {
587 println!(
588 "{}",
589 util::heading(&format!(
590 "epicenter evict --work-dir {name} — DRY RUN (nothing is removed; pass --yes)"
591 ))
592 );
593 }
594 println!(
595 " {} {}",
596 util::dim(&format!("{:<11}", "job dir")),
597 path.display()
598 );
599 println!(
600 " {} {:<40} {:>9} · {:>4} file(s) {:>10} old {}",
601 util::green(util::GLYPH_PASS),
602 name,
603 human_bytes(bytes),
604 files,
605 newest.map(human_age).unwrap_or_else(|| "?".into()),
606 util::dim("explicitly selected — age policy not applied")
607 );
608 println!();
609 println!(
610 " {} {} nominal — {} actually freed · {} links released",
611 util::dim("result: "),
612 human_bytes(bytes),
613 human_bytes(unique),
614 human_bytes(shared)
615 );
616 if !delete {
617 println!(" {}", util::dim("run again with -y or --yes to remove it"));
618 return Ok(());
619 }
620 let tombstone = work_root.join(format!(".{name}.deleting-{}", std::process::id()));
624 if fs::symlink_metadata(&tombstone).is_ok() {
625 let _ = crate::util::force_remove_dir(&tombstone);
626 }
627 fs::rename(&path, &tombstone).with_context(|| format!("isolating {}", path.display()))?;
628 if let Err(err) = fs::remove_dir_all(&tombstone) {
629 let _ = fs::rename(&tombstone, &path);
630 drop(job_lock);
631 return Err(err).with_context(|| format!("removing {}", path.display()));
632 }
633 drop(job_lock);
634 crate::exec::remove_job_lock(&work_root, &name);
635 crate::exec::sweep_job_locks(&work_root);
636 println!();
637 println!(
638 " {} {name} removed · {} actually freed · {} links released",
639 util::green(util::GLYPH_PASS),
640 human_bytes(unique),
641 human_bytes(shared)
642 );
643 println!(
644 " {}",
645 util::dim("the vault is untouched — a job dir is a view")
646 );
647 Ok(())
648}
649
650fn resolve_work_dir(work_root: &Path, target: &str) -> Result<PathBuf> {
653 if target == "build" {
654 bail!(
655 "the shared build dir is never removed — it is the core asset:\n\
656 to work inside it use `epicenter evict --work build --crate <name>`"
657 );
658 }
659 let candidate = work_root.join(target.trim_start_matches("work/"));
660 let candidate = if candidate.is_dir() {
661 candidate
662 } else {
663 PathBuf::from(target)
664 };
665 let candidate_meta = fs::symlink_metadata(&candidate)?;
666 if !candidate_meta.is_dir()
667 || candidate_meta.file_type().is_symlink()
668 || crate::util::is_reparse_point(&candidate_meta)
669 {
670 bail!("no job dir named {target:?} — `epicenter vault` shows the work tier");
671 }
672 let inside = candidate
673 .canonicalize()
674 .ok()
675 .zip(work_root.canonicalize().ok())
676 .map(|(child, root)| child.parent() == Some(root.as_path()))
677 .unwrap_or(false);
678 if !inside {
679 bail!(
680 "{} is not a job dir under {} — --work-dir removes the work tier only",
681 candidate.display(),
682 work_root.display()
683 );
684 }
685 Ok(candidate)
686}
687
688fn evict_work_dirs(
693 build_root: &Path,
694 older_than: Option<&str>,
695 delete: bool,
696) -> Result<WorkReport> {
697 let work_root = crate::exec::work_root(build_root);
698 let age_text = older_than.unwrap_or(DEFAULT_WORK_MAX_AGE);
699 let limit = parse_duration(age_text).context("invalid value for --older-than")?;
700
701 if delete {
702 println!(
703 "{}",
704 util::heading("epicenter evict --work-dirs — removing")
705 );
706 } else {
707 println!(
708 "{}",
709 util::heading(
710 "epicenter evict --work-dirs — DRY RUN (nothing is removed; pass --yes to delete)"
711 )
712 );
713 }
714 let mut report = WorkReport::default();
715 if !work_root.is_dir() {
716 println!(" no working dirs at {}", work_root.display());
717 return Ok(report);
718 }
719
720 let now = SystemTime::now();
721 let mut rows: Vec<WorkDir> = Vec::new();
722 for entry in
723 fs::read_dir(&work_root).with_context(|| format!("reading {}", work_root.display()))?
724 {
725 let entry = entry?;
726 if !entry.file_type()?.is_dir() {
727 continue;
728 }
729 let name = entry.file_name().to_string_lossy().into_owned();
730 if name.starts_with('.') {
731 continue;
732 }
733 let path = entry.path();
734 if fs::symlink_metadata(&path)
735 .map(|meta| crate::util::is_reparse_point(&meta))
736 .unwrap_or(true)
737 {
738 continue;
739 }
740 let newest = dir_newest(&path);
741 let locked = crate::exec::dir_locked(&path);
742 let (bytes, unique, shared, files) = dir_accounting(&path);
743 let old_enough = newest
744 .map(|m| now.duration_since(m).map_or(true, |age| age > limit))
745 .unwrap_or(true);
746 rows.push(WorkDir {
747 name,
748 path,
749 newest,
750 locked,
751 old_enough,
752 bytes,
753 unique,
754 shared,
755 files,
756 });
757 }
758 rows.sort_by(|a, b| a.name.cmp(&b.name));
759 if rows.is_empty() {
760 println!(
761 " no working dirs at {} — nothing to collect",
762 work_root.display()
763 );
764 return Ok(report);
765 }
766
767 for row in &rows {
768 report.scanned += 1;
769 let (icon, note) = if row.locked {
770 report.in_use += 1;
771 (
772 util::warn(util::GLYPH_WARN),
773 util::warn("in use by a live build — never collected").to_string(),
774 )
775 } else if row.old_enough {
776 report.eligible += 1;
777 (
778 util::green(util::GLYPH_PASS),
779 util::green("collectible").to_string(),
780 )
781 } else {
782 report.young += 1;
783 (
784 util::info(util::GLYPH_INFO),
785 util::dim(&format!("younger than {age_text}")).to_string(),
786 )
787 };
788 println!(
789 " {icon} {:<40} {:>9} · {:>4} file(s) {:>10} old {note}",
790 row.name,
791 human_bytes(row.bytes),
792 row.files,
793 row.newest.map(human_age).unwrap_or_else(|| "?".into()),
794 );
795 }
796
797 for row in rows.iter().filter(|row| row.old_enough && !row.locked) {
798 report.nominal_bytes += row.bytes;
799 report.unique_bytes += row.unique;
800 report.shared_bytes += row.shared;
801 }
802 println!();
803 if report.eligible == 0 {
804 println!(
805 " {} nothing older than {age_text} to collect",
806 util::dim("selection:")
807 );
808 } else {
809 println!(
810 " {} {} dir(s) · {} nominal · {} unique to the jobs (freed), {} still shared with the store",
811 util::dim("selection:"),
812 report.eligible,
813 util::green(&human_bytes(report.nominal_bytes)),
814 util::green(&human_bytes(report.unique_bytes)),
815 util::dim(&human_bytes(report.shared_bytes)),
816 );
817 }
818 if report.in_use > 0 {
819 println!(
820 " {} {} dir(s) in use by a live build — left alone",
821 util::warn(util::GLYPH_WARN),
822 report.in_use
823 );
824 }
825 if report.young > 0 {
826 println!(
827 " {} {} dir(s) younger than {age_text} — kept",
828 util::info(util::GLYPH_INFO),
829 report.young
830 );
831 }
832 if report.eligible == 0 {
833 return Ok(report);
834 }
835 if !delete {
836 println!(
837 " {}",
838 util::dim("run again with -y or --yes to remove the selected dirs")
839 );
840 return Ok(report);
841 }
842
843 let mut freed_unique = 0u64;
844 let mut freed_shared = 0u64;
845 for row in rows.iter().filter(|row| row.old_enough && !row.locked) {
846 let job_lock = match crate::util::FileLock::try_acquire(&crate::exec::job_lock_path(
847 &work_root, &row.name,
848 ))? {
849 Some(lock) => lock,
850 None => {
851 report.in_use += 1;
852 continue;
853 }
854 };
855 let Some(_cargo_locks) = crate::exec::cargo_locks(&row.path)? else {
856 report.in_use += 1;
857 continue;
858 };
859 match fs::remove_dir_all(&row.path) {
860 Ok(()) => {
861 report.removed += 1;
862 freed_unique += row.unique;
863 freed_shared += row.shared;
864 drop(job_lock);
865 crate::exec::remove_job_lock(&work_root, &row.name);
866 }
867 Err(e) => {
868 report.errors += 1;
869 eprintln!("could not remove {}: {e}", row.path.display());
870 }
871 }
872 }
873 crate::exec::sweep_job_locks(&work_root);
874 println!();
875 println!(
876 " {} {} dir(s) evicted · {} actually freed · {} link(s) released (other links, e.g. the vault, still hold those bytes)",
877 util::green(util::GLYPH_PASS),
878 report.removed,
879 human_bytes(freed_unique),
880 util::dim(&human_bytes(freed_shared)),
881 );
882 if report.errors > 0 {
883 println!(
884 " {} {} (see messages above)",
885 util::fail(util::GLYPH_FAIL),
886 report.errors
887 );
888 }
889 println!(
890 " {}",
891 util::dim("the vault is untouched — `epicenter vault` shows what remains")
892 );
893 Ok(report)
894}
895
896fn dir_newest(dir: &Path) -> Option<SystemTime> {
899 let mut newest = None;
900 for entry in WalkDir::new(dir).min_depth(1).into_iter().flatten() {
901 if !entry.file_type().is_file() {
902 continue;
903 }
904 if let Some(modified) = entry.metadata().ok().and_then(|meta| meta.modified().ok()) {
905 if newest.map_or(true, |current| modified > current) {
906 newest = Some(modified);
907 }
908 }
909 }
910 newest
911}
912
913fn dir_accounting(dir: &Path) -> (u64, u64, u64, u64) {
915 let mut bytes = 0u64;
916 let mut unique = 0u64;
917 let mut shared = 0u64;
918 let mut files = 0u64;
919 for entry in WalkDir::new(dir).min_depth(1).into_iter().flatten() {
920 if !entry.file_type().is_file() {
921 continue;
922 }
923 let Ok(meta) = entry.metadata() else { continue };
924 let size = meta.len();
925 bytes += size;
926 files += 1;
927 if crate::vault::link_count(&meta) > 1 {
928 shared += size;
929 } else {
930 unique += size;
931 }
932 }
933 (bytes, unique, shared, files)
934}
935
936fn resolve_view(build_root: &Path, work: &str) -> Result<PathBuf> {
939 if work == "build" {
940 return Ok(build_root.to_path_buf());
941 }
942 let work_root = crate::exec::work_root(build_root);
943 let job_id = work.strip_prefix("work/").unwrap_or(work);
947 if crate::util::validate_component(job_id, "job id").is_ok() {
948 let job = work_root.join(job_id);
949 if let Ok(meta) = fs::symlink_metadata(&job) {
950 if meta.is_dir()
951 && !meta.file_type().is_symlink()
952 && !crate::util::is_reparse_point(&meta)
953 {
954 if let (Ok(root), Ok(canon)) = (work_root.canonicalize(), job.canonicalize()) {
957 if canon != root && canon.starts_with(&root) {
958 return Ok(job);
959 }
960 }
961 }
962 }
963 }
964 let path = PathBuf::from(work);
965 if fs::symlink_metadata(&path)
966 .map(|meta| {
967 meta.is_dir() && !meta.file_type().is_symlink() && !crate::util::is_reparse_point(&meta)
968 })
969 .unwrap_or(false)
970 {
971 let canonical = path.canonicalize()?;
972 let work_root_canonical = work_root.canonicalize().ok();
973 let build_canonical = build_root.canonicalize().ok();
974 let allowed = work_root_canonical
975 .iter()
976 .chain(build_canonical.iter())
977 .any(|root| canonical.starts_with(root));
978 if allowed {
979 return Ok(path);
980 }
981 bail!(
982 "view {} is outside {} and {} — refusing to delete outside the cache",
983 path.display(),
984 work_root.display(),
985 build_root.display()
986 );
987 }
988 bail!(
989 "no view named {work:?} — expected `build`, a job id under {}, or a directory under it",
990 work_root.display()
991 );
992}
993
994fn crate_matches(unit: &Unit, name: &str) -> bool {
996 unit.package == name || unit.package.replace('-', "_") == name.replace('-', "_")
997}
998
999fn surgery_unit_matches(unit: &Unit, opts: &Options<'_>) -> bool {
1001 let mut hit = false;
1002 if let Some(query) = opts.unit {
1003 hit |= who::unit_matches(unit, query);
1004 }
1005 if let Some(name) = opts.crate_name {
1006 hit |= crate_matches(unit, name);
1007 }
1008 hit
1009}
1010
1011fn view_unit_files(unit: &Unit, arts: &ArtifactInfo) -> Vec<PathBuf> {
1013 let mut files = Vec::new();
1014 collect_tree_files(&unit.fingerprint_dir, &mut files);
1015 for (path, _, _) in &arts.paths {
1017 files.push(path.clone());
1018 }
1019 files
1020}
1021
1022fn collect_tree_files(dir: &Path, out: &mut Vec<PathBuf>) {
1023 for entry in WalkDir::new(dir).min_depth(1).into_iter().flatten() {
1024 if entry.file_type().is_file() {
1025 out.push(entry.path().to_path_buf());
1026 }
1027 }
1028}
1029
1030fn remove_view_unit(root: &Path, unit: &Unit, arts: &ArtifactInfo, errors: &mut u64) {
1032 remove_dir(&unit.fingerprint_dir, errors);
1033 for anchor in fingerprint::build_anchor_dirs(root, unit) {
1034 if anchor.is_dir() {
1035 remove_dir(&anchor, errors);
1036 }
1037 }
1038 for (path, _, _) in &arts.paths {
1039 remove_file(path, errors);
1040 }
1041}
1042
1043fn account_files(files: &[PathBuf]) -> (u64, u64, u64, u64) {
1047 #[cfg(unix)]
1048 {
1049 use std::collections::{HashMap, HashSet};
1050 use std::os::unix::fs::MetadataExt;
1051
1052 let mut per_inode: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
1053 let mut seen: HashSet<&Path> = HashSet::new();
1054 let mut count = 0u64;
1055 for path in files {
1056 if !seen.insert(path.as_path()) {
1057 continue;
1058 }
1059 let Ok(meta) = fs::metadata(path) else {
1060 continue;
1061 };
1062 count += 1;
1063 let entry =
1064 per_inode
1065 .entry((meta.dev(), meta.ino()))
1066 .or_insert((meta.len(), meta.nlink(), 0));
1067 entry.2 += 1;
1068 }
1069 let mut nominal = 0u64;
1070 let mut freed = 0u64;
1071 for (size, links, planned) in per_inode.values() {
1072 nominal += size;
1073 if planned >= links {
1074 freed += size;
1075 }
1076 }
1077 (nominal, freed, nominal - freed, count)
1078 }
1079 #[cfg(not(unix))]
1080 {
1081 let mut seen: std::collections::HashSet<&Path> = std::collections::HashSet::new();
1083 let mut nominal = 0u64;
1084 let mut count = 0u64;
1085 for path in files {
1086 if !seen.insert(path.as_path()) {
1087 continue;
1088 }
1089 if let Ok(meta) = fs::metadata(path) {
1090 nominal += meta.len();
1091 count += 1;
1092 }
1093 }
1094 (nominal, nominal, 0, count)
1095 }
1096}
1097
1098struct Candidate {
1101 name: String,
1102 kind: String,
1103 bytes: u64,
1104 newest: Option<SystemTime>,
1105 current: bool,
1106 places: Vec<Place>,
1107}
1108
1109enum Place {
1111 Store {
1112 generation: String,
1113 files: Vec<PathBuf>,
1114 },
1115 View {
1116 label: String,
1117 root: PathBuf,
1118 unit_dir: PathBuf,
1119 anchors: Vec<PathBuf>,
1120 artifacts: Vec<PathBuf>,
1121 files: Vec<PathBuf>,
1122 },
1123}
1124
1125impl Place {
1126 fn label(&self) -> &str {
1127 match self {
1128 Place::Store { .. } => "store",
1129 Place::View { label, .. } => label,
1130 }
1131 }
1132
1133 fn files(&self) -> &[PathBuf] {
1134 match self {
1135 Place::Store { files, .. } | Place::View { files, .. } => files,
1136 }
1137 }
1138}
1139
1140fn candidate_for<'a>(
1141 candidates: &'a mut BTreeMap<String, Candidate>,
1142 name: &str,
1143) -> &'a mut Candidate {
1144 candidates
1145 .entry(name.to_string())
1146 .or_insert_with(|| Candidate {
1147 name: name.to_string(),
1148 kind: "?".to_string(),
1149 bytes: 0,
1150 newest: None,
1151 current: false,
1152 places: Vec::new(),
1153 })
1154}
1155
1156fn push_place(candidate: &mut Candidate, place: Place) {
1157 candidate.bytes += place
1158 .files()
1159 .iter()
1160 .filter_map(|path| fs::metadata(path).ok())
1161 .map(|meta| meta.len())
1162 .sum::<u64>();
1163 candidate.places.push(place);
1164}
1165
1166fn stamp_time(stamp: u64) -> Option<SystemTime> {
1167 (stamp > 0).then(|| std::time::UNIX_EPOCH + Duration::from_secs(stamp))
1168}
1169
1170fn max_time(left: Option<SystemTime>, right: Option<SystemTime>) -> Option<SystemTime> {
1171 match (left, right) {
1172 (Some(a), Some(b)) => Some(a.max(b)),
1173 (left, None) => left,
1174 (None, right) => right,
1175 }
1176}
1177
1178fn known_views(build_root: &Path) -> Vec<(String, PathBuf)> {
1180 let mut views = vec![("build".to_string(), build_root.to_path_buf())];
1181 let work_root = crate::exec::work_root(build_root);
1182 if let Ok(entries) = fs::read_dir(&work_root) {
1183 for entry in entries.flatten() {
1184 if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
1185 continue;
1186 }
1187 let name = entry.file_name().to_string_lossy().into_owned();
1188 if name.starts_with('.') {
1189 continue;
1190 }
1191 if fs::symlink_metadata(entry.path())
1192 .map(|meta| crate::util::is_reparse_point(&meta))
1193 .unwrap_or(true)
1194 {
1195 continue;
1196 }
1197 views.push((format!("work/{name}"), entry.path()));
1198 }
1199 }
1200 views
1201}
1202
1203fn active_cohort(rows: &[(Unit, ArtifactInfo)]) -> String {
1206 who::rustc_cohorts(rows)
1207 .into_iter()
1208 .max_by_key(|(_, count)| *count)
1209 .map(|(id, _)| id)
1210 .unwrap_or_default()
1211}
1212
1213fn store_selectors_hit(
1216 opts: &Options<'_>,
1217 stored: &crate::vault::StoredUnit,
1218 active: Option<&str>,
1219 limit: Option<Duration>,
1220 now: SystemTime,
1221) -> bool {
1222 let mut hit = false;
1223 if let Some(query) = opts.unit {
1224 hit |= stored.entry.name.contains(query);
1225 }
1226 if opts.stale {
1227 hit |= active.map(|key| stored.generation != key).unwrap_or(false);
1228 }
1229 if let Some(key) = opts.rustc_key {
1230 hit |= crate::vault::stored_unit_rustc(stored)
1231 .map(|id| id.starts_with(key))
1232 .unwrap_or(false);
1233 }
1234 if let Some(limit) = limit {
1235 hit |= stamp_time(stored.entry.stamp)
1236 .map(|stamp| now.duration_since(stamp).map_or(true, |age| age > limit))
1237 .unwrap_or(false);
1238 }
1239 if opts.husks {
1240 hit |= stored
1241 .entry
1242 .files
1243 .iter()
1244 .any(|file| !stored.dir.join(&file.path).is_file());
1245 }
1246 hit
1247}
1248
1249fn view_selectors_hit(
1251 opts: &Options<'_>,
1252 unit: &Unit,
1253 arts: &ArtifactInfo,
1254 current: &str,
1255 limit: Option<Duration>,
1256 now: SystemTime,
1257) -> bool {
1258 let mut hit = false;
1259 if let Some(query) = opts.unit {
1260 hit |= who::unit_matches(unit, query);
1261 }
1262 if opts.stale {
1263 hit |= !current.is_empty() && who::rustc_of(unit) != current;
1264 }
1265 if let Some(key) = opts.rustc_key {
1266 hit |= who::rustc_of(unit).starts_with(key);
1267 }
1268 if opts.husks {
1269 hit |= arts.paths.is_empty();
1270 }
1271 if let Some(limit) = limit {
1272 hit |= arts
1273 .newest
1274 .map(|newest| now.duration_since(newest).map_or(true, |age| age > limit))
1275 .unwrap_or(false);
1276 }
1277 hit
1278}
1279
1280fn evict_generations(build_root: &Path, opts: &Options<'_>, query: &str) -> Result<()> {
1282 let store_root = crate::vault::store_root(build_root);
1283 if store_root.exists() {
1284 crate::vault::check_store_trust(&store_root)?;
1285 }
1286 let generations = crate::vault::generations(&store_root)?;
1287 let chosen: Vec<&crate::vault::GenerationInfo> = generations
1288 .iter()
1289 .filter(|generation| generation.parity.contains(query))
1290 .collect();
1291 if chosen.is_empty() {
1292 bail!("no vault generation matches {query:?} — `epicenter vault` lists them");
1293 }
1294 if !opts.force {
1295 if let Some(generation) = chosen.iter().find(|generation| generation.active) {
1296 bail!(
1297 "the active vault generation {} is protected — pass --force to delete it (the next build starts cold)",
1298 generation.parity
1299 );
1300 }
1301 }
1302
1303 let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1306 let mut files: Vec<PathBuf> = Vec::new();
1307 for generation in &chosen {
1308 names.extend(crate::vault::generation_unit_names(
1309 &store_root,
1310 &generation.parity,
1311 )?);
1312 collect_tree_files(&store_root.join(&generation.parity), &mut files);
1313 }
1314
1315 let mut views: Vec<(String, PathBuf, Vec<(Unit, ArtifactInfo)>)> = Vec::new();
1316 let mut skipped_views = Vec::new();
1317 if opts.tier.touches_view() {
1318 for (label, root) in known_views(build_root) {
1319 if crate::exec::dir_locked(&root) {
1320 skipped_views.push(label);
1321 continue;
1322 }
1323 let rows = who::collect(&root).with_context(|| format!("reading view {label}"))?;
1324 let units: Vec<(Unit, ArtifactInfo)> = rows
1325 .into_iter()
1326 .filter(|(unit, _)| names.contains(&unit.dir_name))
1327 .collect();
1328 if units.is_empty() {
1329 continue;
1330 }
1331 for (unit, arts) in &units {
1332 files.extend(view_unit_files(unit, arts));
1333 }
1334 views.push((label, root, units));
1335 }
1336 }
1337
1338 let (nominal, freed, released, count) = account_files(&files);
1339 println!(
1340 "{}",
1341 util::heading(if opts.delete {
1342 "epicenter evict — generations"
1343 } else {
1344 "epicenter evict — generations (DRY RUN — pass --yes to delete)"
1345 })
1346 );
1347 for generation in &chosen {
1348 println!(
1349 " {} {:<52} {:>5} unit(s) · {:>9}",
1350 util::dim("generation"),
1351 generation.parity,
1352 generation.units,
1353 human_bytes(generation.bytes)
1354 );
1355 }
1356 if !skipped_views.is_empty() {
1357 println!(
1358 " {} {} view(s) in use by a live build — skipped",
1359 util::warn(util::GLYPH_WARN),
1360 skipped_views.len()
1361 );
1362 }
1363 println!();
1364 println!(
1365 " {} {} generation(s) · {} unit(s) · {} nominal",
1366 util::dim("selection:"),
1367 chosen.len(),
1368 names.len(),
1369 util::green(&human_bytes(nominal))
1370 );
1371 println!(
1372 " {} {} actually freed · {} links released (still held elsewhere, e.g. a locked view)",
1373 util::dim("result: "),
1374 human_bytes(freed),
1375 human_bytes(released)
1376 );
1377 println!(
1378 " {} {} file(s) · {} view(s) planned",
1379 util::dim("files: "),
1380 count,
1381 views.len()
1382 );
1383 if !opts.delete {
1384 println!(
1385 " {}",
1386 util::dim("run again with -y or --yes to delete these generations")
1387 );
1388 return Ok(());
1389 }
1390
1391 let mut errors = 0u64;
1392 let mut unlinked_views = 0usize;
1393 for (label, root, units) in &views {
1394 if crate::exec::dir_locked(root) {
1395 errors += 1;
1396 eprintln!("view {label} became busy — skipped");
1397 continue;
1398 }
1399 let _job_lock = if let Some(name) = label.strip_prefix("work/") {
1400 match crate::util::FileLock::try_acquire(&crate::exec::job_lock_path(
1401 &crate::exec::work_root(build_root),
1402 name,
1403 )) {
1404 Ok(Some(lock)) => Some(lock),
1405 Ok(None) => {
1406 errors += 1;
1407 eprintln!("view {label} is in use by an epicenter process — skipped");
1408 continue;
1409 }
1410 Err(err) => {
1411 errors += 1;
1412 eprintln!("view {label} lock failed: {err:#} — skipped");
1413 continue;
1414 }
1415 }
1416 } else {
1417 None
1418 };
1419 for (unit, arts) in units {
1420 remove_view_unit(root, unit, arts, &mut errors);
1421 }
1422 unlinked_views += 1;
1423 }
1424
1425 let mut deleted = 0usize;
1426 for generation in &chosen {
1427 match crate::vault::drop_generation(&store_root, &generation.parity) {
1428 Ok(true) => deleted += 1,
1429 Ok(false) => {
1430 errors += 1;
1431 eprintln!(
1432 "generation {} is in use by a live harvest — skipped",
1433 generation.parity
1434 );
1435 }
1436 Err(err) => {
1437 errors += 1;
1438 eprintln!("could not remove generation {}: {err:#}", generation.parity);
1439 }
1440 }
1441 }
1442 crate::vault::sweep_store_debris(&store_root);
1443
1444 println!();
1445 println!(
1446 " {} {} generation(s) deleted · {} view(s) unlinked · {} actually freed · {} links released",
1447 util::green(util::GLYPH_PASS),
1448 deleted,
1449 unlinked_views,
1450 human_bytes(freed),
1451 human_bytes(released)
1452 );
1453 if errors > 0 {
1454 println!(
1455 " {} {} (see messages above)",
1456 util::fail(util::GLYPH_FAIL),
1457 errors
1458 );
1459 }
1460 println!(
1461 " {}",
1462 util::dim("`epicenter vault` shows what the store holds now")
1463 );
1464 Ok(())
1465}
1466
1467fn tier_summary(tier: Tier, views: &[String]) -> String {
1469 match tier {
1470 Tier::Store => "store — the vault only (views keep their links)".to_string(),
1471 Tier::View => format!("view — {} (the vault is untouched)", view_list(views)),
1472 Tier::All => format!("all — the vault + {}", view_list(views)),
1473 }
1474}
1475
1476fn view_list(views: &[String]) -> String {
1477 if views.is_empty() {
1478 return "no views".to_string();
1479 }
1480 let mut text = views.iter().take(4).cloned().collect::<Vec<_>>().join(", ");
1481 if views.len() > 4 {
1482 text.push_str(&format!(", … (+{} more)", views.len() - 4));
1483 }
1484 text
1485}
1486
1487fn evict_from_view(build_root: &Path, opts: &Options<'_>, work: &str) -> Result<()> {
1492 let root = resolve_view(build_root, work)?;
1493 let Some(_cargo_locks) = crate::exec::cargo_locks(&root)? else {
1495 bail!(
1496 "view {} is in use by a live build — unlink when it finishes",
1497 root.display()
1498 );
1499 };
1500 let vaulted: std::collections::BTreeSet<String> =
1501 crate::vault::stored_units(&crate::vault::store_root(build_root))?
1502 .into_iter()
1503 .map(|unit| unit.entry.name)
1504 .collect();
1505
1506 let rows = who::collect(&root).context("reading the view")?;
1507 let chosen: Vec<&(Unit, ArtifactInfo)> = rows
1508 .iter()
1509 .filter(|(unit, _)| surgery_unit_matches(unit, opts))
1510 .collect();
1511
1512 if opts.delete {
1513 println!(
1514 "{}",
1515 util::heading(&format!("epicenter evict --work {work} — unlinking"))
1516 );
1517 } else {
1518 println!(
1519 "{}",
1520 util::heading(&format!(
1521 "epicenter evict --work {work} — DRY RUN (nothing is unlinked; pass --yes)"
1522 ))
1523 );
1524 }
1525 println!(
1526 " {} {}",
1527 util::dim(&format!("{:<11}", "view")),
1528 root.display()
1529 );
1530 if chosen.is_empty() {
1531 println!(" nothing in this view matches — check `epicenter who --unit <q>`");
1532 return Ok(());
1533 }
1534
1535 let mut files = Vec::new();
1536 for (unit, arts) in &chosen {
1537 files.extend(view_unit_files(unit, arts));
1538 }
1539 let (nominal, freed, released, count) = account_files(&files);
1540
1541 for (unit, arts) in &chosen {
1542 println!(
1543 " {} {:<40} {:<9} {:>9} {}",
1544 util::green(util::GLYPH_PASS),
1545 unit.dir_name,
1546 unit.kind.label(),
1547 human_bytes(arts.bytes),
1548 if vaulted.contains(&unit.dir_name) {
1549 util::dim("in the vault — Cargo will rebuild it here")
1550 } else {
1551 util::warn("only in this view — its bytes go")
1552 }
1553 );
1554 }
1555 println!();
1556 println!(
1557 " {} {} unit(s) · {} file(s) · {} nominal",
1558 util::dim("selection:"),
1559 chosen.len(),
1560 count,
1561 util::green(&human_bytes(nominal))
1562 );
1563 println!(
1564 " {} {} actually freed · {} links released (still held elsewhere, e.g. the vault)",
1565 util::dim("result: "),
1566 human_bytes(freed),
1567 human_bytes(released)
1568 );
1569 if !opts.delete {
1570 println!(
1571 " {}",
1572 util::dim("run again with -y or --yes to unlink these units from this view")
1573 );
1574 return Ok(());
1575 }
1576
1577 let mut errors = 0u64;
1578 for (unit, arts) in &chosen {
1582 remove_view_unit(&root, unit, arts, &mut errors);
1583 }
1584 println!();
1585 println!(
1586 " {} {} unit(s) unlinked from {work} · {} actually freed · {} links released",
1587 util::green(util::GLYPH_PASS),
1588 chosen.len(),
1589 human_bytes(freed),
1590 human_bytes(released)
1591 );
1592 if errors > 0 {
1593 println!(
1594 " {} {} (see messages above)",
1595 util::fail(util::GLYPH_FAIL),
1596 errors
1597 );
1598 }
1599 println!(
1600 " {}",
1601 util::dim("Cargo will rebuild these units here; the vault is untouched")
1602 );
1603 Ok(())
1604}
1605
1606fn remove_dir(path: &Path, errors: &mut u64) {
1607 if let Err(e) = fs::remove_dir_all(path) {
1608 if e.kind() != ErrorKind::NotFound {
1609 *errors += 1;
1610 eprintln!("could not remove {}: {e}", path.display());
1611 }
1612 }
1613}
1614
1615fn remove_file(path: &Path, errors: &mut u64) {
1616 if let Err(e) = fs::remove_file(path) {
1617 if e.kind() != ErrorKind::NotFound {
1618 *errors += 1;
1619 eprintln!("could not remove {}: {e}", path.display());
1620 }
1621 }
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626 use super::*;
1627
1628 fn write_synthetic_unit(root: &Path) -> Result<()> {
1630 let unit_dir = root.join("debug/.fingerprint/pkg-1122334455667788");
1631 fs::create_dir_all(&unit_dir)?;
1632 fs::write(unit_dir.join("lib-pkg"), "0011223344556677")?;
1633 fs::write(unit_dir.join("dep-lib-pkg"), [0u8; 8])?;
1634 fs::create_dir_all(root.join("debug/deps"))?;
1635 fs::write(
1636 root.join("debug/deps/libpkg-1122334455667788.rlib"),
1637 [0u8; 16],
1638 )?;
1639 Ok(())
1640 }
1641
1642 fn write_synthetic_new_unit(root: &Path, package: &str, hash: &str) -> Result<PathBuf> {
1644 let unit = root.join("debug/build").join(package).join(hash);
1645 fs::create_dir_all(unit.join("fingerprint"))?;
1646 fs::create_dir_all(unit.join("out"))?;
1647 fs::write(
1648 unit.join("fingerprint").join(format!("lib-{package}")),
1649 "0011223344556677",
1650 )?;
1651 fs::write(
1652 unit.join("out").join(format!("lib{package}-{hash}.rlib")),
1653 [0u8; 16],
1654 )?;
1655 Ok(unit)
1656 }
1657
1658 #[test]
1659 fn removes_a_new_layout_unit_without_touching_siblings() -> Result<()> {
1660 let root = tempfile::tempdir()?;
1661 let one = write_synthetic_new_unit(root.path(), "anyhow", "1122334455667788")?;
1662 let two = write_synthetic_new_unit(root.path(), "anyhow", "8877665544332211")?;
1663
1664 let units = crate::fingerprint::read_units(root.path())?;
1665 let unit = units
1666 .iter()
1667 .find(|unit| unit.dir_suffix == "1122334455667788")
1668 .expect("new-layout unit");
1669 let arts = crate::fingerprint::artifacts_for(root.path(), unit);
1670 let mut errors = 0;
1671 remove_view_unit(root.path(), unit, &arts, &mut errors);
1672 assert_eq!(errors, 0);
1673 assert!(!one.exists(), "the selected unit dir is removed");
1674 assert!(
1675 two.is_dir(),
1676 "a sibling unit of the same package must survive"
1677 );
1678 Ok(())
1679 }
1680
1681 fn synthetic_home() -> Result<(tempfile::TempDir, PathBuf)> {
1684 let home = tempfile::tempdir()?;
1685 let build = home.path().join("build");
1686 fs::create_dir_all(&build)?;
1687 write_synthetic_unit(&build)?;
1688 Ok((home, build))
1689 }
1690
1691 fn no_selectors() -> Options<'static> {
1692 Options {
1693 unit: None,
1694 stale: false,
1695 rustc_key: None,
1696 generation: None,
1697 husks: false,
1698 older_than: None,
1699 work_dirs: false,
1700 work_dir: None,
1701 work: None,
1702 crate_name: None,
1703 tier: Tier::All,
1704 force: false,
1705 delete: false,
1706 }
1707 }
1708
1709 #[test]
1710 fn resolve_view_rejects_paths_outside_the_cache() -> Result<()> {
1711 let home = tier_home()?;
1712 let build = home.path().join("build");
1713 let job = home.path().join("work/ci-1");
1714 fs::create_dir_all(&job)?;
1715 assert_eq!(resolve_view(&build, "ci-1")?, job);
1716 let outside = tempfile::tempdir()?;
1717 assert!(resolve_view(&build, outside.path().to_str().unwrap()).is_err());
1718 assert!(resolve_view(&build, "../escape").is_err());
1719 assert!(resolve_view(&build, "/tmp/escape").is_err());
1720 assert!(resolve_view(&build, ".. ").is_err());
1721 assert!(resolve_view(&build, "CON.txt").is_err());
1722 Ok(())
1723 }
1724
1725 #[test]
1726 fn requires_at_least_one_selector() -> Result<()> {
1727 let (_home, build) = synthetic_home()?;
1728 assert!(run(&build, &no_selectors()).is_err());
1729 Ok(())
1730 }
1731
1732 #[test]
1733 fn stale_selector_skips_current_generation() -> Result<()> {
1734 let (_home, build) = synthetic_home()?;
1735 let opts = Options {
1736 stale: true,
1737 ..no_selectors()
1738 };
1739 run(&build, &opts)?;
1741 assert!(
1742 build
1743 .join("debug/.fingerprint/pkg-1122334455667788")
1744 .exists()
1745 );
1746 Ok(())
1747 }
1748
1749 #[test]
1750 fn evicts_fingerprint_and_artifacts_with_force() -> Result<()> {
1751 let (_home, build) = synthetic_home()?;
1752 let opts = Options {
1753 unit: Some("pkg-1122334455667788"),
1754 force: true,
1755 delete: true,
1756 ..no_selectors()
1757 };
1758 run(&build, &opts)?;
1759 assert!(
1760 !build
1761 .join("debug/.fingerprint/pkg-1122334455667788")
1762 .exists(),
1763 "fingerprint dir must be gone"
1764 );
1765 assert!(
1766 !build
1767 .join("debug/deps/libpkg-1122334455667788.rlib")
1768 .exists(),
1769 "artifact must be gone"
1770 );
1771 Ok(())
1772 }
1773
1774 use std::time::Duration;
1775
1776 fn tier_home() -> Result<tempfile::TempDir> {
1778 let home = tempfile::tempdir()?;
1779 fs::create_dir_all(home.path().join("build"))?;
1780 fs::create_dir_all(home.path().join("work"))?;
1781 Ok(home)
1782 }
1783
1784 fn aged_job(home: &Path, name: &str, age_days: u64) -> Result<PathBuf> {
1786 let debug = home.join("work").join(name).join("debug");
1787 fs::create_dir_all(&debug)?;
1788 let artifact = debug.join("libjob.rlib");
1789 fs::write(&artifact, [0u8; 64])?;
1790 if age_days > 0 {
1791 let when = SystemTime::now() - Duration::from_secs(age_days * 86_400);
1792 fs::OpenOptions::new()
1793 .write(true)
1794 .open(&artifact)?
1795 .set_modified(when)?;
1796 }
1797 Ok(home.join("work").join(name))
1798 }
1799
1800 #[test]
1801 fn work_dirs_collect_by_age_and_keep_young_ones() -> Result<()> {
1802 let home = tier_home()?;
1803 let build = home.path().join("build");
1804 let old_job = aged_job(home.path(), "old-job", 30)?;
1805 let fresh_job = aged_job(home.path(), "fresh-job", 0)?;
1806
1807 let dry = evict_work_dirs(&build, None, false)?;
1808 assert_eq!((dry.scanned, dry.eligible, dry.young), (2, 1, 1));
1809 assert!(
1810 old_job.exists() && fresh_job.exists(),
1811 "dry run touches nothing"
1812 );
1813
1814 fs::write(home.path().join("work/.lock-old-job"), b"")?;
1815 fs::write(home.path().join("work/.lock-fresh-job"), b"")?;
1816 fs::write(home.path().join("work/.lock-orphan"), b"")?;
1817
1818 let done = evict_work_dirs(&build, None, true)?;
1819 assert_eq!(done.removed, 1);
1820 assert_eq!(done.unique_bytes, 64, "unique bytes are actually freed");
1821 assert!(!old_job.exists(), "aged job dir collected");
1822 assert!(fresh_job.exists(), "young job dir kept");
1823 assert!(
1824 !home.path().join("work/.lock-old-job").exists(),
1825 "the collected job's lock is removed"
1826 );
1827 assert!(
1828 home.path().join("work/.lock-fresh-job").is_file(),
1829 "the kept job's lock stays"
1830 );
1831 assert!(
1832 !home.path().join("work/.lock-orphan").exists(),
1833 "orphan lock debris is swept"
1834 );
1835 Ok(())
1836 }
1837
1838 #[test]
1839 fn work_dirs_never_collect_locked_jobs() -> Result<()> {
1840 let home = tier_home()?;
1841 let build = home.path().join("build");
1842 let job = aged_job(home.path(), "locked-job", 30)?;
1843 let lock_path = job.join("debug/.cargo-build-lock");
1844 fs::write(&lock_path, b"")?;
1845 let when = SystemTime::now() - Duration::from_secs(30 * 86_400);
1847 fs::OpenOptions::new()
1848 .write(true)
1849 .open(&lock_path)?
1850 .set_modified(when)?;
1851
1852 let held = fs::OpenOptions::new().write(true).open(&lock_path)?;
1853 held.try_lock().context("test holds the lock")?;
1854
1855 let report = evict_work_dirs(&build, None, true)?;
1856 assert_eq!((report.in_use, report.removed), (1, 0));
1857 assert!(job.exists(), "a locked job dir is never touched");
1858
1859 held.unlock()?;
1860 drop(held);
1861 let report = evict_work_dirs(&build, None, true)?;
1862 assert_eq!(report.removed, 1);
1863 assert!(!job.exists());
1864 Ok(())
1865 }
1866
1867 #[test]
1868 fn work_dirs_cannot_be_combined_with_unit_selectors() -> Result<()> {
1869 let home = tier_home()?;
1870 let opts = Options {
1871 work_dirs: true,
1872 stale: true,
1873 ..no_selectors()
1874 };
1875 assert!(run(&home.path().join("build"), &opts).is_err());
1876 Ok(())
1877 }
1878
1879 #[test]
1880 fn work_dir_accounting_separates_unique_and_shared_bytes() -> Result<()> {
1881 let home = tier_home()?;
1882 let debug = home.path().join("work/aged-job/debug");
1883 fs::create_dir_all(&debug)?;
1884 let unique = debug.join("unique.rlib");
1885 let shared = debug.join("shared.rlib");
1886 fs::write(&unique, [0u8; 100])?;
1887 fs::write(&shared, [0u8; 200])?;
1888 fs::hard_link(&shared, home.path().join("store-copy.rlib"))?;
1890 let when = SystemTime::now() - Duration::from_secs(30 * 86_400);
1891 fs::OpenOptions::new()
1892 .write(true)
1893 .open(&unique)?
1894 .set_modified(when)?;
1895 fs::OpenOptions::new()
1896 .write(true)
1897 .open(&shared)?
1898 .set_modified(when)?;
1899
1900 let report = evict_work_dirs(&home.path().join("build"), None, false)?;
1901 assert_eq!(report.unique_bytes, 100);
1902 assert_eq!(report.shared_bytes, 200);
1903 assert_eq!(report.nominal_bytes, 300);
1904 Ok(())
1905 }
1906
1907 #[test]
1908 fn work_dir_removal_ignores_the_age_window() -> Result<()> {
1909 let home = tier_home()?;
1910 let build = home.path().join("build");
1911 let job = aged_job(home.path(), "fresh-job", 0)?;
1912 fs::write(home.path().join("work/.lock-fresh-job"), b"")?;
1913 fs::write(home.path().join("work/.lock-orphan"), b"")?;
1914 let opts = Options {
1915 work_dir: Some("fresh-job"),
1916 delete: true,
1917 ..no_selectors()
1918 };
1919 run(&build, &opts)?;
1920 assert!(
1921 !job.exists(),
1922 "an explicitly selected dir goes regardless of age"
1923 );
1924 assert!(
1925 !home.path().join("work/.lock-fresh-job").exists(),
1926 "the removed job's lock is cleaned"
1927 );
1928 assert!(
1929 !home.path().join("work/.lock-orphan").exists(),
1930 "orphan lock debris is swept"
1931 );
1932 Ok(())
1933 }
1934
1935 #[test]
1936 fn work_dir_refuses_locked_and_unknown_targets() -> Result<()> {
1937 let home = tier_home()?;
1938 let build = home.path().join("build");
1939 let job = aged_job(home.path(), "locked-job", 0)?;
1940 let lock_path = job.join("debug/.cargo-build-lock");
1941 fs::write(&lock_path, b"")?;
1942 let held = fs::OpenOptions::new().write(true).open(&lock_path)?;
1943 held.try_lock().context("test holds the lock")?;
1944
1945 let opts = Options {
1946 work_dir: Some("locked-job"),
1947 delete: true,
1948 ..no_selectors()
1949 };
1950 let err = run(&build, &opts).unwrap_err();
1951 assert!(err.to_string().contains("in use"), "{err}");
1952 assert!(job.exists(), "a locked job dir survives");
1953 drop(held);
1954
1955 let unknown = Options {
1956 work_dir: Some("nope"),
1957 ..no_selectors()
1958 };
1959 assert!(run(&build, &unknown).is_err());
1960 Ok(())
1961 }
1962
1963 #[test]
1964 fn work_dir_never_targets_the_shared_build_or_outside_paths() -> Result<()> {
1965 let home = tier_home()?;
1966 let build = home.path().join("build");
1967 let shared = Options {
1968 work_dir: Some("build"),
1969 ..no_selectors()
1970 };
1971 let err = run(&build, &shared).unwrap_err();
1972 assert!(err.to_string().contains("never removed"), "{err}");
1973
1974 let outside = Options {
1975 work_dir: Some("/tmp"),
1976 ..no_selectors()
1977 };
1978 assert!(
1979 run(&build, &outside).is_err(),
1980 "only job dirs under work/ — /tmp is refused"
1981 );
1982 Ok(())
1983 }
1984
1985 #[test]
1986 fn work_dir_rejects_selectors_and_sibling_modes() -> Result<()> {
1987 let home = tier_home()?;
1988 let build = home.path().join("build");
1989 let with_age = Options {
1990 work_dir: Some("x"),
1991 older_than: Some("30d"),
1992 ..no_selectors()
1993 };
1994 assert!(run(&build, &with_age).is_err());
1995
1996 let with_dirs = Options {
1997 work_dir: Some("x"),
1998 work_dirs: true,
1999 ..no_selectors()
2000 };
2001 let err = run(&build, &with_dirs).unwrap_err();
2002 assert!(err.to_string().contains("one operation"), "{err}");
2003 Ok(())
2004 }
2005
2006 fn tiered_views() -> Result<(tempfile::TempDir, PathBuf)> {
2008 let home = tempfile::tempdir()?;
2009 fs::create_dir_all(home.path().join("build"))?;
2010 fs::create_dir_all(home.path().join("work"))?;
2011 write_synthetic_unit(&home.path().join("build"))?;
2012 let job = home.path().join("work/ci-1");
2013 write_synthetic_unit(&job)?;
2014 Ok((home, job))
2015 }
2016
2017 #[test]
2018 fn work_surgery_unlinks_only_the_named_view() -> Result<()> {
2019 let (home, job) = tiered_views()?;
2020 let build = home.path().join("build");
2021 let opts = Options {
2022 work: Some("ci-1"),
2023 crate_name: Some("pkg"),
2024 delete: true,
2025 ..no_selectors()
2026 };
2027 run(&build, &opts)?;
2028 assert!(
2029 !job.join("debug/.fingerprint/pkg-1122334455667788").exists(),
2030 "job fingerprint unlinked"
2031 );
2032 assert!(
2033 !job.join("debug/deps/libpkg-1122334455667788.rlib").exists(),
2034 "job artifact unlinked"
2035 );
2036 assert!(
2037 build
2038 .join("debug/.fingerprint/pkg-1122334455667788")
2039 .exists(),
2040 "build view untouched"
2041 );
2042 assert!(
2043 build
2044 .join("debug/deps/libpkg-1122334455667788.rlib")
2045 .exists(),
2046 "build artifact untouched"
2047 );
2048 assert!(!home.path().join("store").exists(), "no store was touched");
2049 Ok(())
2050 }
2051
2052 #[test]
2053 fn work_surgery_holds_the_cargo_lock_without_self_contention() -> Result<()> {
2054 let (home, job) = tiered_views()?;
2055 fs::write(job.join("debug/.cargo-build-lock"), b"")?;
2059 let build = home.path().join("build");
2060 let opts = Options {
2061 work: Some("ci-1"),
2062 crate_name: Some("pkg"),
2063 delete: true,
2064 ..no_selectors()
2065 };
2066 run(&build, &opts)?;
2067 assert!(
2068 !job.join("debug/.fingerprint/pkg-1122334455667788").exists(),
2069 "the unit is unlinked while the cargo lock is held"
2070 );
2071 Ok(())
2072 }
2073
2074 #[test]
2075 fn work_surgery_accepts_the_shared_build_view() -> Result<()> {
2076 let (home, job) = tiered_views()?;
2077 let build = home.path().join("build");
2078 let opts = Options {
2079 work: Some("build"),
2080 unit: Some("pkg"),
2081 delete: true,
2082 ..no_selectors()
2083 };
2084 run(&build, &opts)?;
2085 assert!(
2086 !build
2087 .join("debug/.fingerprint/pkg-1122334455667788")
2088 .exists()
2089 );
2090 assert!(job.join("debug/.fingerprint/pkg-1122334455667788").exists());
2091 Ok(())
2092 }
2093
2094 #[test]
2095 fn work_surgery_refuses_a_locked_view() -> Result<()> {
2096 let (home, job) = tiered_views()?;
2097 let build = home.path().join("build");
2098 let lock_path = job.join("debug/.cargo-build-lock");
2099 fs::write(&lock_path, b"")?;
2100 let held = fs::OpenOptions::new().write(true).open(&lock_path)?;
2101 held.try_lock().context("test holds the lock")?;
2102 let opts = Options {
2103 work: Some("ci-1"),
2104 crate_name: Some("pkg"),
2105 delete: true,
2106 ..no_selectors()
2107 };
2108 let err = run(&build, &opts).unwrap_err();
2109 assert!(err.to_string().contains("in use"), "{err}");
2110 assert!(job.join("debug/.fingerprint/pkg-1122334455667788").exists());
2111 Ok(())
2112 }
2113
2114 #[test]
2115 fn work_surgery_requires_a_selector_and_rejects_bulk_ones() -> Result<()> {
2116 let (home, _job) = tiered_views()?;
2117 let build = home.path().join("build");
2118 let bare = Options {
2119 work: Some("ci-1"),
2120 ..no_selectors()
2121 };
2122 assert!(run(&build, &bare).is_err(), "needs --crate/--unit");
2123 let bulk = Options {
2124 work: Some("ci-1"),
2125 stale: true,
2126 ..no_selectors()
2127 };
2128 let err = run(&build, &bulk).unwrap_err();
2129 assert!(err.to_string().contains("--crate"), "{err}");
2130 Ok(())
2131 }
2132
2133 #[test]
2134 fn work_surgery_unknown_view_errors() -> Result<()> {
2135 let (home, _job) = tiered_views()?;
2136 let build = home.path().join("build");
2137 let opts = Options {
2138 work: Some("nope"),
2139 crate_name: Some("pkg"),
2140 ..no_selectors()
2141 };
2142 assert!(run(&build, &opts).is_err());
2143 Ok(())
2144 }
2145
2146 fn harvested_home() -> Result<(tempfile::TempDir, PathBuf)> {
2149 let (home, build) = synthetic_home()?;
2150 crate::vault::harvest(&build, &home.path().join("store"), None, false)?;
2151 Ok((home, build))
2152 }
2153
2154 #[test]
2155 fn store_tier_drops_groups_and_prunes_the_manifest() -> Result<()> {
2156 let (home, build) = harvested_home()?;
2157 let store = home.path().join("store");
2158 assert_eq!(crate::vault::stored_units(&store)?.len(), 1, "harvested");
2159
2160 let opts = Options {
2162 unit: Some("pkg"),
2163 tier: Tier::Store,
2164 delete: true,
2165 ..no_selectors()
2166 };
2167 run(&build, &opts)?;
2168 assert_eq!(
2169 crate::vault::stored_units(&store)?.len(),
2170 0,
2171 "store record gone"
2172 );
2173 let parity = crate::vault::parity_key()?;
2174 assert!(
2175 !store.join(&parity).exists(),
2176 "the emptied parity-key store is removed"
2177 );
2178 assert!(
2179 !store.join(format!(".lock-{parity}")).exists(),
2180 "its generation lock is removed too"
2181 );
2182 assert!(
2183 build
2184 .join("debug/.fingerprint/pkg-1122334455667788")
2185 .exists(),
2186 "the view keeps its links"
2187 );
2188 assert!(
2189 build
2190 .join("debug/deps/libpkg-1122334455667788.rlib")
2191 .exists(),
2192 "the view keeps its artifact"
2193 );
2194 Ok(())
2195 }
2196
2197 #[test]
2198 fn generation_selector_deletes_the_parity_dir_and_lock() -> Result<()> {
2199 let (home, build) = harvested_home()?;
2200 let store = home.path().join("store");
2201 let parity = crate::vault::parity_key()?;
2202 let generation = store.join(&parity);
2203 let lock = store.join(format!(".lock-{parity}"));
2204 assert!(
2205 generation.is_dir() && lock.is_file(),
2206 "harvested generation"
2207 );
2208
2209 let opts = Options {
2210 generation: Some(&parity),
2211 force: true,
2212 delete: true,
2213 ..no_selectors()
2214 };
2215 run(&build, &opts)?;
2216 assert!(!generation.exists(), "parity-key store deleted");
2217 assert!(!lock.exists(), "generation lock deleted");
2218 assert!(crate::vault::generations(&store)?.is_empty());
2219 Ok(())
2220 }
2221
2222 #[test]
2223 fn generation_selector_protects_the_active_generation() -> Result<()> {
2224 let (home, build) = harvested_home()?;
2225 let store = home.path().join("store");
2226 let parity = crate::vault::parity_key()?;
2227 let opts = Options {
2228 generation: Some(&parity),
2229 delete: true,
2230 ..no_selectors()
2231 };
2232 let err = run(&build, &opts).unwrap_err();
2233 assert!(err.to_string().contains("protected"), "{err}");
2234 assert!(store.join(&parity).is_dir(), "nothing was deleted");
2235 Ok(())
2236 }
2237
2238 #[test]
2239 fn debris_sweep_removes_orphan_locks_and_empty_generations() -> Result<()> {
2240 let (home, _build) = harvested_home()?;
2241 let store = home.path().join("store");
2242 let parity = crate::vault::parity_key()?;
2243 fs::write(store.join(".lock-9.9.9-deadbeef"), b"")?;
2244 fs::create_dir_all(store.join("9.9.9-deadbeef/units"))?;
2245
2246 crate::vault::sweep_store_debris(&store);
2247 assert!(!store.join(".lock-9.9.9-deadbeef").exists());
2248 assert!(!store.join("9.9.9-deadbeef").exists());
2249 assert!(store.join(&parity).is_dir(), "the live generation is kept");
2250 assert!(
2251 store.join(format!(".lock-{parity}")).is_file(),
2252 "its lock is kept"
2253 );
2254 Ok(())
2255 }
2256
2257 #[test]
2258 fn view_tier_leaves_the_store_alone() -> Result<()> {
2259 let (home, build) = harvested_home()?;
2260 let store = home.path().join("store");
2261 let opts = Options {
2262 unit: Some("pkg"),
2263 tier: Tier::View,
2264 force: true,
2265 delete: true,
2266 ..no_selectors()
2267 };
2268 run(&build, &opts)?;
2269 assert!(
2270 !build
2271 .join("debug/.fingerprint/pkg-1122334455667788")
2272 .exists()
2273 );
2274 assert_eq!(
2275 crate::vault::stored_units(&store)?.len(),
2276 1,
2277 "store record stays"
2278 );
2279 Ok(())
2280 }
2281
2282 #[test]
2283 fn default_tier_removes_store_and_view_together() -> Result<()> {
2284 let (home, build) = harvested_home()?;
2285 let store = home.path().join("store");
2286 let opts = Options {
2287 unit: Some("pkg"),
2288 force: true,
2289 delete: true,
2290 ..no_selectors()
2291 };
2292 run(&build, &opts)?;
2293 assert_eq!(crate::vault::stored_units(&store)?.len(), 0);
2294 assert!(
2295 !build
2296 .join("debug/.fingerprint/pkg-1122334455667788")
2297 .exists()
2298 );
2299 assert!(
2300 !build
2301 .join("debug/deps/libpkg-1122334455667788.rlib")
2302 .exists()
2303 );
2304 Ok(())
2305 }
2306
2307 #[test]
2308 fn current_generation_is_protected_for_views() -> Result<()> {
2309 let (home, build) = harvested_home()?;
2310 let store = home.path().join("store");
2311 let opts = Options {
2312 older_than: Some("0s"),
2313 delete: true,
2314 ..no_selectors()
2315 };
2316 run(&build, &opts)?;
2317 assert!(
2318 build
2319 .join("debug/.fingerprint/pkg-1122334455667788")
2320 .exists(),
2321 "a bulk selector protects the current unit without --force"
2322 );
2323 assert_eq!(crate::vault::stored_units(&store)?.len(), 1);
2324 Ok(())
2325 }
2326
2327 #[test]
2328 fn explicit_unit_bypasses_the_current_generation_guard() -> Result<()> {
2329 let (home, build) = harvested_home()?;
2330 let store = home.path().join("store");
2331 let opts = Options {
2332 unit: Some("pkg"),
2333 delete: true,
2334 ..no_selectors()
2335 };
2336 run(&build, &opts)?;
2337 assert!(
2338 !build
2339 .join("debug/.fingerprint/pkg-1122334455667788")
2340 .exists(),
2341 "an explicit --unit is deliberate: it needs no --force"
2342 );
2343 assert_eq!(crate::vault::stored_units(&store)?.len(), 0);
2344 Ok(())
2345 }
2346
2347 #[test]
2348 fn stale_drops_non_active_generations_from_the_store() -> Result<()> {
2349 let (home, build) = harvested_home()?;
2350 let store = home.path().join("store");
2351 let old = store.join("0.0.0-deadbeef-x86_64-unknown-linux-gnu-00000000");
2352 let unit = old.join("units/oldpkg-0000111122223333");
2353 fs::create_dir_all(&unit)?;
2354 fs::write(unit.join("lib-old"), "aaaaaaaabbbbbbbb")?;
2355 let manifest = crate::vault::Manifest {
2356 parity_key: "0.0.0-deadbeef-x86_64-unknown-linux-gnu-00000000".into(),
2357 toolchain: "rustc 0.0.0".into(),
2358 created: 1,
2359 updated: 1,
2360 units: vec![crate::vault::UnitEntry {
2361 name: "oldpkg-0000111122223333".into(),
2362 profile: "debug".into(),
2363 memo: "lib-old".into(),
2364 hash: "aaaaaaaabbbbbbbb".into(),
2365 stamp: 1,
2366 layout: None,
2367 files: vec![crate::vault::FileEntry {
2368 path: "lib-old".into(),
2369 dest: ".fingerprint/oldpkg-0000111122223333/lib-old".into(),
2370 size: 16,
2371 sha256: "0".repeat(64),
2372 role: "memo".into(),
2373 }],
2374 }],
2375 mac: String::new(),
2376 };
2377 crate::vault::save_manifest(
2378 &store,
2379 "0.0.0-deadbeef-x86_64-unknown-linux-gnu-00000000",
2380 &manifest,
2381 )?;
2382
2383 let opts = Options {
2384 stale: true,
2385 tier: Tier::Store,
2386 delete: true,
2387 ..no_selectors()
2388 };
2389 run(&build, &opts)?;
2390 assert!(!old.exists(), "the old generation is gone");
2391 assert_eq!(
2392 crate::vault::stored_units(&store)?.len(),
2393 1,
2394 "the active generation survives"
2395 );
2396 Ok(())
2397 }
2398
2399 #[test]
2400 fn accounting_frees_only_inodes_whose_last_link_goes() -> Result<()> {
2401 let home = tempfile::tempdir()?;
2402 let a = home.path().join("a.bin");
2403 let b = home.path().join("b.bin");
2404 let store_copy = home.path().join("store-copy.bin");
2405 fs::write(&a, [0u8; 100])?;
2406 fs::write(&b, [0u8; 200])?;
2407 fs::hard_link(&b, &store_copy)?;
2408
2409 let (nominal, freed, released, count) =
2411 account_files(&[a.clone(), b.clone(), store_copy.clone()]);
2412 assert_eq!((nominal, freed, released, count), (300, 300, 0, 3));
2413
2414 let (nominal, freed, released, count) = account_files(&[a, b]);
2416 assert_eq!((nominal, freed, released, count), (300, 100, 200, 2));
2417 Ok(())
2418 }
2419}