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
66fn is_hex16(text: &str) -> bool {
68 text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
69}
70
71pub fn artifact_key(root: &Path, path: &Path) -> Option<String> {
73 if !path.starts_with(root) {
74 return None;
75 }
76 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" => {
96 let file = components.last()?;
97 return name_hash(file, true);
98 }
99 "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 return name_hash(first, false);
107 }
108 "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
119fn 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 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
150pub 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 let entry = match entry {
156 Ok(e) => e,
157 Err(_) => continue,
158 };
159 if !entry.file_type().is_file() {
160 continue;
161 }
162 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
197pub 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 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 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 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 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 let cutoff = SystemTime::now() + std::time::Duration::from_secs(3_600);
413
414 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 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}