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