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