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