1use 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
20pub struct Group {
22 pub key: String,
24 pub files: Vec<PathBuf>,
26 pub anchor: Option<PathBuf>,
29 pub newest: Option<SystemTime>,
31 pub bytes: u64,
33}
34
35fn name_hash(name: &str, strip_ext: bool) -> Option<String> {
37 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 let (name, hash) = stem.rsplit_once('-')?;
49 if hash.is_empty() {
50 return None;
51 }
52
53 let name = if strip_ext {
55 name.strip_prefix("lib").unwrap_or(name)
56 } else {
57 name
58 };
59
60 Some(format!("{}-{}", name.replace('-', "_"), hash))
64}
65
66pub fn artifact_key(root: &Path, path: &Path) -> Option<String> {
68 if !path.starts_with(root) {
69 return None;
70 }
71 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" => {
91 let file = components.last()?;
92 return name_hash(file, true);
93 }
94 "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
105fn 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
121pub 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 let entry = match entry {
127 Ok(e) => e,
128 Err(_) => continue,
129 };
130 if !entry.file_type().is_file() {
131 continue;
132 }
133 let Some(key) = artifact_key(root, entry.path()) else {
135 continue;
136 };
137 let Ok(meta) = entry.metadata() else { continue };
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
168pub 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 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 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 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 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 let cutoff = SystemTime::now() + std::time::Duration::from_secs(3_600);
349
350 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 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}