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