1use std::collections::HashSet;
41use std::path::{Path, PathBuf};
42
43use anyhow::Result;
44
45use crate::adapters;
46use crate::constants;
47use crate::json;
48use crate::output;
49
50pub struct CacheReport {
52 pub manager: &'static str,
54 pub kind: &'static str,
56 pub path: PathBuf,
58 pub bytes: u64,
60 pub clear_command: &'static str,
62 pub note: Option<&'static str>,
64}
65
66struct Probe {
68 manager: &'static str,
69 kind: &'static str,
70 query: Option<(&'static str, &'static [&'static str])>,
76 clear_command: &'static str,
77 note: Option<&'static str>,
78}
79
80#[cfg(windows)]
83const CARGO_CACHE_CLEAR: &str =
84 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
85#[cfg(not(windows))]
86const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
87
88#[cfg(windows)]
89const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
90#[cfg(not(windows))]
91const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
92
93const PROBES: &[Probe] = &[
94 Probe {
95 manager: "npm",
96 kind: "cache",
97 query: Some(("npm", &["config", "get", "cache"])),
98 clear_command: "npm cache clean --force",
99 note: None,
100 },
101 Probe {
102 manager: "pnpm",
103 kind: "store",
104 query: Some(("pnpm", &["store", "path"])),
105 clear_command: "pnpm store prune",
106 note: Some(
107 "hardlinked into every node_modules on the machine; emptying it is what makes \
108 the next pnpm install a download",
109 ),
110 },
111 Probe {
112 manager: "yarn",
113 kind: "cache",
114 query: Some(("yarn", &["cache", "dir"])),
115 clear_command: "yarn cache clean",
116 note: None,
117 },
118 Probe {
119 manager: "bun",
120 kind: "cache",
121 query: Some(("bun", &["pm", "cache"])),
122 clear_command: "bun pm cache rm",
123 note: None,
124 },
125 Probe {
126 manager: "uv",
127 kind: "cache",
128 query: Some(("uv", &["cache", "dir"])),
129 clear_command: "uv cache prune",
132 note: None,
133 },
134 Probe {
135 manager: "pip",
136 kind: "cache",
137 query: Some(("pip", &["cache", "dir"])),
138 clear_command: "pip cache purge",
139 note: None,
140 },
141 Probe {
142 manager: "cargo",
143 kind: "registry cache",
144 query: None,
145 clear_command: CARGO_CACHE_CLEAR,
146 note: Some("the downloaded .crate archives; clearing them means downloading again"),
147 },
148 Probe {
149 manager: "cargo",
150 kind: "registry sources",
151 query: None,
152 clear_command: CARGO_SRC_CLEAR,
153 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
154 },
155 Probe {
156 manager: "go",
157 kind: "module cache",
158 query: Some(("go", &["env", "GOMODCACHE"])),
159 clear_command: "go clean -modcache",
160 note: None,
161 },
162 Probe {
163 manager: "go",
164 kind: "build cache",
165 query: Some(("go", &["env", "GOCACHE"])),
166 clear_command: "go clean -cache",
167 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
168 },
169];
170
171pub fn run(json_output: bool) -> Result<()> {
173 let reports = collect(!json_output);
174
175 if json_output {
176 return json::emit(&json::caches_document(&reports));
177 }
178
179 print_report(&reports);
180 Ok(())
181}
182
183fn collect(spinner: bool) -> Vec<CacheReport> {
185 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
186 let from = query_dir();
187
188 let mut seen: HashSet<PathBuf> = HashSet::new();
189 let mut reports = Vec::new();
190
191 for probe in PROBES {
192 let Some(path) = locate(probe, &from) else {
193 continue;
194 };
195 let path = path.canonicalize().unwrap_or(path);
202 if !seen.insert(path.clone()) {
203 continue;
204 }
205 reports.push(CacheReport {
206 manager: probe.manager,
207 kind: probe.kind,
208 bytes: adapters::dir_size(&path),
209 path,
210 clear_command: probe.clear_command,
211 note: probe.note,
212 });
213 }
214
215 if let Some(pb) = pb {
216 pb.finish_and_clear();
217 }
218
219 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
220 reports
221}
222
223fn query_dir() -> PathBuf {
230 dirs::home_dir()
231 .or_else(|| std::env::current_dir().ok())
232 .unwrap_or_else(|| PathBuf::from("."))
233}
234
235fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
237 if let Some((program, args)) = probe.query {
238 if adapters::binary_available(program) {
239 let answered = adapters::capture_command_with_timeout(
240 program,
241 args,
242 from,
243 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
244 )
245 .ok()
246 .and_then(|raw| path_from_output(&raw))
247 .filter(|p| p.is_dir());
248 if answered.is_some() {
249 return answered;
250 }
251 }
252 }
253
254 fallbacks(probe.manager, probe.kind)
259 .into_iter()
260 .find(|p| p.is_dir())
261}
262
263fn path_from_output(raw: &str) -> Option<PathBuf> {
268 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
269 let line = line.trim_matches('"');
270 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
273 return None;
274 }
275 Some(PathBuf::from(line))
276}
277
278fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
280 let home = dirs::home_dir();
281 let local = dirs::data_local_dir();
282 let cache = dirs::cache_dir();
283 let under = |base: &Option<PathBuf>, rel: &str| {
287 base.as_ref()
288 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
289 };
290
291 let candidates = match (manager, kind) {
292 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
295 ("pnpm", _) => vec![
296 under(&local, "pnpm/store"),
297 under(&home, ".local/share/pnpm/store"),
298 under(&home, "Library/pnpm/store"),
299 under(&home, ".pnpm-store"),
300 ],
301 ("yarn", _) => vec![
302 under(&home, ".yarn/berry/cache"),
303 under(&local, "Yarn/Cache"),
304 under(&cache, "yarn"),
305 ],
306 ("bun", _) => vec![under(&home, ".bun/install/cache")],
307 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
308 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
309 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
310 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
311 ("go", "module cache") => vec![
312 std::env::var_os("GOMODCACHE").map(PathBuf::from),
313 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
314 under(&home, "go/pkg/mod"),
315 ],
316 ("go", _) => vec![
317 std::env::var_os("GOCACHE").map(PathBuf::from),
318 under(&cache, "go-build"),
319 under(&local, "go-build"),
320 ],
321 _ => vec![],
322 };
323
324 candidates.into_iter().flatten().collect()
325}
326
327fn cargo_home() -> PathBuf {
329 std::env::var_os("CARGO_HOME")
330 .map(PathBuf::from)
331 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
332 .unwrap_or_else(|| PathBuf::from(".cargo"))
333}
334
335fn print_report(reports: &[CacheReport]) {
336 output::print_header("Package manager caches");
337
338 if reports.is_empty() {
339 println!();
340 output::print_info("No package manager caches found on this machine.");
341 return;
342 }
343
344 println!();
345 for r in reports {
346 let label = format!("{} {}", r.manager, r.kind);
347 println!(
348 " {:<22} {:>10} {}",
349 label,
350 output::format_bytes(r.bytes),
351 output::clean_path(&r.path)
352 );
353 println!(" {:<22} {:>10} clear: {}", "", "", r.clear_command);
354 if let Some(note) = r.note {
355 println!(" {:<22} {:>10} {}", "", "", note);
356 }
357 println!();
358 }
359
360 let total: u64 = reports.iter().map(|r| r.bytes).sum();
361 println!(
362 " {:<22} {:>10} across {} {}",
363 "Total",
364 output::format_bytes(total),
365 reports.len(),
366 output::plural(reports.len(), "cache", "caches")
367 );
368
369 println!();
370 output::print_info(
371 "Nothing above was deleted, and dev-prune never deletes any of it. A cache is \
372 shared by every project on the machine, so no single repository's lockfile can \
373 prove it is recoverable — and it is what makes `devp restore` fast. Run a clear \
374 command yourself when you want the space more than the speed.",
375 );
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn every_probe_can_be_found_without_its_manager_installed() {
384 for probe in PROBES {
387 assert!(
388 !fallbacks(probe.manager, probe.kind).is_empty(),
389 "{} {} has no conventional location",
390 probe.manager,
391 probe.kind
392 );
393 }
394 }
395
396 #[test]
397 fn every_probe_names_the_command_that_clears_it() {
398 for probe in PROBES {
399 assert!(
400 !probe.clear_command.trim().is_empty(),
401 "{} {} reports a size with no way to act on it",
402 probe.manager,
403 probe.kind
404 );
405 }
406 }
407
408 #[test]
409 fn no_two_probes_describe_the_same_cache() {
410 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
411 let count = keys.len();
412 keys.sort_unstable();
413 keys.dedup();
414 assert_eq!(keys.len(), count, "two probes share a manager and kind");
415 }
416
417 #[test]
418 fn a_managers_answer_is_read_off_the_last_line() {
419 let raw = if cfg!(windows) {
421 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
422 } else {
423 "npm warn config global deprecated\n/home/dev/.npm\n"
424 };
425 assert!(path_from_output(raw).is_some());
426 }
427
428 #[test]
429 fn quoted_paths_lose_their_quotes() {
430 let raw = if cfg!(windows) {
431 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
432 } else {
433 "\"/opt/go path/pkg/mod\"\n"
434 };
435 let path = path_from_output(raw).expect("a quoted path is still a path");
436 assert!(!path.to_string_lossy().contains('"'));
437 }
438
439 #[test]
440 fn a_non_answer_is_not_mistaken_for_a_path() {
441 for raw in [
444 "",
445 "\n \n",
446 "undefined\n",
447 "not a command\n",
448 "./relative\n",
449 ] {
450 assert!(
451 path_from_output(raw).is_none(),
452 "{raw:?} was accepted as a cache path"
453 );
454 }
455 }
456
457 #[test]
458 fn the_cargo_rows_point_inside_the_registry() {
459 for kind in ["registry cache", "registry sources"] {
462 let path = fallbacks("cargo", kind).remove(0);
463 assert!(
464 path.starts_with(cargo_home().join("registry")),
465 "{kind} resolved outside the cargo registry: {}",
466 path.display()
467 );
468 }
469 }
470
471 #[test]
472 fn the_report_is_ordered_by_what_is_worth_clearing() {
473 let mut reports = [
474 CacheReport {
475 manager: "npm",
476 kind: "cache",
477 path: PathBuf::from("/a"),
478 bytes: 10,
479 clear_command: "x",
480 note: None,
481 },
482 CacheReport {
483 manager: "go",
484 kind: "module cache",
485 path: PathBuf::from("/b"),
486 bytes: 4_000,
487 clear_command: "y",
488 note: None,
489 },
490 ];
491 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
492 assert_eq!(reports[0].manager, "go");
493 }
494}