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