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