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 husks: bool,
33 pub older_than: Option<&'a str>,
35 pub work_dirs: bool,
38 pub work_dir: Option<&'a str>,
41 pub work: Option<&'a str>,
44 pub crate_name: Option<&'a str>,
46 pub tier: Tier,
48 pub force: bool,
50 pub delete: bool,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
57pub enum Tier {
58 All,
60 Store,
62 View,
64}
65
66impl Tier {
67 fn touches_store(self) -> bool {
68 matches!(self, Tier::All | Tier::Store)
69 }
70
71 fn touches_view(self) -> bool {
72 matches!(self, Tier::All | Tier::View)
73 }
74}
75
76pub const DEFAULT_WORK_MAX_AGE: &str = "7d";
78
79pub fn run(build_root: &Path, opts: &Options<'_>) -> Result<()> {
81 #[cfg(windows)]
82 crate::util::ensure_windows_user_path(&crate::exec::work_root(build_root), "work root")?;
83 for (name, value) in [
85 ("--unit", opts.unit),
86 ("--rustc-key", opts.rustc_key),
87 ("--crate", opts.crate_name),
88 ] {
89 if value.is_some_and(|value| value.is_empty()) {
90 bail!("{name} must not be empty — use an explicit selector or a dry run");
91 }
92 }
93 if let Some(target) = opts.work_dir {
94 if opts.work_dirs || opts.work.is_some() {
95 bail!(
96 "one operation at a time — --work-dir removes ONE job dir:\n\
97 --work-dirs collects the whole tier by age, --work unlinks units inside a view"
98 );
99 }
100 if opts.unit.is_some()
101 || opts.crate_name.is_some()
102 || opts.stale
103 || opts.rustc_key.is_some()
104 || opts.husks
105 || opts.older_than.is_some()
106 {
107 bail!(
108 "--work-dir removes exactly the named job dir and takes no selectors:\n\
109 e.g. `epicenter evict --work-dir ci-1` (dry run) then --yes"
110 );
111 }
112 if opts.tier != Tier::All {
113 bail!("--tier does not apply to --work-dir — a job dir is one view, removed whole");
114 }
115 return evict_work_dir(build_root, target, opts.delete);
116 }
117
118 if opts.work_dirs {
119 if opts.work.is_some()
120 || opts.unit.is_some()
121 || opts.stale
122 || opts.rustc_key.is_some()
123 || opts.husks
124 {
125 bail!(
126 "--work-dirs collects whole stale job dirs — it takes no unit selectors:\n\
127 run `epicenter evict --work-dirs` on its own, or drop --work-dirs"
128 );
129 }
130 evict_work_dirs(build_root, opts.older_than, opts.delete)?;
131 return Ok(());
132 }
133
134 if let Some(work) = opts.work {
135 if opts.stale || opts.rustc_key.is_some() || opts.husks || opts.older_than.is_some() {
136 bail!(
137 "--work unlinks units from ONE view — only --crate / --unit apply:\n\
138 for bulk view work use `--tier view`, for whole job dirs use --work-dirs"
139 );
140 }
141 if opts.unit.is_none() && opts.crate_name.is_none() {
142 bail!(
143 "nothing selected — --work needs --crate <name> or --unit <hash>:\n\
144 e.g. `epicenter evict --work ci-1 --crate serde` (dry run unless --yes)"
145 );
146 }
147 if opts.tier != Tier::All {
148 bail!(
149 "--tier does not apply to --work — that operation is always view-only:\n\
150 the vault is never touched by it"
151 );
152 }
153 return evict_from_view(build_root, opts, work);
154 }
155
156 if opts.unit.is_none()
157 && !opts.stale
158 && opts.rustc_key.is_none()
159 && !opts.husks
160 && opts.older_than.is_none()
161 {
162 bail!(
163 "nothing selected — pass one selector:\n\
164 --unit <hash|package> a specific unit\n\
165 --stale every unit from an older toolchain\n\
166 --rustc-key <hash> units matching a toolchain identity\n\
167 --husks units whose artifacts are already gone\n\
168 --older-than <age> units untouched for longer (e.g. 90d)\n\
169 --work-dirs stale per-job working dirs (default 7d)\n\
170 dry run unless you pass --yes — full help: `epicenter evict --help`"
171 );
172 }
173
174 let store_root = crate::vault::store_root(build_root);
175 let active = crate::vault::parity_key().ok();
176 let age_limit = opts
177 .older_than
178 .map(parse_duration)
179 .transpose()
180 .context("invalid value for --older-than")?;
181 let now = SystemTime::now();
182
183 let mut candidates: BTreeMap<String, Candidate> = BTreeMap::new();
184 let mut view_labels: Vec<String> = Vec::new();
185 let mut skipped_views: Vec<String> = Vec::new();
186
187 if opts.tier.touches_store() {
190 for stored in crate::vault::stored_units(&store_root)? {
191 if !store_selectors_hit(opts, &stored, active.as_deref(), age_limit, now) {
192 continue;
193 }
194 let mut files = Vec::new();
195 collect_tree_files(&stored.dir, &mut files);
196 let current = active.as_deref() == Some(stored.generation.as_str());
197 let candidate = candidate_for(&mut candidates, &stored.entry.name);
198 candidate.kind = fingerprint::classify_kind(&stored.entry.memo)
199 .label()
200 .to_string();
201 candidate.current |= current;
202 candidate.newest = max_time(candidate.newest, stamp_time(stored.entry.stamp));
203 push_place(
204 candidate,
205 Place::Store {
206 generation: stored.generation.clone(),
207 files,
208 },
209 );
210 }
211 }
212
213 if opts.tier.touches_view() {
215 for (label, root) in known_views(build_root) {
216 if crate::exec::dir_locked(&root) {
217 skipped_views.push(label);
218 continue;
219 }
220 let rows = who::collect(&root).with_context(|| format!("reading view {label}"))?;
221 let current = active_cohort(&rows);
222 for (unit, arts) in rows {
223 if !view_selectors_hit(opts, &unit, &arts, ¤t, age_limit, now) {
224 continue;
225 }
226 if !view_labels.contains(&label) {
227 view_labels.push(label.clone());
228 }
229 let files = view_unit_files(&root, &unit, &arts);
230 let artifacts: Vec<PathBuf> =
231 arts.paths.iter().map(|(path, _, _)| path.clone()).collect();
232 let candidate = candidate_for(&mut candidates, &unit.dir_name);
233 candidate.kind = unit.kind.label().to_string();
234 candidate.current |= !current.is_empty() && who::rustc_of(&unit) == current;
235 candidate.newest = max_time(candidate.newest, arts.newest);
236 push_place(
237 candidate,
238 Place::View {
239 label: label.clone(),
240 root: root.clone(),
241 unit_dir: root
242 .join(&unit.profile)
243 .join(".fingerprint")
244 .join(&unit.dir_name),
245 anchors: fingerprint::build_anchor_dirs(&root, &unit),
246 artifacts,
247 files,
248 },
249 );
250 }
251 }
252 }
253
254 if opts.delete {
255 println!("{}", util::heading("epicenter evict — removing"));
256 } else {
257 println!(
258 "{}",
259 util::heading("epicenter evict — DRY RUN (nothing is removed; pass --yes to delete)")
260 );
261 }
262
263 let mut protected = 0usize;
264 let mut chosen: Vec<Candidate> = Vec::new();
265 for (_, candidate) in candidates {
266 if candidate.current && !opts.force && opts.tier.touches_view() {
269 protected += 1;
270 continue;
271 }
272 chosen.push(candidate);
273 }
274 chosen.sort_by_key(|candidate| std::cmp::Reverse(candidate.bytes));
275
276 if chosen.is_empty() {
277 println!(" nothing matches the given selectors");
278 if protected > 0 {
279 println!(
280 " {} {protected} current-generation unit(s) matched but are protected — use --force to include them",
281 util::warn(util::GLYPH_WARN)
282 );
283 }
284 return Ok(());
285 }
286
287 for candidate in &chosen {
288 let mut labels: Vec<&str> = candidate.places.iter().map(Place::label).collect();
289 labels.sort_unstable();
290 labels.dedup();
291 println!(
292 " {} {:<38} {:<9} {:>9} {:>10} old {}{}",
293 util::green(util::GLYPH_PASS),
294 candidate.name,
295 candidate.kind,
296 human_bytes(candidate.bytes),
297 candidate
298 .newest
299 .map(human_age)
300 .unwrap_or_else(|| "?".into()),
301 labels.join(" + "),
302 if candidate.current && opts.force {
303 util::dim(" (current — forced)")
304 } else if candidate.current {
305 util::dim(" (current)")
306 } else {
307 String::new()
308 },
309 );
310 }
311
312 let all_files: Vec<PathBuf> = chosen
313 .iter()
314 .flat_map(|candidate| candidate.places.iter())
315 .flat_map(|place| place.files().iter().cloned())
316 .collect();
317 let (nominal, freed, released, count) = account_files(&all_files);
318 let places: usize = chosen.iter().map(|candidate| candidate.places.len()).sum();
319
320 println!();
321 println!(
322 " {} {} unit(s) · {} place(s) · {} file(s) · {} nominal",
323 util::dim("selection:"),
324 chosen.len(),
325 places,
326 count,
327 util::green(&human_bytes(nominal))
328 );
329 println!(
330 " {} {} actually freed · {} links released",
331 util::dim("result: "),
332 human_bytes(freed),
333 human_bytes(released)
334 );
335 println!(
336 " {} {}",
337 util::dim("tier: "),
338 tier_summary(opts.tier, &view_labels)
339 );
340 if protected > 0 {
341 println!(
342 " {} {protected} current-generation unit(s) skipped — use --force to include them",
343 util::warn(util::GLYPH_WARN)
344 );
345 }
346 for label in &skipped_views {
347 println!(
348 " {} view {label} is in use by a live build — skipped",
349 util::warn(util::GLYPH_WARN)
350 );
351 }
352 if !opts.delete {
353 println!(
354 " {}",
355 util::dim("run again with -y or --yes to remove them")
356 );
357 return Ok(());
358 }
359
360 let mut errors = 0u64;
362
363 let mut per_generation: BTreeMap<String, Vec<String>> = BTreeMap::new();
364 for candidate in &chosen {
365 for place in &candidate.places {
366 if let Place::Store { generation, .. } = place {
367 per_generation
368 .entry(generation.clone())
369 .or_default()
370 .push(candidate.name.clone());
371 }
372 }
373 }
374 let mut store_dropped = 0usize;
375 for (generation, names) in &per_generation {
376 match crate::vault::drop_units(&store_root, generation, names) {
377 Ok(dropped) => store_dropped += dropped,
378 Err(err) => {
379 errors += 1;
380 eprintln!("could not prune the vault: {err}");
381 }
382 }
383 }
384
385 let mut view_counts: BTreeMap<String, usize> = BTreeMap::new();
386 for candidate in &chosen {
387 for place in &candidate.places {
388 if let Place::View {
389 label,
390 root,
391 unit_dir,
392 anchors,
393 artifacts,
394 ..
395 } = place
396 {
397 let _job_lock = match label.strip_prefix("work/") {
400 Some(name) => match crate::util::FileLock::try_acquire(
401 &crate::exec::job_lock_path(
402 &crate::exec::work_root(&crate::vault::store_root(
403 &crate::config::trusted_cache_root()?,
404 )),
405 name,
406 ),
407 ) {
408 Ok(Some(lock)) => Some(lock),
409 Ok(None) => {
410 errors += 1;
411 eprintln!("view {label} is in use by an epicenter process — skipped");
412 continue;
413 }
414 Err(err) => {
415 errors += 1;
416 eprintln!("view {label} lock failed: {err:#} — skipped");
417 continue;
418 }
419 },
420 None => None,
421 };
422 if crate::exec::dir_locked(root) {
423 errors += 1;
424 eprintln!("view {label} is in use by a live build — skipped");
425 continue;
426 }
427 remove_dir(unit_dir, &mut errors);
428 for anchor in anchors {
429 if anchor.is_dir() {
430 remove_dir(anchor, &mut errors);
431 }
432 }
433 for artifact in artifacts {
434 remove_file(artifact, &mut errors);
435 }
436 *view_counts.entry(label.clone()).or_default() += 1;
437 }
438 }
439 }
440
441 println!();
442 println!(
443 " {} {} unit(s) evicted · {} actually freed · {} links released",
444 util::green(util::GLYPH_PASS),
445 chosen.len(),
446 human_bytes(freed),
447 human_bytes(released)
448 );
449 if store_dropped > 0 {
450 println!(
451 " {:<11} {} unit(s) dropped · manifest updated",
452 util::dim("store"),
453 store_dropped
454 );
455 }
456 for (label, count) in &view_counts {
457 println!(" {:<11} {} unit(s) unlinked", util::dim(label), count);
458 }
459 if errors > 0 {
460 println!(
461 " {} {} (see messages above)",
462 util::fail(util::GLYPH_FAIL),
463 errors
464 );
465 }
466 println!(
467 " {}",
468 util::dim("`epicenter vault` shows what the store holds now")
469 );
470 Ok(())
471}
472
473#[derive(Debug, Default)]
475pub struct WorkReport {
476 pub scanned: usize,
478 pub eligible: usize,
480 pub young: usize,
482 pub in_use: usize,
484 pub removed: usize,
486 pub nominal_bytes: u64,
488 pub unique_bytes: u64,
490 pub shared_bytes: u64,
492 pub errors: u64,
493}
494
495struct WorkDir {
497 name: String,
498 path: PathBuf,
499 newest: Option<SystemTime>,
500 locked: bool,
501 old_enough: bool,
502 bytes: u64,
503 unique: u64,
504 shared: u64,
505 files: u64,
506}
507
508fn evict_work_dir(build_root: &Path, target: &str, delete: bool) -> Result<()> {
514 let work_root = crate::exec::work_root(build_root);
515 let path = resolve_work_dir(&work_root, target)?;
516 let name = path
517 .file_name()
518 .map(|name| name.to_string_lossy().into_owned())
519 .unwrap_or_default();
520 let job_lock =
521 match crate::util::FileLock::try_acquire(&crate::exec::job_lock_path(&work_root, &name))? {
522 Some(lock) => lock,
523 None => bail!(
524 "job dir {} is in use by an epicenter process — remove it when it finishes",
525 path.display()
526 ),
527 };
528 let Some(_cargo_locks) = crate::exec::cargo_locks(&path)? else {
532 bail!(
533 "job dir {} is in use by a live build — remove it when it finishes",
534 path.display()
535 );
536 };
537 let newest = dir_newest(&path);
538 let (bytes, unique, shared, files) = dir_accounting(&path);
539
540 if delete {
541 println!(
542 "{}",
543 util::heading(&format!("epicenter evict --work-dir {name} — removing"))
544 );
545 } else {
546 println!(
547 "{}",
548 util::heading(&format!(
549 "epicenter evict --work-dir {name} — DRY RUN (nothing is removed; pass --yes)"
550 ))
551 );
552 }
553 println!(
554 " {} {}",
555 util::dim(&format!("{:<11}", "job dir")),
556 path.display()
557 );
558 println!(
559 " {} {:<40} {:>9} · {:>4} file(s) {:>10} old {}",
560 util::green(util::GLYPH_PASS),
561 name,
562 human_bytes(bytes),
563 files,
564 newest.map(human_age).unwrap_or_else(|| "?".into()),
565 util::dim("explicitly selected — age policy not applied")
566 );
567 println!();
568 println!(
569 " {} {} nominal — {} actually freed · {} links released",
570 util::dim("result: "),
571 human_bytes(bytes),
572 human_bytes(unique),
573 human_bytes(shared)
574 );
575 if !delete {
576 println!(" {}", util::dim("run again with -y or --yes to remove it"));
577 return Ok(());
578 }
579 let tombstone = work_root.join(format!(".{name}.deleting-{}", std::process::id()));
583 if fs::symlink_metadata(&tombstone).is_ok() {
584 let _ = crate::util::force_remove_dir(&tombstone);
585 }
586 fs::rename(&path, &tombstone).with_context(|| format!("isolating {}", path.display()))?;
587 if let Err(err) = fs::remove_dir_all(&tombstone) {
588 let _ = fs::rename(&tombstone, &path);
589 drop(job_lock);
590 return Err(err).with_context(|| format!("removing {}", path.display()));
591 }
592 drop(job_lock);
593 println!();
594 println!(
595 " {} {name} removed · {} actually freed · {} links released",
596 util::green(util::GLYPH_PASS),
597 human_bytes(unique),
598 human_bytes(shared)
599 );
600 println!(
601 " {}",
602 util::dim("the vault is untouched — a job dir is a view")
603 );
604 Ok(())
605}
606
607fn resolve_work_dir(work_root: &Path, target: &str) -> Result<PathBuf> {
610 if target == "build" {
611 bail!(
612 "the shared build dir is never removed — it is the core asset:\n\
613 to work inside it use `epicenter evict --work build --crate <name>`"
614 );
615 }
616 let candidate = work_root.join(target.trim_start_matches("work/"));
617 let candidate = if candidate.is_dir() {
618 candidate
619 } else {
620 PathBuf::from(target)
621 };
622 let candidate_meta = fs::symlink_metadata(&candidate)?;
623 if !candidate_meta.is_dir()
624 || candidate_meta.file_type().is_symlink()
625 || crate::util::is_reparse_point(&candidate_meta)
626 {
627 bail!("no job dir named {target:?} — `epicenter vault` shows the work tier");
628 }
629 let inside = candidate
630 .canonicalize()
631 .ok()
632 .zip(work_root.canonicalize().ok())
633 .map(|(child, root)| child.parent() == Some(root.as_path()))
634 .unwrap_or(false);
635 if !inside {
636 bail!(
637 "{} is not a job dir under {} — --work-dir removes the work tier only",
638 candidate.display(),
639 work_root.display()
640 );
641 }
642 Ok(candidate)
643}
644
645fn evict_work_dirs(
650 build_root: &Path,
651 older_than: Option<&str>,
652 delete: bool,
653) -> Result<WorkReport> {
654 let work_root = crate::exec::work_root(build_root);
655 let age_text = older_than.unwrap_or(DEFAULT_WORK_MAX_AGE);
656 let limit = parse_duration(age_text).context("invalid value for --older-than")?;
657
658 if delete {
659 println!(
660 "{}",
661 util::heading("epicenter evict --work-dirs — removing")
662 );
663 } else {
664 println!(
665 "{}",
666 util::heading(
667 "epicenter evict --work-dirs — DRY RUN (nothing is removed; pass --yes to delete)"
668 )
669 );
670 }
671 let mut report = WorkReport::default();
672 if !work_root.is_dir() {
673 println!(" no working dirs at {}", work_root.display());
674 return Ok(report);
675 }
676
677 let now = SystemTime::now();
678 let mut rows: Vec<WorkDir> = Vec::new();
679 for entry in
680 fs::read_dir(&work_root).with_context(|| format!("reading {}", work_root.display()))?
681 {
682 let entry = entry?;
683 if !entry.file_type()?.is_dir() {
684 continue;
685 }
686 let name = entry.file_name().to_string_lossy().into_owned();
687 if name.starts_with('.') {
688 continue;
689 }
690 let path = entry.path();
691 if fs::symlink_metadata(&path)
692 .map(|meta| crate::util::is_reparse_point(&meta))
693 .unwrap_or(true)
694 {
695 continue;
696 }
697 let newest = dir_newest(&path);
698 let locked = crate::exec::dir_locked(&path);
699 let (bytes, unique, shared, files) = dir_accounting(&path);
700 let old_enough = newest
701 .map(|m| now.duration_since(m).map_or(true, |age| age > limit))
702 .unwrap_or(true);
703 rows.push(WorkDir {
704 name,
705 path,
706 newest,
707 locked,
708 old_enough,
709 bytes,
710 unique,
711 shared,
712 files,
713 });
714 }
715 rows.sort_by(|a, b| a.name.cmp(&b.name));
716 if rows.is_empty() {
717 println!(
718 " no working dirs at {} — nothing to collect",
719 work_root.display()
720 );
721 return Ok(report);
722 }
723
724 for row in &rows {
725 report.scanned += 1;
726 let (icon, note) = if row.locked {
727 report.in_use += 1;
728 (
729 util::warn(util::GLYPH_WARN),
730 util::warn("in use by a live build — never collected").to_string(),
731 )
732 } else if row.old_enough {
733 report.eligible += 1;
734 (
735 util::green(util::GLYPH_PASS),
736 util::green("collectible").to_string(),
737 )
738 } else {
739 report.young += 1;
740 (
741 util::info(util::GLYPH_INFO),
742 util::dim(&format!("younger than {age_text}")).to_string(),
743 )
744 };
745 println!(
746 " {icon} {:<40} {:>9} · {:>4} file(s) {:>10} old {note}",
747 row.name,
748 human_bytes(row.bytes),
749 row.files,
750 row.newest.map(human_age).unwrap_or_else(|| "?".into()),
751 );
752 }
753
754 for row in rows.iter().filter(|row| row.old_enough && !row.locked) {
755 report.nominal_bytes += row.bytes;
756 report.unique_bytes += row.unique;
757 report.shared_bytes += row.shared;
758 }
759 println!();
760 if report.eligible == 0 {
761 println!(
762 " {} nothing older than {age_text} to collect",
763 util::dim("selection:")
764 );
765 } else {
766 println!(
767 " {} {} dir(s) · {} nominal · {} unique to the jobs (freed), {} still shared with the store",
768 util::dim("selection:"),
769 report.eligible,
770 util::green(&human_bytes(report.nominal_bytes)),
771 util::green(&human_bytes(report.unique_bytes)),
772 util::dim(&human_bytes(report.shared_bytes)),
773 );
774 }
775 if report.in_use > 0 {
776 println!(
777 " {} {} dir(s) in use by a live build — left alone",
778 util::warn(util::GLYPH_WARN),
779 report.in_use
780 );
781 }
782 if report.young > 0 {
783 println!(
784 " {} {} dir(s) younger than {age_text} — kept",
785 util::info(util::GLYPH_INFO),
786 report.young
787 );
788 }
789 if report.eligible == 0 {
790 return Ok(report);
791 }
792 if !delete {
793 println!(
794 " {}",
795 util::dim("run again with -y or --yes to remove the selected dirs")
796 );
797 return Ok(report);
798 }
799
800 let mut freed_unique = 0u64;
801 let mut freed_shared = 0u64;
802 for row in rows.iter().filter(|row| row.old_enough && !row.locked) {
803 let _job_lock = match crate::util::FileLock::try_acquire(&crate::exec::job_lock_path(
804 &work_root, &row.name,
805 ))? {
806 Some(lock) => lock,
807 None => {
808 report.in_use += 1;
809 continue;
810 }
811 };
812 let Some(_cargo_locks) = crate::exec::cargo_locks(&row.path)? else {
813 report.in_use += 1;
814 continue;
815 };
816 match fs::remove_dir_all(&row.path) {
817 Ok(()) => {
818 report.removed += 1;
819 freed_unique += row.unique;
820 freed_shared += row.shared;
821 }
822 Err(e) => {
823 report.errors += 1;
824 eprintln!("could not remove {}: {e}", row.path.display());
825 }
826 }
827 }
828 println!();
829 println!(
830 " {} {} dir(s) evicted · {} actually freed · {} link(s) released (other links, e.g. the vault, still hold those bytes)",
831 util::green(util::GLYPH_PASS),
832 report.removed,
833 human_bytes(freed_unique),
834 util::dim(&human_bytes(freed_shared)),
835 );
836 if report.errors > 0 {
837 println!(
838 " {} {} (see messages above)",
839 util::fail(util::GLYPH_FAIL),
840 report.errors
841 );
842 }
843 println!(
844 " {}",
845 util::dim("the vault is untouched — `epicenter vault` shows what remains")
846 );
847 Ok(report)
848}
849
850fn dir_newest(dir: &Path) -> Option<SystemTime> {
853 let mut newest = None;
854 for entry in WalkDir::new(dir).min_depth(1).into_iter().flatten() {
855 if !entry.file_type().is_file() {
856 continue;
857 }
858 if let Some(modified) = entry.metadata().ok().and_then(|meta| meta.modified().ok()) {
859 if newest.map_or(true, |current| modified > current) {
860 newest = Some(modified);
861 }
862 }
863 }
864 newest
865}
866
867fn dir_accounting(dir: &Path) -> (u64, u64, u64, u64) {
869 let mut bytes = 0u64;
870 let mut unique = 0u64;
871 let mut shared = 0u64;
872 let mut files = 0u64;
873 for entry in WalkDir::new(dir).min_depth(1).into_iter().flatten() {
874 if !entry.file_type().is_file() {
875 continue;
876 }
877 let Ok(meta) = entry.metadata() else { continue };
878 let size = meta.len();
879 bytes += size;
880 files += 1;
881 if crate::vault::link_count(&meta) > 1 {
882 shared += size;
883 } else {
884 unique += size;
885 }
886 }
887 (bytes, unique, shared, files)
888}
889
890fn resolve_view(build_root: &Path, work: &str) -> Result<PathBuf> {
893 if work == "build" {
894 return Ok(build_root.to_path_buf());
895 }
896 let work_root = crate::exec::work_root(build_root);
897 let job_id = work.strip_prefix("work/").unwrap_or(work);
901 if crate::util::validate_component(job_id, "job id").is_ok() {
902 let job = work_root.join(job_id);
903 if let Ok(meta) = fs::symlink_metadata(&job) {
904 if meta.is_dir()
905 && !meta.file_type().is_symlink()
906 && !crate::util::is_reparse_point(&meta)
907 {
908 if let (Ok(root), Ok(canon)) = (work_root.canonicalize(), job.canonicalize()) {
911 if canon != root && canon.starts_with(&root) {
912 return Ok(job);
913 }
914 }
915 }
916 }
917 }
918 let path = PathBuf::from(work);
919 if fs::symlink_metadata(&path)
920 .map(|meta| {
921 meta.is_dir() && !meta.file_type().is_symlink() && !crate::util::is_reparse_point(&meta)
922 })
923 .unwrap_or(false)
924 {
925 let canonical = path.canonicalize()?;
926 let work_root_canonical = work_root.canonicalize().ok();
927 let build_canonical = build_root.canonicalize().ok();
928 let allowed = work_root_canonical
929 .iter()
930 .chain(build_canonical.iter())
931 .any(|root| canonical.starts_with(root));
932 if allowed {
933 return Ok(path);
934 }
935 bail!(
936 "view {} is outside {} and {} — refusing to delete outside the cache",
937 path.display(),
938 work_root.display(),
939 build_root.display()
940 );
941 }
942 bail!(
943 "no view named {work:?} — expected `build`, a job id under {}, or a directory under it",
944 work_root.display()
945 );
946}
947
948fn crate_matches(unit: &Unit, name: &str) -> bool {
950 unit.package == name || unit.package.replace('-', "_") == name.replace('-', "_")
951}
952
953fn surgery_unit_matches(unit: &Unit, opts: &Options<'_>) -> bool {
955 let mut hit = false;
956 if let Some(query) = opts.unit {
957 hit |= who::unit_matches(unit, query);
958 }
959 if let Some(name) = opts.crate_name {
960 hit |= crate_matches(unit, name);
961 }
962 hit
963}
964
965fn view_unit_files(root: &Path, unit: &Unit, arts: &ArtifactInfo) -> Vec<PathBuf> {
967 let mut files = Vec::new();
968 collect_tree_files(
969 &root
970 .join(&unit.profile)
971 .join(".fingerprint")
972 .join(&unit.dir_name),
973 &mut files,
974 );
975 for (path, _, _) in &arts.paths {
977 files.push(path.clone());
978 }
979 files
980}
981
982fn collect_tree_files(dir: &Path, out: &mut Vec<PathBuf>) {
983 for entry in WalkDir::new(dir).min_depth(1).into_iter().flatten() {
984 if entry.file_type().is_file() {
985 out.push(entry.path().to_path_buf());
986 }
987 }
988}
989
990fn remove_view_unit(root: &Path, unit: &Unit, arts: &ArtifactInfo, errors: &mut u64) {
992 let fingerprint_dir = root
993 .join(&unit.profile)
994 .join(".fingerprint")
995 .join(&unit.dir_name);
996 remove_dir(&fingerprint_dir, errors);
997 for anchor in fingerprint::build_anchor_dirs(root, unit) {
998 if anchor.is_dir() {
999 remove_dir(&anchor, errors);
1000 }
1001 }
1002 for (path, _, _) in &arts.paths {
1003 remove_file(path, errors);
1004 }
1005}
1006
1007fn account_files(files: &[PathBuf]) -> (u64, u64, u64, u64) {
1011 #[cfg(unix)]
1012 {
1013 use std::collections::{HashMap, HashSet};
1014 use std::os::unix::fs::MetadataExt;
1015
1016 let mut per_inode: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
1017 let mut seen: HashSet<&Path> = HashSet::new();
1018 let mut count = 0u64;
1019 for path in files {
1020 if !seen.insert(path.as_path()) {
1021 continue;
1022 }
1023 let Ok(meta) = fs::metadata(path) else {
1024 continue;
1025 };
1026 count += 1;
1027 let entry =
1028 per_inode
1029 .entry((meta.dev(), meta.ino()))
1030 .or_insert((meta.len(), meta.nlink(), 0));
1031 entry.2 += 1;
1032 }
1033 let mut nominal = 0u64;
1034 let mut freed = 0u64;
1035 for (size, links, planned) in per_inode.values() {
1036 nominal += size;
1037 if planned >= links {
1038 freed += size;
1039 }
1040 }
1041 (nominal, freed, nominal - freed, count)
1042 }
1043 #[cfg(not(unix))]
1044 {
1045 let mut seen: std::collections::HashSet<&Path> = std::collections::HashSet::new();
1047 let mut nominal = 0u64;
1048 let mut count = 0u64;
1049 for path in files {
1050 if !seen.insert(path.as_path()) {
1051 continue;
1052 }
1053 if let Ok(meta) = fs::metadata(path) {
1054 nominal += meta.len();
1055 count += 1;
1056 }
1057 }
1058 (nominal, nominal, 0, count)
1059 }
1060}
1061
1062struct Candidate {
1065 name: String,
1066 kind: String,
1067 bytes: u64,
1068 newest: Option<SystemTime>,
1069 current: bool,
1070 places: Vec<Place>,
1071}
1072
1073enum Place {
1075 Store {
1076 generation: String,
1077 files: Vec<PathBuf>,
1078 },
1079 View {
1080 label: String,
1081 root: PathBuf,
1082 unit_dir: PathBuf,
1083 anchors: Vec<PathBuf>,
1084 artifacts: Vec<PathBuf>,
1085 files: Vec<PathBuf>,
1086 },
1087}
1088
1089impl Place {
1090 fn label(&self) -> &str {
1091 match self {
1092 Place::Store { .. } => "store",
1093 Place::View { label, .. } => label,
1094 }
1095 }
1096
1097 fn files(&self) -> &[PathBuf] {
1098 match self {
1099 Place::Store { files, .. } | Place::View { files, .. } => files,
1100 }
1101 }
1102}
1103
1104fn candidate_for<'a>(
1105 candidates: &'a mut BTreeMap<String, Candidate>,
1106 name: &str,
1107) -> &'a mut Candidate {
1108 candidates
1109 .entry(name.to_string())
1110 .or_insert_with(|| Candidate {
1111 name: name.to_string(),
1112 kind: "?".to_string(),
1113 bytes: 0,
1114 newest: None,
1115 current: false,
1116 places: Vec::new(),
1117 })
1118}
1119
1120fn push_place(candidate: &mut Candidate, place: Place) {
1121 candidate.bytes += place
1122 .files()
1123 .iter()
1124 .filter_map(|path| fs::metadata(path).ok())
1125 .map(|meta| meta.len())
1126 .sum::<u64>();
1127 candidate.places.push(place);
1128}
1129
1130fn stamp_time(stamp: u64) -> Option<SystemTime> {
1131 (stamp > 0).then(|| std::time::UNIX_EPOCH + Duration::from_secs(stamp))
1132}
1133
1134fn max_time(left: Option<SystemTime>, right: Option<SystemTime>) -> Option<SystemTime> {
1135 match (left, right) {
1136 (Some(a), Some(b)) => Some(a.max(b)),
1137 (left, None) => left,
1138 (None, right) => right,
1139 }
1140}
1141
1142fn known_views(build_root: &Path) -> Vec<(String, PathBuf)> {
1144 let mut views = vec![("build".to_string(), build_root.to_path_buf())];
1145 let work_root = crate::exec::work_root(build_root);
1146 if let Ok(entries) = fs::read_dir(&work_root) {
1147 for entry in entries.flatten() {
1148 if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
1149 continue;
1150 }
1151 let name = entry.file_name().to_string_lossy().into_owned();
1152 if name.starts_with('.') {
1153 continue;
1154 }
1155 if fs::symlink_metadata(entry.path())
1156 .map(|meta| crate::util::is_reparse_point(&meta))
1157 .unwrap_or(true)
1158 {
1159 continue;
1160 }
1161 views.push((format!("work/{name}"), entry.path()));
1162 }
1163 }
1164 views
1165}
1166
1167fn active_cohort(rows: &[(Unit, ArtifactInfo)]) -> String {
1170 who::rustc_cohorts(rows)
1171 .into_iter()
1172 .max_by_key(|(_, count)| *count)
1173 .map(|(id, _)| id)
1174 .unwrap_or_default()
1175}
1176
1177fn store_selectors_hit(
1180 opts: &Options<'_>,
1181 stored: &crate::vault::StoredUnit,
1182 active: Option<&str>,
1183 limit: Option<Duration>,
1184 now: SystemTime,
1185) -> bool {
1186 let mut hit = false;
1187 if let Some(query) = opts.unit {
1188 hit |= stored.entry.name.contains(query);
1189 }
1190 if opts.stale {
1191 hit |= active.map(|key| stored.generation != key).unwrap_or(false);
1192 }
1193 if let Some(key) = opts.rustc_key {
1194 hit |= crate::vault::stored_unit_rustc(stored)
1195 .map(|id| id.starts_with(key))
1196 .unwrap_or(false);
1197 }
1198 if let Some(limit) = limit {
1199 hit |= stamp_time(stored.entry.stamp)
1200 .map(|stamp| now.duration_since(stamp).map_or(true, |age| age > limit))
1201 .unwrap_or(false);
1202 }
1203 if opts.husks {
1204 hit |= stored
1205 .entry
1206 .files
1207 .iter()
1208 .any(|file| !stored.dir.join(&file.path).is_file());
1209 }
1210 hit
1211}
1212
1213fn view_selectors_hit(
1215 opts: &Options<'_>,
1216 unit: &Unit,
1217 arts: &ArtifactInfo,
1218 current: &str,
1219 limit: Option<Duration>,
1220 now: SystemTime,
1221) -> bool {
1222 let mut hit = false;
1223 if let Some(query) = opts.unit {
1224 hit |= who::unit_matches(unit, query);
1225 }
1226 if opts.stale {
1227 hit |= !current.is_empty() && who::rustc_of(unit) != current;
1228 }
1229 if let Some(key) = opts.rustc_key {
1230 hit |= who::rustc_of(unit).starts_with(key);
1231 }
1232 if opts.husks {
1233 hit |= arts.paths.is_empty();
1234 }
1235 if let Some(limit) = limit {
1236 hit |= arts
1237 .newest
1238 .map(|newest| now.duration_since(newest).map_or(true, |age| age > limit))
1239 .unwrap_or(false);
1240 }
1241 hit
1242}
1243
1244fn tier_summary(tier: Tier, views: &[String]) -> String {
1246 match tier {
1247 Tier::Store => "store — the vault only (views keep their links)".to_string(),
1248 Tier::View => format!("view — {} (the vault is untouched)", view_list(views)),
1249 Tier::All => format!("all — the vault + {}", view_list(views)),
1250 }
1251}
1252
1253fn view_list(views: &[String]) -> String {
1254 if views.is_empty() {
1255 return "no views".to_string();
1256 }
1257 let mut text = views.iter().take(4).cloned().collect::<Vec<_>>().join(", ");
1258 if views.len() > 4 {
1259 text.push_str(&format!(", … (+{} more)", views.len() - 4));
1260 }
1261 text
1262}
1263
1264fn evict_from_view(build_root: &Path, opts: &Options<'_>, work: &str) -> Result<()> {
1269 let root = resolve_view(build_root, work)?;
1270 let Some(_cargo_locks) = crate::exec::cargo_locks(&root)? else {
1272 bail!(
1273 "view {} is in use by a live build — unlink when it finishes",
1274 root.display()
1275 );
1276 };
1277 let vaulted: std::collections::BTreeSet<String> =
1278 crate::vault::stored_units(&crate::vault::store_root(build_root))?
1279 .into_iter()
1280 .map(|unit| unit.entry.name)
1281 .collect();
1282
1283 let rows = who::collect(&root).context("reading the view")?;
1284 let chosen: Vec<&(Unit, ArtifactInfo)> = rows
1285 .iter()
1286 .filter(|(unit, _)| surgery_unit_matches(unit, opts))
1287 .collect();
1288
1289 if opts.delete {
1290 println!(
1291 "{}",
1292 util::heading(&format!("epicenter evict --work {work} — unlinking"))
1293 );
1294 } else {
1295 println!(
1296 "{}",
1297 util::heading(&format!(
1298 "epicenter evict --work {work} — DRY RUN (nothing is unlinked; pass --yes)"
1299 ))
1300 );
1301 }
1302 println!(
1303 " {} {}",
1304 util::dim(&format!("{:<11}", "view")),
1305 root.display()
1306 );
1307 if chosen.is_empty() {
1308 println!(" nothing in this view matches — check `epicenter who --unit <q>`");
1309 return Ok(());
1310 }
1311
1312 let mut files = Vec::new();
1313 for (unit, arts) in &chosen {
1314 files.extend(view_unit_files(&root, unit, arts));
1315 }
1316 let (nominal, freed, released, count) = account_files(&files);
1317
1318 for (unit, arts) in &chosen {
1319 println!(
1320 " {} {:<40} {:<9} {:>9} {}",
1321 util::green(util::GLYPH_PASS),
1322 unit.dir_name,
1323 unit.kind.label(),
1324 human_bytes(arts.bytes),
1325 if vaulted.contains(&unit.dir_name) {
1326 util::dim("in the vault — Cargo will rebuild it here")
1327 } else {
1328 util::warn("only in this view — its bytes go")
1329 }
1330 );
1331 }
1332 println!();
1333 println!(
1334 " {} {} unit(s) · {} file(s) · {} nominal",
1335 util::dim("selection:"),
1336 chosen.len(),
1337 count,
1338 util::green(&human_bytes(nominal))
1339 );
1340 println!(
1341 " {} {} actually freed · {} links released (still held elsewhere, e.g. the vault)",
1342 util::dim("result: "),
1343 human_bytes(freed),
1344 human_bytes(released)
1345 );
1346 if !opts.delete {
1347 println!(
1348 " {}",
1349 util::dim("run again with -y or --yes to unlink these units from this view")
1350 );
1351 return Ok(());
1352 }
1353
1354 let mut errors = 0u64;
1355 if crate::exec::dir_locked(&root) {
1356 bail!(
1357 "view {} became busy — refusing to unlink from a live build",
1358 root.display()
1359 );
1360 }
1361 for (unit, arts) in &chosen {
1362 remove_view_unit(&root, unit, arts, &mut errors);
1363 }
1364 println!();
1365 println!(
1366 " {} {} unit(s) unlinked from {work} · {} actually freed · {} links released",
1367 util::green(util::GLYPH_PASS),
1368 chosen.len(),
1369 human_bytes(freed),
1370 human_bytes(released)
1371 );
1372 if errors > 0 {
1373 println!(
1374 " {} {} (see messages above)",
1375 util::fail(util::GLYPH_FAIL),
1376 errors
1377 );
1378 }
1379 println!(
1380 " {}",
1381 util::dim("Cargo will rebuild these units here; the vault is untouched")
1382 );
1383 Ok(())
1384}
1385
1386fn remove_dir(path: &Path, errors: &mut u64) {
1387 if let Err(e) = fs::remove_dir_all(path) {
1388 if e.kind() != ErrorKind::NotFound {
1389 *errors += 1;
1390 eprintln!("could not remove {}: {e}", path.display());
1391 }
1392 }
1393}
1394
1395fn remove_file(path: &Path, errors: &mut u64) {
1396 if let Err(e) = fs::remove_file(path) {
1397 if e.kind() != ErrorKind::NotFound {
1398 *errors += 1;
1399 eprintln!("could not remove {}: {e}", path.display());
1400 }
1401 }
1402}
1403
1404#[cfg(test)]
1405mod tests {
1406 use super::*;
1407
1408 fn write_synthetic_unit(root: &Path) -> Result<()> {
1410 let unit_dir = root.join("debug/.fingerprint/pkg-1122334455667788");
1411 fs::create_dir_all(&unit_dir)?;
1412 fs::write(unit_dir.join("lib-pkg"), "0011223344556677")?;
1413 fs::write(unit_dir.join("dep-lib-pkg"), [0u8; 8])?;
1414 fs::create_dir_all(root.join("debug/deps"))?;
1415 fs::write(
1416 root.join("debug/deps/libpkg-1122334455667788.rlib"),
1417 [0u8; 16],
1418 )?;
1419 Ok(())
1420 }
1421
1422 fn synthetic_home() -> Result<(tempfile::TempDir, PathBuf)> {
1425 let home = tempfile::tempdir()?;
1426 let build = home.path().join("build");
1427 fs::create_dir_all(&build)?;
1428 write_synthetic_unit(&build)?;
1429 Ok((home, build))
1430 }
1431
1432 fn no_selectors() -> Options<'static> {
1433 Options {
1434 unit: None,
1435 stale: false,
1436 rustc_key: None,
1437 husks: false,
1438 older_than: None,
1439 work_dirs: false,
1440 work_dir: None,
1441 work: None,
1442 crate_name: None,
1443 tier: Tier::All,
1444 force: false,
1445 delete: false,
1446 }
1447 }
1448
1449 #[test]
1450 fn resolve_view_rejects_paths_outside_the_cache() -> Result<()> {
1451 let home = tier_home()?;
1452 let build = home.path().join("build");
1453 let job = home.path().join("work/ci-1");
1454 fs::create_dir_all(&job)?;
1455 assert_eq!(resolve_view(&build, "ci-1")?, job);
1456 let outside = tempfile::tempdir()?;
1457 assert!(resolve_view(&build, outside.path().to_str().unwrap()).is_err());
1458 assert!(resolve_view(&build, "../escape").is_err());
1459 assert!(resolve_view(&build, "/tmp/escape").is_err());
1460 assert!(resolve_view(&build, ".. ").is_err());
1461 assert!(resolve_view(&build, "CON.txt").is_err());
1462 Ok(())
1463 }
1464
1465 #[test]
1466 fn requires_at_least_one_selector() -> Result<()> {
1467 let (_home, build) = synthetic_home()?;
1468 assert!(run(&build, &no_selectors()).is_err());
1469 Ok(())
1470 }
1471
1472 #[test]
1473 fn stale_selector_skips_current_generation() -> Result<()> {
1474 let (_home, build) = synthetic_home()?;
1475 let opts = Options {
1476 stale: true,
1477 ..no_selectors()
1478 };
1479 run(&build, &opts)?;
1481 assert!(
1482 build
1483 .join("debug/.fingerprint/pkg-1122334455667788")
1484 .exists()
1485 );
1486 Ok(())
1487 }
1488
1489 #[test]
1490 fn evicts_fingerprint_and_artifacts_with_force() -> Result<()> {
1491 let (_home, build) = synthetic_home()?;
1492 let opts = Options {
1493 unit: Some("pkg-1122334455667788"),
1494 force: true,
1495 delete: true,
1496 ..no_selectors()
1497 };
1498 run(&build, &opts)?;
1499 assert!(
1500 !build
1501 .join("debug/.fingerprint/pkg-1122334455667788")
1502 .exists(),
1503 "fingerprint dir must be gone"
1504 );
1505 assert!(
1506 !build
1507 .join("debug/deps/libpkg-1122334455667788.rlib")
1508 .exists(),
1509 "artifact must be gone"
1510 );
1511 Ok(())
1512 }
1513
1514 use std::time::Duration;
1515
1516 fn tier_home() -> Result<tempfile::TempDir> {
1518 let home = tempfile::tempdir()?;
1519 fs::create_dir_all(home.path().join("build"))?;
1520 fs::create_dir_all(home.path().join("work"))?;
1521 Ok(home)
1522 }
1523
1524 fn aged_job(home: &Path, name: &str, age_days: u64) -> Result<PathBuf> {
1526 let debug = home.join("work").join(name).join("debug");
1527 fs::create_dir_all(&debug)?;
1528 let artifact = debug.join("libjob.rlib");
1529 fs::write(&artifact, [0u8; 64])?;
1530 if age_days > 0 {
1531 let when = SystemTime::now() - Duration::from_secs(age_days * 86_400);
1532 fs::OpenOptions::new()
1533 .write(true)
1534 .open(&artifact)?
1535 .set_modified(when)?;
1536 }
1537 Ok(home.join("work").join(name))
1538 }
1539
1540 #[test]
1541 fn work_dirs_collect_by_age_and_keep_young_ones() -> Result<()> {
1542 let home = tier_home()?;
1543 let build = home.path().join("build");
1544 let old_job = aged_job(home.path(), "old-job", 30)?;
1545 let fresh_job = aged_job(home.path(), "fresh-job", 0)?;
1546
1547 let dry = evict_work_dirs(&build, None, false)?;
1548 assert_eq!((dry.scanned, dry.eligible, dry.young), (2, 1, 1));
1549 assert!(
1550 old_job.exists() && fresh_job.exists(),
1551 "dry run touches nothing"
1552 );
1553
1554 let done = evict_work_dirs(&build, None, true)?;
1555 assert_eq!(done.removed, 1);
1556 assert_eq!(done.unique_bytes, 64, "unique bytes are actually freed");
1557 assert!(!old_job.exists(), "aged job dir collected");
1558 assert!(fresh_job.exists(), "young job dir kept");
1559 Ok(())
1560 }
1561
1562 #[test]
1563 fn work_dirs_never_collect_locked_jobs() -> Result<()> {
1564 let home = tier_home()?;
1565 let build = home.path().join("build");
1566 let job = aged_job(home.path(), "locked-job", 30)?;
1567 let lock_path = job.join("debug/.cargo-build-lock");
1568 fs::write(&lock_path, b"")?;
1569 let when = SystemTime::now() - Duration::from_secs(30 * 86_400);
1571 fs::OpenOptions::new()
1572 .write(true)
1573 .open(&lock_path)?
1574 .set_modified(when)?;
1575
1576 let held = fs::OpenOptions::new().write(true).open(&lock_path)?;
1577 held.try_lock().context("test holds the lock")?;
1578
1579 let report = evict_work_dirs(&build, None, true)?;
1580 assert_eq!((report.in_use, report.removed), (1, 0));
1581 assert!(job.exists(), "a locked job dir is never touched");
1582
1583 held.unlock()?;
1584 drop(held);
1585 let report = evict_work_dirs(&build, None, true)?;
1586 assert_eq!(report.removed, 1);
1587 assert!(!job.exists());
1588 Ok(())
1589 }
1590
1591 #[test]
1592 fn work_dirs_cannot_be_combined_with_unit_selectors() -> Result<()> {
1593 let home = tier_home()?;
1594 let opts = Options {
1595 work_dirs: true,
1596 stale: true,
1597 ..no_selectors()
1598 };
1599 assert!(run(&home.path().join("build"), &opts).is_err());
1600 Ok(())
1601 }
1602
1603 #[test]
1604 fn work_dir_accounting_separates_unique_and_shared_bytes() -> Result<()> {
1605 let home = tier_home()?;
1606 let debug = home.path().join("work/aged-job/debug");
1607 fs::create_dir_all(&debug)?;
1608 let unique = debug.join("unique.rlib");
1609 let shared = debug.join("shared.rlib");
1610 fs::write(&unique, [0u8; 100])?;
1611 fs::write(&shared, [0u8; 200])?;
1612 fs::hard_link(&shared, home.path().join("store-copy.rlib"))?;
1614 let when = SystemTime::now() - Duration::from_secs(30 * 86_400);
1615 fs::OpenOptions::new()
1616 .write(true)
1617 .open(&unique)?
1618 .set_modified(when)?;
1619 fs::OpenOptions::new()
1620 .write(true)
1621 .open(&shared)?
1622 .set_modified(when)?;
1623
1624 let report = evict_work_dirs(&home.path().join("build"), None, false)?;
1625 assert_eq!(report.unique_bytes, 100);
1626 assert_eq!(report.shared_bytes, 200);
1627 assert_eq!(report.nominal_bytes, 300);
1628 Ok(())
1629 }
1630
1631 #[test]
1632 fn work_dir_removal_ignores_the_age_window() -> Result<()> {
1633 let home = tier_home()?;
1634 let build = home.path().join("build");
1635 let job = aged_job(home.path(), "fresh-job", 0)?;
1636 let opts = Options {
1637 work_dir: Some("fresh-job"),
1638 delete: true,
1639 ..no_selectors()
1640 };
1641 run(&build, &opts)?;
1642 assert!(
1643 !job.exists(),
1644 "an explicitly selected dir goes regardless of age"
1645 );
1646 Ok(())
1647 }
1648
1649 #[test]
1650 fn work_dir_refuses_locked_and_unknown_targets() -> Result<()> {
1651 let home = tier_home()?;
1652 let build = home.path().join("build");
1653 let job = aged_job(home.path(), "locked-job", 0)?;
1654 let lock_path = job.join("debug/.cargo-build-lock");
1655 fs::write(&lock_path, b"")?;
1656 let held = fs::OpenOptions::new().write(true).open(&lock_path)?;
1657 held.try_lock().context("test holds the lock")?;
1658
1659 let opts = Options {
1660 work_dir: Some("locked-job"),
1661 delete: true,
1662 ..no_selectors()
1663 };
1664 let err = run(&build, &opts).unwrap_err();
1665 assert!(err.to_string().contains("in use"), "{err}");
1666 assert!(job.exists(), "a locked job dir survives");
1667 drop(held);
1668
1669 let unknown = Options {
1670 work_dir: Some("nope"),
1671 ..no_selectors()
1672 };
1673 assert!(run(&build, &unknown).is_err());
1674 Ok(())
1675 }
1676
1677 #[test]
1678 fn work_dir_never_targets_the_shared_build_or_outside_paths() -> Result<()> {
1679 let home = tier_home()?;
1680 let build = home.path().join("build");
1681 let shared = Options {
1682 work_dir: Some("build"),
1683 ..no_selectors()
1684 };
1685 let err = run(&build, &shared).unwrap_err();
1686 assert!(err.to_string().contains("never removed"), "{err}");
1687
1688 let outside = Options {
1689 work_dir: Some("/tmp"),
1690 ..no_selectors()
1691 };
1692 assert!(
1693 run(&build, &outside).is_err(),
1694 "only job dirs under work/ — /tmp is refused"
1695 );
1696 Ok(())
1697 }
1698
1699 #[test]
1700 fn work_dir_rejects_selectors_and_sibling_modes() -> Result<()> {
1701 let home = tier_home()?;
1702 let build = home.path().join("build");
1703 let with_age = Options {
1704 work_dir: Some("x"),
1705 older_than: Some("30d"),
1706 ..no_selectors()
1707 };
1708 assert!(run(&build, &with_age).is_err());
1709
1710 let with_dirs = Options {
1711 work_dir: Some("x"),
1712 work_dirs: true,
1713 ..no_selectors()
1714 };
1715 let err = run(&build, &with_dirs).unwrap_err();
1716 assert!(err.to_string().contains("one operation"), "{err}");
1717 Ok(())
1718 }
1719
1720 fn tiered_views() -> Result<(tempfile::TempDir, PathBuf)> {
1722 let home = tempfile::tempdir()?;
1723 fs::create_dir_all(home.path().join("build"))?;
1724 fs::create_dir_all(home.path().join("work"))?;
1725 write_synthetic_unit(&home.path().join("build"))?;
1726 let job = home.path().join("work/ci-1");
1727 write_synthetic_unit(&job)?;
1728 Ok((home, job))
1729 }
1730
1731 #[test]
1732 fn work_surgery_unlinks_only_the_named_view() -> Result<()> {
1733 let (home, job) = tiered_views()?;
1734 let build = home.path().join("build");
1735 let opts = Options {
1736 work: Some("ci-1"),
1737 crate_name: Some("pkg"),
1738 delete: true,
1739 ..no_selectors()
1740 };
1741 run(&build, &opts)?;
1742 assert!(
1743 !job.join("debug/.fingerprint/pkg-1122334455667788").exists(),
1744 "job fingerprint unlinked"
1745 );
1746 assert!(
1747 !job.join("debug/deps/libpkg-1122334455667788.rlib").exists(),
1748 "job artifact unlinked"
1749 );
1750 assert!(
1751 build
1752 .join("debug/.fingerprint/pkg-1122334455667788")
1753 .exists(),
1754 "build view untouched"
1755 );
1756 assert!(
1757 build
1758 .join("debug/deps/libpkg-1122334455667788.rlib")
1759 .exists(),
1760 "build artifact untouched"
1761 );
1762 assert!(!home.path().join("store").exists(), "no store was touched");
1763 Ok(())
1764 }
1765
1766 #[test]
1767 fn work_surgery_accepts_the_shared_build_view() -> Result<()> {
1768 let (home, job) = tiered_views()?;
1769 let build = home.path().join("build");
1770 let opts = Options {
1771 work: Some("build"),
1772 unit: Some("pkg"),
1773 delete: true,
1774 ..no_selectors()
1775 };
1776 run(&build, &opts)?;
1777 assert!(
1778 !build
1779 .join("debug/.fingerprint/pkg-1122334455667788")
1780 .exists()
1781 );
1782 assert!(job.join("debug/.fingerprint/pkg-1122334455667788").exists());
1783 Ok(())
1784 }
1785
1786 #[test]
1787 fn work_surgery_refuses_a_locked_view() -> Result<()> {
1788 let (home, job) = tiered_views()?;
1789 let build = home.path().join("build");
1790 let lock_path = job.join("debug/.cargo-build-lock");
1791 fs::write(&lock_path, b"")?;
1792 let held = fs::OpenOptions::new().write(true).open(&lock_path)?;
1793 held.try_lock().context("test holds the lock")?;
1794 let opts = Options {
1795 work: Some("ci-1"),
1796 crate_name: Some("pkg"),
1797 delete: true,
1798 ..no_selectors()
1799 };
1800 let err = run(&build, &opts).unwrap_err();
1801 assert!(err.to_string().contains("in use"), "{err}");
1802 assert!(job.join("debug/.fingerprint/pkg-1122334455667788").exists());
1803 Ok(())
1804 }
1805
1806 #[test]
1807 fn work_surgery_requires_a_selector_and_rejects_bulk_ones() -> Result<()> {
1808 let (home, _job) = tiered_views()?;
1809 let build = home.path().join("build");
1810 let bare = Options {
1811 work: Some("ci-1"),
1812 ..no_selectors()
1813 };
1814 assert!(run(&build, &bare).is_err(), "needs --crate/--unit");
1815 let bulk = Options {
1816 work: Some("ci-1"),
1817 stale: true,
1818 ..no_selectors()
1819 };
1820 let err = run(&build, &bulk).unwrap_err();
1821 assert!(err.to_string().contains("--crate"), "{err}");
1822 Ok(())
1823 }
1824
1825 #[test]
1826 fn work_surgery_unknown_view_errors() -> Result<()> {
1827 let (home, _job) = tiered_views()?;
1828 let build = home.path().join("build");
1829 let opts = Options {
1830 work: Some("nope"),
1831 crate_name: Some("pkg"),
1832 ..no_selectors()
1833 };
1834 assert!(run(&build, &opts).is_err());
1835 Ok(())
1836 }
1837
1838 fn harvested_home() -> Result<(tempfile::TempDir, PathBuf)> {
1841 let (home, build) = synthetic_home()?;
1842 crate::vault::harvest(&build, &home.path().join("store"), None, false)?;
1843 Ok((home, build))
1844 }
1845
1846 #[test]
1847 fn store_tier_drops_groups_and_prunes_the_manifest() -> Result<()> {
1848 let (home, build) = harvested_home()?;
1849 let store = home.path().join("store");
1850 assert_eq!(crate::vault::stored_units(&store)?.len(), 1, "harvested");
1851
1852 let opts = Options {
1854 unit: Some("pkg"),
1855 tier: Tier::Store,
1856 delete: true,
1857 ..no_selectors()
1858 };
1859 run(&build, &opts)?;
1860 assert_eq!(
1861 crate::vault::stored_units(&store)?.len(),
1862 0,
1863 "store record gone"
1864 );
1865 assert!(
1866 build
1867 .join("debug/.fingerprint/pkg-1122334455667788")
1868 .exists(),
1869 "the view keeps its links"
1870 );
1871 assert!(
1872 build
1873 .join("debug/deps/libpkg-1122334455667788.rlib")
1874 .exists(),
1875 "the view keeps its artifact"
1876 );
1877 Ok(())
1878 }
1879
1880 #[test]
1881 fn view_tier_leaves_the_store_alone() -> Result<()> {
1882 let (home, build) = harvested_home()?;
1883 let store = home.path().join("store");
1884 let opts = Options {
1885 unit: Some("pkg"),
1886 tier: Tier::View,
1887 force: true,
1888 delete: true,
1889 ..no_selectors()
1890 };
1891 run(&build, &opts)?;
1892 assert!(
1893 !build
1894 .join("debug/.fingerprint/pkg-1122334455667788")
1895 .exists()
1896 );
1897 assert_eq!(
1898 crate::vault::stored_units(&store)?.len(),
1899 1,
1900 "store record stays"
1901 );
1902 Ok(())
1903 }
1904
1905 #[test]
1906 fn default_tier_removes_store_and_view_together() -> Result<()> {
1907 let (home, build) = harvested_home()?;
1908 let store = home.path().join("store");
1909 let opts = Options {
1910 unit: Some("pkg"),
1911 force: true,
1912 delete: true,
1913 ..no_selectors()
1914 };
1915 run(&build, &opts)?;
1916 assert_eq!(crate::vault::stored_units(&store)?.len(), 0);
1917 assert!(
1918 !build
1919 .join("debug/.fingerprint/pkg-1122334455667788")
1920 .exists()
1921 );
1922 assert!(
1923 !build
1924 .join("debug/deps/libpkg-1122334455667788.rlib")
1925 .exists()
1926 );
1927 Ok(())
1928 }
1929
1930 #[test]
1931 fn current_generation_is_protected_for_views() -> Result<()> {
1932 let (home, build) = harvested_home()?;
1933 let store = home.path().join("store");
1934 let opts = Options {
1935 unit: Some("pkg"),
1936 delete: true,
1937 ..no_selectors()
1938 };
1939 run(&build, &opts)?;
1940 assert!(
1941 build
1942 .join("debug/.fingerprint/pkg-1122334455667788")
1943 .exists(),
1944 "current unit protected without --force"
1945 );
1946 assert_eq!(crate::vault::stored_units(&store)?.len(), 1);
1947 Ok(())
1948 }
1949
1950 #[test]
1951 fn stale_drops_non_active_generations_from_the_store() -> Result<()> {
1952 let (home, build) = harvested_home()?;
1953 let store = home.path().join("store");
1954 let old = store.join("0.0.0-deadbeef-x86_64-unknown-linux-gnu-00000000");
1955 let unit = old.join("units/oldpkg-0000111122223333");
1956 fs::create_dir_all(&unit)?;
1957 fs::write(unit.join("lib-old"), "aaaaaaaabbbbbbbb")?;
1958 let manifest = crate::vault::Manifest {
1959 parity_key: "0.0.0-deadbeef-x86_64-unknown-linux-gnu-00000000".into(),
1960 toolchain: "rustc 0.0.0".into(),
1961 created: 1,
1962 updated: 1,
1963 units: vec![crate::vault::UnitEntry {
1964 name: "oldpkg-0000111122223333".into(),
1965 profile: "debug".into(),
1966 memo: "lib-old".into(),
1967 hash: "aaaaaaaabbbbbbbb".into(),
1968 stamp: 1,
1969 files: vec![crate::vault::FileEntry {
1970 path: "lib-old".into(),
1971 dest: ".fingerprint/oldpkg-0000111122223333/lib-old".into(),
1972 size: 16,
1973 sha256: "0".repeat(64),
1974 role: "memo".into(),
1975 }],
1976 }],
1977 mac: String::new(),
1978 };
1979 crate::vault::save_manifest(
1980 &store,
1981 "0.0.0-deadbeef-x86_64-unknown-linux-gnu-00000000",
1982 &manifest,
1983 )?;
1984
1985 let opts = Options {
1986 stale: true,
1987 tier: Tier::Store,
1988 delete: true,
1989 ..no_selectors()
1990 };
1991 run(&build, &opts)?;
1992 assert!(!old.exists(), "the old generation is gone");
1993 assert_eq!(
1994 crate::vault::stored_units(&store)?.len(),
1995 1,
1996 "the active generation survives"
1997 );
1998 Ok(())
1999 }
2000
2001 #[test]
2002 fn accounting_frees_only_inodes_whose_last_link_goes() -> Result<()> {
2003 let home = tempfile::tempdir()?;
2004 let a = home.path().join("a.bin");
2005 let b = home.path().join("b.bin");
2006 let store_copy = home.path().join("store-copy.bin");
2007 fs::write(&a, [0u8; 100])?;
2008 fs::write(&b, [0u8; 200])?;
2009 fs::hard_link(&b, &store_copy)?;
2010
2011 let (nominal, freed, released, count) =
2013 account_files(&[a.clone(), b.clone(), store_copy.clone()]);
2014 assert_eq!((nominal, freed, released, count), (300, 300, 0, 3));
2015
2016 let (nominal, freed, released, count) = account_files(&[a, b]);
2018 assert_eq!((nominal, freed, released, count), (300, 100, 200, 2));
2019 Ok(())
2020 }
2021}