Skip to main content

epicenter_cli/
evict.rs

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