Skip to main content

epicenter_cli/
prune.rs

1//! Garbage collection for the shared cache.
2//!
3//! The key idea: every artifact in the cache belongs to a build "unit"
4//! identified by a `name-hash` key (Cargo appends a metadata hash to
5//! filenames). Deleting a *whole unit* keeps Cargo's bookkeeping consistent;
6//! and if anything still needed got deleted, Cargo simply rebuilds it —
7//! pruning is safe by design.
8//!
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::io::ErrorKind;
13use std::path::Component;
14use std::path::{Path, PathBuf};
15use std::time::SystemTime;
16
17use anyhow::Result;
18use walkdir::WalkDir;
19
20/// One build unit: all files in the cache sharing a `name-hash` key.
21pub struct Group {
22    /// e.g. `serde-deadbeef`
23    pub key: String,
24    /// Every file belonging to the unit (may span `deps/` and `.fingerprint/`).
25    pub files: Vec<PathBuf>,
26    /// For `build/`, `incremental/`, `.fingerprint/` units: the directory that
27    /// backs the whole unit, deleted as a whole when stale.
28    pub anchor: Option<PathBuf>,
29    /// Newest mtime seen among the files ("last used").
30    pub newest: Option<SystemTime>,
31    /// Total size of all files.
32    pub bytes: u64,
33}
34
35/// Extract `name-hash` from a Cargo artifact name.
36fn name_hash(name: &str, strip_ext: bool) -> Option<String> {
37    // Final component only (survives both `path\to\name` and `path/to/name`).
38    let file = name.replace('\\', "/");
39    let file = file.split('/').next_back()?;
40
41    let stem = if strip_ext {
42        Path::new(file).file_stem()?.to_str()?
43    } else {
44        file
45    };
46
47    // Split at the LAST `-`; no dash or an empty hash is unhashable.
48    let (name, hash) = stem.rsplit_once('-')?;
49    if hash.is_empty() {
50        return None;
51    }
52
53    // `.fingerprint/.../<dir>` anchors have no `lib` prefix — dep files do.
54    let name = if strip_ext {
55        name.strip_prefix("lib").unwrap_or(name)
56    } else {
57        name
58    };
59
60    // Normalize dashes to underscores in the name part so that
61    // `deps/libtokio_util-abc.rlib` and `.fingerprint/tokio-util-abc/`
62    // land on the SAME key and get deleted together.
63    Some(format!("{}-{}", name.replace('-', "_"), hash))
64}
65
66/// Classify a file inside the cache into its build unit.
67pub fn artifact_key(root: &Path, path: &Path) -> Option<String> {
68    if !path.starts_with(root) {
69        return None;
70    }
71    // Only the components RELATIVE to the root are scanned — otherwise the
72    // root's own name can trigger keywords (the cache root is literally
73    // named `.../epicenter/build`, which would classify every file as
74    // an `anchor` unit and silently empty the whole scan!).
75    let components: Vec<&str> = path
76        .strip_prefix(root)
77        .ok()?
78        .components()
79        .filter(|c| !matches!(c, Component::RootDir))
80        .filter_map(|c| c.as_os_str().to_str())
81        .collect();
82
83    if components.len() < 2 || components.iter().any(|c| *c == ".cargo-lock") {
84        return None;
85    }
86
87    for (i, comp) in components.iter().enumerate() {
88        match *comp {
89            // deps/<libname-hash>.rlib|.rmeta|.d ... — key from the file name
90            "deps" => {
91                let file = components.last()?;
92                return name_hash(file, true);
93            }
94            // build|incremental|.fingerprint followed by <crate-hash>= anchor
95            "build" | "incremental" | ".fingerprint" => {
96                let anchor_dir = components.get(i + 1)?;
97                return name_hash(anchor_dir, false);
98            }
99            _ => {}
100        }
101    }
102    None
103}
104
105/// For `build/`, `incremental/` and `.fingerprint/` units: the directory
106/// backing the unit (everything up to and including the component AFTER the
107/// keyword, joined onto `root`).
108fn anchor_for(root: &Path, path: &Path) -> Option<PathBuf> {
109    let rel = path.strip_prefix(root).ok()?;
110    let comps: Vec<Component> = rel.components().collect();
111    for (i, comp) in comps.iter().enumerate() {
112        let name = comp.as_os_str().to_str()?;
113        if matches!(name, "build" | "incremental" | ".fingerprint") {
114            let anchor: PathBuf = comps.iter().take(i + 2).collect();
115            return Some(root.join(anchor));
116        }
117    }
118    None
119}
120
121/// Walk `root` and group every file into units.
122pub fn scan(root: &Path) -> Result<BTreeMap<String, Group>> {
123    let mut groups: BTreeMap<String, Group> = BTreeMap::new();
124    for entry in WalkDir::new(root).min_depth(1) {
125        // Unreadable entries are skipped rather than failing the whole scan.
126        let entry = match entry {
127            Ok(e) => e,
128            Err(_) => continue,
129        };
130        if !entry.file_type().is_file() {
131            continue;
132        }
133        // `artifact_key` -> None means "never touch" (locks, root files...).
134        let Some(key) = artifact_key(root, entry.path()) else {
135            continue;
136        };
137        let meta = entry.metadata()?;
138        let group = groups.entry(key.clone()).or_insert_with(|| Group {
139            key,
140            files: Vec::new(),
141            anchor: None,
142            newest: None,
143            bytes: 0,
144        });
145        group.bytes += meta.len();
146        group.files.push(entry.into_path());
147        if let Ok(mtime) = meta.modified() {
148            if group.newest.map_or(false, |n| mtime > n) || group.newest.is_none() {
149                group.newest = Some(mtime);
150            }
151        }
152    }
153    for group in groups.values_mut() {
154        group.anchor = group.files.iter().find_map(|f| anchor_for(root, f));
155    }
156    Ok(groups)
157}
158
159#[derive(Debug, Default)]
160pub struct PruneReport {
161    pub removed_groups: u64,
162    pub kept_groups: u64,
163    pub files_freed: u64,
164    pub bytes_freed: u64,
165    pub errors: u64,
166}
167
168/// The garbage collector.
169pub fn prune(root: &Path, cutoff: SystemTime, delete: bool) -> Result<PruneReport> {
170    let groups = scan(root)?;
171    let mut report = PruneReport::default();
172    for group in groups.into_values() {
173        // No usable mtime -> keep (conservative).
174        let Some(newest) = group.newest else {
175            report.kept_groups += 1;
176            continue;
177        };
178        if newest >= cutoff {
179            report.kept_groups += 1;
180            continue;
181        }
182
183        // Count everything BEFORE deleting so dry-runs report identically.
184        report.removed_groups += 1;
185        report.files_freed += group.files.len() as u64;
186        report.bytes_freed += group.bytes;
187        if !delete {
188            continue;
189        }
190
191        match &group.anchor {
192            Some(anchor) => {
193                if let Err(e) = fs::remove_dir_all(anchor) {
194                    if e.kind() != ErrorKind::NotFound {
195                        report.errors += 1;
196                        eprintln!("could not remove {}: {e}", anchor.display());
197                    }
198                }
199                // One unit can span both `.fingerprint/<dir>` and `deps/`: the
200                // anchor covers the first family; files outside it go individually.
201                for file in &group.files {
202                    if file.starts_with(anchor) {
203                        continue;
204                    }
205                    if let Err(e) = fs::remove_file(file) {
206                        if e.kind() != ErrorKind::NotFound {
207                            report.errors += 1;
208                            eprintln!("could not remove {}: {e}", file.display());
209                        }
210                    }
211                }
212            }
213            None => {
214                for file in &group.files {
215                    if let Err(e) = fs::remove_file(file) {
216                        if e.kind() != ErrorKind::NotFound {
217                            report.errors += 1;
218                            eprintln!("could not remove {}: {e}", file.display());
219                        }
220                    }
221                }
222            }
223        }
224    }
225    Ok(report)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn name_hash_strips_and_normalizes() {
234        assert_eq!(
235            name_hash(".fingerprints\\libserde-deadbeef.rlib", true),
236            Some("serde-deadbeef".into())
237        );
238        assert_eq!(
239            name_hash("sada/libtokio_util-abc123.rmeta", true),
240            Some("tokio_util-abc123".into())
241        );
242        assert_eq!(
243            name_hash("mybin-abc123.d", true),
244            Some("mybin-abc123".into())
245        );
246        // Anchor dirs never have the `lib` prefix, but dashes -> underscores applies.
247        assert_eq!(
248            name_hash("tokio-util-abc123", false),
249            Some("tokio_util-abc123".into())
250        );
251        assert_eq!(
252            name_hash("serde-deadbeef", false),
253            Some("serde-deadbeef".into())
254        );
255    }
256
257    #[test]
258    fn name_hash_rejects_unhashable_names() {
259        assert_eq!(name_hash("nometa", false), None);
260        assert_eq!(name_hash("weird", false), None);
261    }
262
263    #[test]
264    fn keys_deps_and_fingerprints_together() {
265        let root = Path::new("/cache");
266        assert_eq!(
267            artifact_key(root, Path::new("/cache/debug/deps/libserde-deadbeef.rlib")),
268            Some("serde-deadbeef".into()),
269        );
270        assert_eq!(
271            artifact_key(
272                root,
273                Path::new("/cache/debug/.fingerprint/serde-deadbeef/root-output")
274            ),
275            Some("serde-deadbeef".into()),
276            "same unit must share one key so they get deleted together",
277        );
278    }
279
280    #[test]
281    fn keys_build_and_incremental_by_anchor_dir() {
282        let root = Path::new("/cache");
283        assert_eq!(
284            artifact_key(
285                root,
286                Path::new("/cache/debug/build/tokio-util-abc/out/main.rs")
287            ),
288            Some("tokio_util-abc".into()),
289        );
290        assert_eq!(
291            artifact_key(
292                root,
293                Path::new("/cache/debug/incremental/myproj-1a2b/s-gx/cache.bin")
294            ),
295            Some("myproj-1a2b".into()),
296        );
297    }
298
299    #[test]
300    fn leaves_protected_files_alone() {
301        let root = Path::new("/cache");
302        assert_eq!(artifact_key(root, Path::new("/cache/.cargo-lock")), None);
303        assert_eq!(
304            artifact_key(root, Path::new("/cache/.rustc_info.json")),
305            None
306        );
307        assert_eq!(artifact_key(root, Path::new("/cache/debug/mybin.d")), None);
308        assert_eq!(artifact_key(root, Path::new("/elsewhere/x")), None);
309    }
310
311    #[test]
312    fn scans_units_and_anchors() -> Result<()> {
313        let root = tempfile::tempdir()?;
314        let deps = root.path().join("debug/deps");
315        let fingerprint = root.path().join("debug/.fingerprint/anyhow-abc");
316        fs::create_dir_all(&deps)?;
317        fs::create_dir_all(&fingerprint)?;
318        fs::write(deps.join("libanyhow-abc.rlib"), [0u8; 10])?;
319        fs::write(deps.join("libanyhow-abc.rmeta"), [0u8; 5])?;
320        fs::write(fingerprint.join("root-output"), [0u8; 1])?;
321        fs::write(root.path().join("debug/.cargo-lock"), [0u8; 2])?;
322
323        let groups = scan(root.path())?;
324        assert_eq!(
325            groups.len(),
326            1,
327            "lock file is not a unit; got {:?}",
328            groups.keys()
329        );
330        let group = &groups["anyhow-abc"];
331        assert_eq!(group.files.len(), 3);
332        assert_eq!(group.bytes, 16);
333        assert_eq!(group.anchor.as_deref(), Some(fingerprint.as_path()));
334        Ok(())
335    }
336
337    #[test]
338    fn prune_reports_then_deletes_stale_units() -> Result<()> {
339        let root = tempfile::tempdir()?;
340        let deps = root.path().join("debug/deps");
341        let fingerprint = root.path().join("debug/.fingerprint/x-abc");
342        fs::create_dir_all(&deps)?;
343        fs::create_dir_all(&fingerprint)?;
344        fs::write(deps.join("libx-abc.rlib"), [0u8; 7])?;
345        fs::write(fingerprint.join("root-output"), [0u8; 2])?;
346
347        // Cutoff in the future makes everything look stale — deterministic.
348        let cutoff = SystemTime::now() + std::time::Duration::from_secs(3_600);
349
350        // Dry run: full report, but nothing actually deleted.
351        let report = prune(root.path(), cutoff, false)?;
352        assert_eq!(report.removed_groups, 1);
353        assert_eq!(report.files_freed, 2);
354        assert_eq!(report.bytes_freed, 9);
355        assert!(
356            deps.join("libx-abc.rlib").exists(),
357            "dry run must not delete"
358        );
359
360        // Real run: files AND the .fingerprint dir are gone.
361        let report = prune(root.path(), cutoff, true)?;
362        assert_eq!(report.bytes_freed, 9);
363        assert!(!deps.join("libx-abc.rlib").exists());
364        assert!(!fingerprint.exists());
365        Ok(())
366    }
367}