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