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/// A 16-hex unit hash (`9709d57b46b8d526`).
67fn is_hex16(text: &str) -> bool {
68    text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
69}
70
71/// Classify a file inside the cache into its build unit.
72pub fn artifact_key(root: &Path, path: &Path) -> Option<String> {
73    if !path.starts_with(root) {
74        return None;
75    }
76    // Only the components RELATIVE to the root are scanned — otherwise the
77    // root's own name can trigger keywords (the cache root is literally
78    // named `.../epicenter/build`, which would classify every file as
79    // an `anchor` unit and silently empty the whole scan!).
80    let components: Vec<&str> = path
81        .strip_prefix(root)
82        .ok()?
83        .components()
84        .filter(|c| !matches!(c, Component::RootDir))
85        .filter_map(|c| c.as_os_str().to_str())
86        .collect();
87
88    if components.len() < 2 || components.iter().any(|c| *c == ".cargo-lock") {
89        return None;
90    }
91
92    for (i, comp) in components.iter().enumerate() {
93        match *comp {
94            // deps/<libname-hash>.rlib|.rmeta|.d ... — key from the file name
95            "deps" => {
96                let file = components.last()?;
97                return name_hash(file, true);
98            }
99            // New layout: build/<package>/<16hex>/… — one directory per unit.
100            "build" => {
101                let first = components.get(i + 1)?;
102                if let Some(hash) = components.get(i + 2).filter(|hash| is_hex16(hash)) {
103                    return Some(format!("{}-{}", first.replace('-', "_"), hash));
104                }
105                // Classic: build/<package>-<hash>/…
106                return name_hash(first, false);
107            }
108            // incremental/<crate-hash>/, .fingerprint/<crate-hash>/
109            "incremental" | ".fingerprint" => {
110                let anchor_dir = components.get(i + 1)?;
111                return name_hash(anchor_dir, false);
112            }
113            _ => {}
114        }
115    }
116    None
117}
118
119/// For `build/`, `incremental/` and `.fingerprint/` units: the directory
120/// backing the unit (everything up to and including the component AFTER the
121/// keyword, joined onto `root`).
122fn anchor_for(root: &Path, path: &Path) -> Option<PathBuf> {
123    let rel = path.strip_prefix(root).ok()?;
124    let comps: Vec<Component> = rel.components().collect();
125    for (i, comp) in comps.iter().enumerate() {
126        let name = comp.as_os_str().to_str()?;
127        match name {
128            "build" => {
129                // New layout: build/<pkg>/<16hex> (do NOT collapse to
130                // build/<pkg> — that would delete sibling units).
131                let new_layout = comps
132                    .get(i + 2)
133                    .and_then(|c| c.as_os_str().to_str())
134                    .map(is_hex16)
135                    .unwrap_or(false);
136                let take = if new_layout { i + 3 } else { i + 2 };
137                let anchor: PathBuf = comps.iter().take(take).collect();
138                return Some(root.join(anchor));
139            }
140            "incremental" | ".fingerprint" => {
141                let anchor: PathBuf = comps.iter().take(i + 2).collect();
142                return Some(root.join(anchor));
143            }
144            _ => {}
145        }
146    }
147    None
148}
149
150/// Walk `root` and group every file into units.
151pub fn scan(root: &Path) -> Result<BTreeMap<String, Group>> {
152    let mut groups: BTreeMap<String, Group> = BTreeMap::new();
153    for entry in WalkDir::new(root).min_depth(1) {
154        // Unreadable entries are skipped rather than failing the whole scan.
155        let entry = match entry {
156            Ok(e) => e,
157            Err(_) => continue,
158        };
159        if !entry.file_type().is_file() {
160            continue;
161        }
162        // `artifact_key` -> None means "never touch" (locks, root files...).
163        let Some(key) = artifact_key(root, entry.path()) else {
164            continue;
165        };
166        let Ok(meta) = entry.metadata() else { continue };
167        let group = groups.entry(key.clone()).or_insert_with(|| Group {
168            key,
169            files: Vec::new(),
170            anchor: None,
171            newest: None,
172            bytes: 0,
173        });
174        group.bytes += meta.len();
175        group.files.push(entry.into_path());
176        if let Ok(mtime) = meta.modified() {
177            if group.newest.map_or(false, |n| mtime > n) || group.newest.is_none() {
178                group.newest = Some(mtime);
179            }
180        }
181    }
182    for group in groups.values_mut() {
183        group.anchor = group.files.iter().find_map(|f| anchor_for(root, f));
184    }
185    Ok(groups)
186}
187
188#[derive(Debug, Default)]
189pub struct PruneReport {
190    pub removed_groups: u64,
191    pub kept_groups: u64,
192    pub files_freed: u64,
193    pub bytes_freed: u64,
194    pub errors: u64,
195}
196
197/// The garbage collector.
198pub fn prune(root: &Path, cutoff: SystemTime, delete: bool) -> Result<PruneReport> {
199    let groups = scan(root)?;
200    let mut report = PruneReport::default();
201    for group in groups.into_values() {
202        // No usable mtime -> keep (conservative).
203        let Some(newest) = group.newest else {
204            report.kept_groups += 1;
205            continue;
206        };
207        if newest >= cutoff {
208            report.kept_groups += 1;
209            continue;
210        }
211
212        // Count everything BEFORE deleting so dry-runs report identically.
213        report.removed_groups += 1;
214        report.files_freed += group.files.len() as u64;
215        report.bytes_freed += group.bytes;
216        if !delete {
217            continue;
218        }
219
220        match &group.anchor {
221            Some(anchor) => {
222                if let Err(e) = fs::remove_dir_all(anchor) {
223                    if e.kind() != ErrorKind::NotFound {
224                        report.errors += 1;
225                        eprintln!("could not remove {}: {e}", anchor.display());
226                    }
227                }
228                // One unit can span both `.fingerprint/<dir>` and `deps/`: the
229                // anchor covers the first family; files outside it go individually.
230                for file in &group.files {
231                    if file.starts_with(anchor) {
232                        continue;
233                    }
234                    if let Err(e) = fs::remove_file(file) {
235                        if e.kind() != ErrorKind::NotFound {
236                            report.errors += 1;
237                            eprintln!("could not remove {}: {e}", file.display());
238                        }
239                    }
240                }
241            }
242            None => {
243                for file in &group.files {
244                    if let Err(e) = fs::remove_file(file) {
245                        if e.kind() != ErrorKind::NotFound {
246                            report.errors += 1;
247                            eprintln!("could not remove {}: {e}", file.display());
248                        }
249                    }
250                }
251            }
252        }
253    }
254    Ok(report)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn name_hash_strips_and_normalizes() {
263        assert_eq!(
264            name_hash(".fingerprints\\libserde-deadbeef.rlib", true),
265            Some("serde-deadbeef".into())
266        );
267        assert_eq!(
268            name_hash("sada/libtokio_util-abc123.rmeta", true),
269            Some("tokio_util-abc123".into())
270        );
271        assert_eq!(
272            name_hash("mybin-abc123.d", true),
273            Some("mybin-abc123".into())
274        );
275        // Anchor dirs never have the `lib` prefix, but dashes -> underscores applies.
276        assert_eq!(
277            name_hash("tokio-util-abc123", false),
278            Some("tokio_util-abc123".into())
279        );
280        assert_eq!(
281            name_hash("serde-deadbeef", false),
282            Some("serde-deadbeef".into())
283        );
284    }
285
286    #[test]
287    fn name_hash_rejects_unhashable_names() {
288        assert_eq!(name_hash("nometa", false), None);
289        assert_eq!(name_hash("weird", false), None);
290    }
291
292    #[test]
293    fn keys_deps_and_fingerprints_together() {
294        let root = Path::new("/cache");
295        assert_eq!(
296            artifact_key(root, Path::new("/cache/debug/deps/libserde-deadbeef.rlib")),
297            Some("serde-deadbeef".into()),
298        );
299        assert_eq!(
300            artifact_key(
301                root,
302                Path::new("/cache/debug/.fingerprint/serde-deadbeef/root-output")
303            ),
304            Some("serde-deadbeef".into()),
305            "same unit must share one key so they get deleted together",
306        );
307    }
308
309    #[test]
310    fn keys_build_and_incremental_by_anchor_dir() {
311        let root = Path::new("/cache");
312        assert_eq!(
313            artifact_key(
314                root,
315                Path::new("/cache/debug/build/tokio-util-abc/out/main.rs")
316            ),
317            Some("tokio_util-abc".into()),
318        );
319        assert_eq!(
320            artifact_key(
321                root,
322                Path::new("/cache/debug/incremental/myproj-1a2b/s-gx/cache.bin")
323            ),
324            Some("myproj-1a2b".into()),
325        );
326    }
327
328    #[test]
329    fn leaves_protected_files_alone() {
330        let root = Path::new("/cache");
331        assert_eq!(artifact_key(root, Path::new("/cache/.cargo-lock")), None);
332        assert_eq!(
333            artifact_key(root, Path::new("/cache/.rustc_info.json")),
334            None
335        );
336        assert_eq!(artifact_key(root, Path::new("/cache/debug/mybin.d")), None);
337        assert_eq!(artifact_key(root, Path::new("/elsewhere/x")), None);
338    }
339
340    #[test]
341    fn scans_units_and_anchors() -> Result<()> {
342        let root = tempfile::tempdir()?;
343        let deps = root.path().join("debug/deps");
344        let fingerprint = root.path().join("debug/.fingerprint/anyhow-abc");
345        fs::create_dir_all(&deps)?;
346        fs::create_dir_all(&fingerprint)?;
347        fs::write(deps.join("libanyhow-abc.rlib"), [0u8; 10])?;
348        fs::write(deps.join("libanyhow-abc.rmeta"), [0u8; 5])?;
349        fs::write(fingerprint.join("root-output"), [0u8; 1])?;
350        fs::write(root.path().join("debug/.cargo-lock"), [0u8; 2])?;
351
352        let groups = scan(root.path())?;
353        assert_eq!(
354            groups.len(),
355            1,
356            "lock file is not a unit; got {:?}",
357            groups.keys()
358        );
359        let group = &groups["anyhow-abc"];
360        assert_eq!(group.files.len(), 3);
361        assert_eq!(group.bytes, 16);
362        assert_eq!(group.anchor.as_deref(), Some(fingerprint.as_path()));
363        Ok(())
364    }
365
366    #[test]
367    fn scans_new_layout_units_per_unit_dir() -> Result<()> {
368        let root = tempfile::tempdir()?;
369        let one = root.path().join("debug/build/anyhow/9709d57b46b8d526");
370        let two = root.path().join("debug/build/anyhow/6d5788609cdad9a4");
371        for unit in [&one, &two] {
372            fs::create_dir_all(unit.join("fingerprint"))?;
373            fs::create_dir_all(unit.join("out"))?;
374            fs::write(unit.join("fingerprint/lib-anyhow"), "aaaaaaaabbbbbbbb")?;
375            fs::write(unit.join("out/libanyhow-9709d57b46b8d526.rlib"), [0u8; 10])?;
376        }
377
378        let groups = scan(root.path())?;
379        assert_eq!(
380            groups.len(),
381            2,
382            "two units of one package: {:?}",
383            groups.keys()
384        );
385        assert!(groups.keys().all(|key| key.starts_with("anyhow-")));
386        for (key, group) in &groups {
387            let unit = if key.ends_with("9709d57b46b8d526") {
388                &one
389            } else {
390                &two
391            };
392            assert_eq!(
393                group.anchor.as_deref(),
394                Some(unit.as_path()),
395                "anchor must stop at the unit dir, never build/<pkg>"
396            );
397        }
398        Ok(())
399    }
400
401    #[test]
402    fn prune_reports_then_deletes_stale_units() -> Result<()> {
403        let root = tempfile::tempdir()?;
404        let deps = root.path().join("debug/deps");
405        let fingerprint = root.path().join("debug/.fingerprint/x-abc");
406        fs::create_dir_all(&deps)?;
407        fs::create_dir_all(&fingerprint)?;
408        fs::write(deps.join("libx-abc.rlib"), [0u8; 7])?;
409        fs::write(fingerprint.join("root-output"), [0u8; 2])?;
410
411        // Cutoff in the future makes everything look stale — deterministic.
412        let cutoff = SystemTime::now() + std::time::Duration::from_secs(3_600);
413
414        // Dry run: full report, but nothing actually deleted.
415        let report = prune(root.path(), cutoff, false)?;
416        assert_eq!(report.removed_groups, 1);
417        assert_eq!(report.files_freed, 2);
418        assert_eq!(report.bytes_freed, 9);
419        assert!(
420            deps.join("libx-abc.rlib").exists(),
421            "dry run must not delete"
422        );
423
424        // Real run: files AND the .fingerprint dir are gone.
425        let report = prune(root.path(), cutoff, true)?;
426        assert_eq!(report.bytes_freed, 9);
427        assert!(!deps.join("libx-abc.rlib").exists());
428        assert!(!fingerprint.exists());
429        Ok(())
430    }
431}