1use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29
30use anyhow::Result;
31
32use crate::adapters;
33use crate::constants;
34use crate::json;
35use crate::output;
36
37pub struct CacheReport {
39 pub manager: &'static str,
41 pub kind: &'static str,
43 pub path: PathBuf,
45 pub bytes: u64,
47 pub clear_command: &'static str,
49 pub note: Option<&'static str>,
51}
52
53struct Probe {
55 manager: &'static str,
56 kind: &'static str,
57 query: Option<(&'static str, &'static [&'static str])>,
63 clear_command: &'static str,
64 note: Option<&'static str>,
65}
66
67#[cfg(windows)]
70const CARGO_CACHE_CLEAR: &str =
71 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
72#[cfg(not(windows))]
73const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
74
75#[cfg(windows)]
76const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
77#[cfg(not(windows))]
78const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
79
80#[cfg(windows)]
84const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
85#[cfg(not(windows))]
86const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
87
88#[cfg(windows)]
89const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
90#[cfg(not(windows))]
91const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
92
93#[cfg(windows)]
94const GRADLE_DISTS_CLEAR: &str =
95 r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
96#[cfg(not(windows))]
97const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
98
99#[cfg(windows)]
100const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
101#[cfg(not(windows))]
102const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
103
104const PROBES: &[Probe] = &[
105 Probe {
106 manager: "npm",
107 kind: "cache",
108 query: Some(("npm", &["config", "get", "cache"])),
109 clear_command: "npm cache clean --force",
110 note: None,
111 },
112 Probe {
113 manager: "pnpm",
114 kind: "store",
115 query: Some(("pnpm", &["store", "path"])),
116 clear_command: "pnpm store prune",
117 note: Some(
118 "hardlinked into every node_modules on the machine; emptying it is what makes \
119 the next pnpm install a download",
120 ),
121 },
122 Probe {
123 manager: "yarn",
124 kind: "cache",
125 query: Some(("yarn", &["cache", "dir"])),
126 clear_command: "yarn cache clean",
127 note: None,
128 },
129 Probe {
130 manager: "bun",
131 kind: "cache",
132 query: Some(("bun", &["pm", "cache"])),
133 clear_command: "bun pm cache rm",
134 note: None,
135 },
136 Probe {
137 manager: "uv",
138 kind: "cache",
139 query: Some(("uv", &["cache", "dir"])),
140 clear_command: "uv cache prune",
143 note: None,
144 },
145 Probe {
146 manager: "pip",
147 kind: "cache",
148 query: Some(("pip", &["cache", "dir"])),
149 clear_command: "pip cache purge",
150 note: None,
151 },
152 Probe {
153 manager: "cargo",
154 kind: "registry cache",
155 query: None,
156 clear_command: CARGO_CACHE_CLEAR,
157 note: Some("the downloaded .crate archives; clearing them means downloading again"),
158 },
159 Probe {
160 manager: "cargo",
161 kind: "registry sources",
162 query: None,
163 clear_command: CARGO_SRC_CLEAR,
164 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
165 },
166 Probe {
167 manager: "go",
168 kind: "module cache",
169 query: Some(("go", &["env", "GOMODCACHE"])),
170 clear_command: "go clean -modcache",
171 note: None,
172 },
173 Probe {
174 manager: "go",
175 kind: "build cache",
176 query: Some(("go", &["env", "GOCACHE"])),
177 clear_command: "go clean -cache",
178 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
179 },
180 Probe {
185 manager: "maven",
186 kind: "local repository",
187 query: None,
188 clear_command: MAVEN_REPO_CLEAR,
189 note: Some(
190 "every Maven build on the machine resolves from here; the next build re-downloads what it needs",
191 ),
192 },
193 Probe {
194 manager: "gradle",
195 kind: "caches",
196 query: None,
197 clear_command: GRADLE_CACHE_CLEAR,
198 note: Some(
199 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
200 ),
201 },
202 Probe {
203 manager: "gradle",
204 kind: "wrapper distributions",
205 query: None,
206 clear_command: GRADLE_DISTS_CLEAR,
207 note: Some(
208 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
209 ),
210 },
211 Probe {
215 manager: "nuget",
216 kind: "global packages",
217 query: None,
218 clear_command: "dotnet nuget locals global-packages --clear",
219 note: Some(
220 "every .NET project on the machine restores from here; re-downloaded on the next restore",
221 ),
222 },
223 Probe {
224 manager: "vcpkg",
225 kind: "binary cache",
226 query: None,
227 clear_command: VCPKG_ARCHIVES_CLEAR,
228 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
229 },
230 Probe {
231 manager: "conan",
232 kind: "package cache",
233 query: None,
234 clear_command: "conan remove \"*\" --confirm",
235 note: Some(
236 "recipes and binaries shared by every Conan project; re-fetched on the next install",
237 ),
238 },
239];
240
241pub fn run(json_output: bool) -> Result<()> {
243 let reports = collect(!json_output);
244
245 if json_output {
246 return json::emit(&json::caches_document(&reports));
247 }
248
249 print_report(&reports);
250 Ok(())
251}
252
253fn collect(spinner: bool) -> Vec<CacheReport> {
255 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
256 let from = query_dir();
257
258 let mut seen: HashSet<PathBuf> = HashSet::new();
259 let mut reports = Vec::new();
260
261 for probe in PROBES {
262 let Some(path) = locate(probe, &from) else {
263 continue;
264 };
265 let path = path.canonicalize().unwrap_or(path);
272 if !seen.insert(path.clone()) {
273 continue;
274 }
275 reports.push(CacheReport {
276 manager: probe.manager,
277 kind: probe.kind,
278 bytes: adapters::dir_size(&path),
279 path,
280 clear_command: probe.clear_command,
281 note: probe.note,
282 });
283 }
284
285 if let Some(pb) = pb {
286 pb.finish_and_clear();
287 }
288
289 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
290 reports
291}
292
293fn query_dir() -> PathBuf {
300 dirs::home_dir()
301 .or_else(|| std::env::current_dir().ok())
302 .unwrap_or_else(|| PathBuf::from("."))
303}
304
305fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
307 if let Some((program, args)) = probe.query {
308 if adapters::binary_available(program) {
309 let answered = adapters::capture_command_with_timeout(
310 program,
311 args,
312 from,
313 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
314 )
315 .ok()
316 .and_then(|raw| path_from_output(&raw))
317 .filter(|p| p.is_dir());
318 if answered.is_some() {
319 return answered;
320 }
321 }
322 }
323
324 fallbacks(probe.manager, probe.kind)
329 .into_iter()
330 .find(|p| p.is_dir())
331}
332
333fn path_from_output(raw: &str) -> Option<PathBuf> {
338 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
339 let line = line.trim_matches('"');
340 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
343 return None;
344 }
345 Some(PathBuf::from(line))
346}
347
348fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
350 let home = dirs::home_dir();
351 let local = dirs::data_local_dir();
352 let cache = dirs::cache_dir();
353 let under = |base: &Option<PathBuf>, rel: &str| {
357 base.as_ref()
358 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
359 };
360
361 let candidates = match (manager, kind) {
362 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
365 ("pnpm", _) => vec![
366 under(&local, "pnpm/store"),
367 under(&home, ".local/share/pnpm/store"),
368 under(&home, "Library/pnpm/store"),
369 under(&home, ".pnpm-store"),
370 ],
371 ("yarn", _) => vec![
372 under(&home, ".yarn/berry/cache"),
373 under(&local, "Yarn/Cache"),
374 under(&cache, "yarn"),
375 ],
376 ("bun", _) => vec![under(&home, ".bun/install/cache")],
377 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
378 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
379 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
380 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
381 ("go", "module cache") => vec![
382 std::env::var_os("GOMODCACHE").map(PathBuf::from),
383 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
384 under(&home, "go/pkg/mod"),
385 ],
386 ("go", _) => vec![
387 std::env::var_os("GOCACHE").map(PathBuf::from),
388 under(&cache, "go-build"),
389 under(&local, "go-build"),
390 ],
391 ("maven", _) => vec![under(&home, ".m2/repository")],
392 ("gradle", "caches") => vec![
394 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
395 under(&home, ".gradle/caches"),
396 ],
397 ("gradle", _) => vec![
398 std::env::var_os("GRADLE_USER_HOME")
399 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
400 under(&home, ".gradle/wrapper/dists"),
401 ],
402 ("nuget", _) => vec![
403 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
404 under(&home, ".nuget/packages"),
405 ],
406 ("vcpkg", _) => vec![
407 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
408 under(&local, "vcpkg/archives"),
409 under(&cache, "vcpkg/archives"),
410 ],
411 ("conan", _) => vec![
414 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
415 under(&home, ".conan2/p"),
416 ],
417 _ => vec![],
418 };
419
420 candidates.into_iter().flatten().collect()
421}
422
423fn cargo_home() -> PathBuf {
425 std::env::var_os("CARGO_HOME")
426 .map(PathBuf::from)
427 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
428 .unwrap_or_else(|| PathBuf::from(".cargo"))
429}
430
431fn print_report(reports: &[CacheReport]) {
432 output::print_header("Package manager caches");
433
434 if reports.is_empty() {
435 println!();
436 output::print_info("No package manager caches found on this machine.");
437 return;
438 }
439
440 println!();
441 for r in reports {
442 let label = format!("{} {}", r.manager, r.kind);
443 println!(
444 " {:<22} {:>10} {}",
445 label,
446 output::format_bytes(r.bytes),
447 output::clean_path(&r.path)
448 );
449 println!(" {:<22} {:>10} clear: {}", "", "", r.clear_command);
450 if let Some(note) = r.note {
451 println!(" {:<22} {:>10} {}", "", "", note);
452 }
453 println!();
454 }
455
456 let total: u64 = reports.iter().map(|r| r.bytes).sum();
457 println!(
458 " {:<22} {:>10} across {} {}",
459 "Total",
460 output::format_bytes(total),
461 reports.len(),
462 output::plural(reports.len(), "cache", "caches")
463 );
464
465 println!();
466 output::print_info(
467 "Nothing above was deleted, and dev-prune never deletes any of it. A cache is \
468 shared by every project on the machine, so no single repository's lockfile can \
469 prove it is recoverable — and it is what makes `devp restore` fast. Run a clear \
470 command yourself when you want the space more than the speed.",
471 );
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn every_probe_can_be_found_without_its_manager_installed() {
480 for probe in PROBES {
483 assert!(
484 !fallbacks(probe.manager, probe.kind).is_empty(),
485 "{} {} has no conventional location",
486 probe.manager,
487 probe.kind
488 );
489 }
490 }
491
492 #[test]
493 fn every_probe_names_the_command_that_clears_it() {
494 for probe in PROBES {
495 assert!(
496 !probe.clear_command.trim().is_empty(),
497 "{} {} reports a size with no way to act on it",
498 probe.manager,
499 probe.kind
500 );
501 }
502 }
503
504 #[test]
505 fn no_two_probes_describe_the_same_cache() {
506 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
507 let count = keys.len();
508 keys.sort_unstable();
509 keys.dedup();
510 assert_eq!(keys.len(), count, "two probes share a manager and kind");
511 }
512
513 #[test]
514 fn a_managers_answer_is_read_off_the_last_line() {
515 let raw = if cfg!(windows) {
517 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
518 } else {
519 "npm warn config global deprecated\n/home/dev/.npm\n"
520 };
521 assert!(path_from_output(raw).is_some());
522 }
523
524 #[test]
525 fn quoted_paths_lose_their_quotes() {
526 let raw = if cfg!(windows) {
527 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
528 } else {
529 "\"/opt/go path/pkg/mod\"\n"
530 };
531 let path = path_from_output(raw).expect("a quoted path is still a path");
532 assert!(!path.to_string_lossy().contains('"'));
533 }
534
535 #[test]
536 fn a_non_answer_is_not_mistaken_for_a_path() {
537 for raw in [
540 "",
541 "\n \n",
542 "undefined\n",
543 "not a command\n",
544 "./relative\n",
545 ] {
546 assert!(
547 path_from_output(raw).is_none(),
548 "{raw:?} was accepted as a cache path"
549 );
550 }
551 }
552
553 #[test]
554 fn the_cargo_rows_point_inside_the_registry() {
555 for kind in ["registry cache", "registry sources"] {
558 let path = fallbacks("cargo", kind).remove(0);
559 assert!(
560 path.starts_with(cargo_home().join("registry")),
561 "{kind} resolved outside the cargo registry: {}",
562 path.display()
563 );
564 }
565 }
566
567 #[test]
568 fn the_report_is_ordered_by_what_is_worth_clearing() {
569 let mut reports = [
570 CacheReport {
571 manager: "npm",
572 kind: "cache",
573 path: PathBuf::from("/a"),
574 bytes: 10,
575 clear_command: "x",
576 note: None,
577 },
578 CacheReport {
579 manager: "go",
580 kind: "module cache",
581 path: PathBuf::from("/b"),
582 bytes: 4_000,
583 clear_command: "y",
584 note: None,
585 },
586 ];
587 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
588 assert_eq!(reports[0].manager, "go");
589 }
590}