1use std::collections::{BTreeMap, HashSet};
42use std::path::{Path, PathBuf};
43
44use anyhow::Result;
45use colored::Colorize as _;
46
47use crate::adapters;
48use crate::constants;
49use crate::i18n;
50use crate::json;
51use crate::output;
52
53pub struct CacheReport {
55 pub manager: &'static str,
57 pub kind: &'static str,
59 pub path: PathBuf,
61 pub bytes: u64,
63 pub clear_command: String,
69 pub clear: Clear,
71 pub note: Option<&'static str>,
73 pub cap_gb: Option<u64>,
78 pub over_cap: bool,
85 pub dependents: Option<usize>,
96 pub extra_args: Vec<String>,
104}
105
106#[derive(Clone, Copy)]
108pub enum Clear {
109 Command(&'static str, &'static [&'static str]),
113 Directory,
116 Manual { why: &'static str },
120}
121
122struct Probe {
124 manager: &'static str,
125 kind: &'static str,
126 query: Option<(&'static str, &'static [&'static str])>,
132 query_suffix: Option<&'static str>,
142 clear_command: &'static str,
143 clear: Clear,
144 note: Option<&'static str>,
145}
146
147#[cfg(windows)]
150const CARGO_CACHE_CLEAR: &str =
151 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
152#[cfg(not(windows))]
153const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
154
155#[cfg(windows)]
156const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
157#[cfg(not(windows))]
158const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
159
160#[cfg(windows)]
165const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
166#[cfg(not(windows))]
167const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
168
169const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
189 download cache: `mvn install` and `install:install-file` write artifacts there \
190 that exist nowhere else, and nothing in the tree tells them apart from the \
191 downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
192 the command; running it is yours to decide.";
193
194#[cfg(windows)]
195const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
196#[cfg(not(windows))]
197const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
198
199#[cfg(windows)]
200const GRADLE_DISTS_CLEAR: &str =
201 r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
202#[cfg(not(windows))]
203const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
204
205#[cfg(windows)]
206const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
207#[cfg(not(windows))]
208const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
209
210#[cfg(windows)]
213const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
214#[cfg(not(windows))]
215const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
216
217#[cfg(windows)]
222const BUNDLER_CACHE_CLEAR: &str = "Remove-Item -Recurse -Force \"$(gem env gemdir)\\cache\"";
223#[cfg(not(windows))]
224const BUNDLER_CACHE_CLEAR: &str = "rm -rf \"$(gem env gemdir)/cache\"";
225
226#[cfg(windows)]
231const DART_HOSTED_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Pub\Cache\hosted";
232#[cfg(not(windows))]
233const DART_HOSTED_CLEAR: &str = "rm -rf ~/.pub-cache/hosted";
234
235#[cfg(windows)]
239const SWIFTPM_CACHE_CLEAR: &str =
240 r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\org.swift.swiftpm";
241#[cfg(target_os = "macos")]
242const SWIFTPM_CACHE_CLEAR: &str = "rm -rf ~/Library/Caches/org.swift.swiftpm";
243#[cfg(not(any(windows, target_os = "macos")))]
244const SWIFTPM_CACHE_CLEAR: &str = "rm -rf ~/.cache/org.swift.swiftpm";
245
246#[cfg(windows)]
247const TERRAFORM_PLUGIN_CLEAR: &str =
248 r"Remove-Item -Recurse -Force $env:APPDATA\terraform.d\plugin-cache";
249#[cfg(not(windows))]
250const TERRAFORM_PLUGIN_CLEAR: &str = "rm -rf ~/.terraform.d/plugin-cache";
251
252#[cfg(windows)]
256const POETRY_ARTIFACTS_CLEAR: &str =
257 "Remove-Item -Recurse -Force \"$(poetry config cache-dir)\\artifacts\"";
258#[cfg(not(windows))]
259const POETRY_ARTIFACTS_CLEAR: &str = "rm -rf \"$(poetry config cache-dir)/artifacts\"";
260
261#[cfg(windows)]
264const SCCACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Mozilla\sccache";
265#[cfg(target_os = "macos")]
266const SCCACHE_CLEAR: &str = "rm -rf ~/Library/Caches/Mozilla.sccache";
267#[cfg(not(any(windows, target_os = "macos")))]
268const SCCACHE_CLEAR: &str = "rm -rf ~/.cache/sccache";
269
270#[cfg(windows)]
273const PLAYWRIGHT_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\ms-playwright";
274#[cfg(target_os = "macos")]
275const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/Library/Caches/ms-playwright";
276#[cfg(not(any(windows, target_os = "macos")))]
277const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/.cache/ms-playwright";
278
279#[cfg(windows)]
282const PUPPETEER_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\puppeteer";
283#[cfg(not(windows))]
284const PUPPETEER_CLEAR: &str = "rm -rf ~/.cache/puppeteer";
285
286#[cfg(windows)]
291const HUGGINGFACE_CLEAR: &str =
292 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\huggingface\hub";
293#[cfg(not(windows))]
294const HUGGINGFACE_CLEAR: &str = "rm -rf ~/.cache/huggingface/hub";
295
296#[cfg(windows)]
297const CYPRESS_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Cypress\Cache";
298#[cfg(target_os = "macos")]
299const CYPRESS_CLEAR: &str = "rm -rf ~/Library/Caches/Cypress";
300#[cfg(not(any(windows, target_os = "macos")))]
301const CYPRESS_CLEAR: &str = "rm -rf ~/.cache/Cypress";
302
303#[cfg(windows)]
304const ELECTRON_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron\Cache";
305#[cfg(target_os = "macos")]
306const ELECTRON_CLEAR: &str = "rm -rf ~/Library/Caches/electron";
307#[cfg(not(any(windows, target_os = "macos")))]
308const ELECTRON_CLEAR: &str = "rm -rf ~/.cache/electron";
309
310#[cfg(windows)]
311const ELECTRON_BUILDER_CLEAR: &str =
312 r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron-builder\Cache";
313#[cfg(target_os = "macos")]
314const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/Library/Caches/electron-builder";
315#[cfg(not(any(windows, target_os = "macos")))]
316const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/.cache/electron-builder";
317
318const PROBES: &[Probe] = &[
319 Probe {
320 manager: "npm",
321 kind: "cache",
322 query: Some(("npm", &["config", "get", "cache"])),
323 query_suffix: None,
324 clear_command: "npm cache clean --force",
325 clear: Clear::Command("npm", &["cache", "clean", "--force"]),
326 note: None,
327 },
328 Probe {
329 manager: "pnpm",
330 kind: "store",
331 query: Some(("pnpm", &["store", "path"])),
332 query_suffix: None,
333 clear_command: "pnpm store prune",
334 clear: Clear::Command("pnpm", &["store", "prune"]),
335 note: Some(
336 "hardlinked into every node_modules it filled; emptying it is what makes the \
337 next pnpm install a download",
338 ),
339 },
340 Probe {
341 manager: "yarn",
342 kind: "cache",
343 query: Some(("yarn", &["cache", "dir"])),
344 query_suffix: None,
345 clear_command: "yarn cache clean",
346 clear: Clear::Command("yarn", &["cache", "clean"]),
347 note: None,
348 },
349 Probe {
350 manager: "bun",
351 kind: "cache",
352 query: Some(("bun", &["pm", "cache"])),
353 query_suffix: None,
354 clear_command: "bun pm cache rm",
355 clear: Clear::Command("bun", &["pm", "cache", "rm"]),
356 note: None,
357 },
358 Probe {
359 manager: "uv",
360 kind: "cache",
361 query: Some(("uv", &["cache", "dir"])),
362 query_suffix: None,
363 clear_command: "uv cache prune",
366 clear: Clear::Command("uv", &["cache", "prune"]),
367 note: None,
368 },
369 Probe {
370 manager: "pip",
371 kind: "cache",
372 query: Some(("pip", &["cache", "dir"])),
373 query_suffix: None,
374 clear_command: "pip cache purge",
375 clear: Clear::Command("pip", &["cache", "purge"]),
376 note: None,
377 },
378 Probe {
384 manager: "conda",
385 kind: "package cache",
386 query: None,
387 query_suffix: None,
388 clear_command: "conda clean --packages --tarballs --yes",
389 clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
390 note: Some(
391 "unpacked packages and downloaded archives; conda keeps what its \
392 environments use, except any it linked by symlink rather than hardlink",
393 ),
394 },
395 Probe {
396 manager: "cargo",
397 kind: "registry cache",
398 query: None,
399 query_suffix: None,
400 clear_command: CARGO_CACHE_CLEAR,
401 clear: Clear::Directory,
402 note: Some("the downloaded .crate archives; clearing them means downloading again"),
403 },
404 Probe {
405 manager: "cargo",
406 kind: "registry sources",
407 query: None,
408 query_suffix: None,
409 clear_command: CARGO_SRC_CLEAR,
410 clear: Clear::Directory,
411 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
412 },
413 Probe {
414 manager: "go",
415 kind: "module cache",
416 query: Some(("go", &["env", "GOMODCACHE"])),
417 query_suffix: None,
418 clear_command: "go clean -modcache",
419 clear: Clear::Command("go", &["clean", "-modcache"]),
420 note: None,
421 },
422 Probe {
423 manager: "go",
424 kind: "build cache",
425 query: Some(("go", &["env", "GOCACHE"])),
426 query_suffix: None,
427 clear_command: "go clean -cache",
428 clear: Clear::Command("go", &["clean", "-cache"]),
429 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
430 },
431 Probe {
436 manager: "maven",
437 kind: "local repository",
438 query: None,
439 query_suffix: None,
440 clear_command: MAVEN_REPO_CLEAR,
441 clear: Clear::Manual { why: MAVEN_MANUAL },
442 note: Some(
443 "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
444 ),
445 },
446 Probe {
447 manager: "gradle",
448 kind: "caches",
449 query: None,
450 query_suffix: None,
451 clear_command: GRADLE_CACHE_CLEAR,
452 clear: Clear::Directory,
453 note: Some(
454 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
455 ),
456 },
457 Probe {
458 manager: "gradle",
459 kind: "wrapper distributions",
460 query: None,
461 query_suffix: None,
462 clear_command: GRADLE_DISTS_CLEAR,
463 clear: Clear::Directory,
464 note: Some(
465 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
466 ),
467 },
468 Probe {
472 manager: "nuget",
473 kind: "global packages",
474 query: None,
475 query_suffix: None,
476 clear_command: "dotnet nuget locals global-packages --clear",
477 clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
478 note: Some(
479 "every .NET project on the machine restores from here; re-downloaded on the next restore",
480 ),
481 },
482 Probe {
483 manager: "vcpkg",
484 kind: "binary cache",
485 query: None,
486 query_suffix: None,
487 clear_command: VCPKG_ARCHIVES_CLEAR,
488 clear: Clear::Directory,
489 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
490 },
491 Probe {
492 manager: "conan",
493 kind: "package cache",
494 query: None,
495 query_suffix: None,
496 clear_command: "conan remove \"*\" --confirm",
497 clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
498 note: Some(
499 "recipes and binaries shared by every Conan project; re-fetched on the next install",
500 ),
501 },
502 Probe {
506 manager: "ccache",
507 kind: "compiler cache",
508 query: Some(("ccache", &["--get-config", "cache_dir"])),
509 query_suffix: None,
510 clear_command: "ccache --clear",
511 clear: Clear::Command("ccache", &["--clear"]),
512 note: Some(
513 "compiled objects keyed by source and flags; the next build repopulates it one cache miss at a time",
514 ),
515 },
516 Probe {
520 manager: "sccache",
521 kind: "compiler cache",
522 query: None,
523 query_suffix: None,
524 clear_command: SCCACHE_CLEAR,
525 clear: Clear::Directory,
526 note: Some(
527 "compiled objects keyed by source and flags; the next build repopulates it. If a clear is refused, `sccache --stop-server` first — the daemon holds the directory open",
528 ),
529 },
530 Probe {
535 manager: "composer",
536 kind: "cache",
537 query: Some(("composer", &["config", "--global", "cache-dir"])),
538 query_suffix: None,
539 clear_command: "composer clear-cache",
540 clear: Clear::Command("composer", &["clear-cache"]),
541 note: Some(
542 "downloaded package archives and repository metadata; re-fetched by the next composer install",
543 ),
544 },
545 Probe {
550 manager: "cocoapods",
551 kind: "cache",
552 query: None,
553 query_suffix: None,
554 clear_command: "pod cache clean --all",
555 clear: Clear::Command("pod", &["cache", "clean", "--all"]),
556 note: Some("downloaded pod sources, re-fetched by the next pod install"),
557 },
558 Probe {
559 manager: "hex",
560 kind: "package cache",
561 query: None,
562 query_suffix: None,
563 clear_command: HEX_CACHE_CLEAR,
564 clear: Clear::Directory,
565 note: Some(
566 "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
567 ),
568 },
569 Probe {
575 manager: "bundler",
576 kind: "gem cache",
577 query: Some(("gem", &["env", "gemdir"])),
578 query_suffix: Some("cache"),
579 clear_command: BUNDLER_CACHE_CLEAR,
580 clear: Clear::Directory,
581 note: Some(
582 "only the `cache/` subdirectory of the gem home, which holds the downloaded \
583 `.gem` archives. The installed gems beside it, and the binstubs in `bin/`, \
584 are untouched — including anything put there by `gem install ./local.gem`, \
585 which no remote could hand back.",
586 ),
587 },
588 Probe {
589 manager: "dart",
590 kind: "pub cache",
591 query: None,
592 query_suffix: None,
593 clear_command: DART_HOSTED_CLEAR,
594 clear: Clear::Directory,
595 note: Some(
596 "only `hosted/`, the packages pub downloaded from a registry. The `git/` \
597 checkouts and the executables `dart pub global activate` installed live \
598 beside it and are left alone; `dart pub cache clean` would take all three.",
599 ),
600 },
601 Probe {
602 manager: "swift",
603 kind: "swiftpm cache",
604 query: None,
605 query_suffix: None,
606 clear_command: SWIFTPM_CACHE_CLEAR,
607 clear: Clear::Directory,
608 note: Some(
609 "the shared repository and manifest cache SwiftPM fills for every package on \
610 the machine. `swift build` re-clones what it needs, so the first build after \
611 this one is a network build.",
612 ),
613 },
614 Probe {
615 manager: "terraform",
616 kind: "plugin cache",
617 query: None,
618 query_suffix: None,
619 clear_command: TERRAFORM_PLUGIN_CLEAR,
620 clear: Clear::Directory,
621 note: Some(
622 "this exists only if you switched it on with `TF_PLUGIN_CACHE_DIR` or \
623 `plugin_cache_dir` in `.terraformrc`. Without it Terraform downloads a \
624 private copy of every provider into each project's `.terraform/` — which is \
625 what `devp run` prunes — and there is no shared store to report.",
626 ),
627 },
628 Probe {
629 manager: "poetry",
630 kind: "artifact cache",
631 query: Some(("poetry", &["config", "cache-dir"])),
632 query_suffix: Some("artifacts"),
633 clear_command: POETRY_ARTIFACTS_CLEAR,
634 clear: Clear::Directory,
635 note: Some(
636 "only `artifacts/`, the wheels and sdists Poetry downloaded. `virtualenvs/` \
637 sits in the same cache directory and holds the environments themselves — \
638 deleting that would not be clearing a cache.",
639 ),
640 },
641 Probe {
642 manager: "pdm",
643 kind: "cache",
644 query: Some(("pdm", &["config", "cache_dir"])),
645 query_suffix: None,
646 clear_command: "pdm cache clear",
647 clear: Clear::Command("pdm", &["cache", "clear"]),
648 note: None,
649 },
650 Probe {
656 manager: "playwright",
657 kind: "browser bundles",
658 query: None,
659 query_suffix: None,
660 clear_command: PLAYWRIGHT_CLEAR,
661 clear: Clear::Directory,
662 note: Some(
663 "a full Chromium, Firefox and WebKit build per Playwright version, and the \
664 old ones are never removed when the dependency moves. `npx playwright \
665 install` re-downloads only the versions the installed Playwright asks for, \
666 which is usually why this comes back smaller than it was.",
667 ),
668 },
669 Probe {
670 manager: "puppeteer",
671 kind: "browser bundles",
672 query: None,
673 query_suffix: None,
674 clear_command: PUPPETEER_CLEAR,
675 clear: Clear::Directory,
676 note: Some(
677 "one Chrome build per Puppeteer version. `npx puppeteer browsers install` \
678 re-downloads the current one.",
679 ),
680 },
681 Probe {
682 manager: "huggingface",
683 kind: "hub cache",
684 query: None,
685 query_suffix: None,
686 clear_command: HUGGINGFACE_CLEAR,
687 clear: Clear::Directory,
688 note: Some(
689 "model weights, not packages. This is the one row here where re-downloading \
690 is measured in tens of gigabytes and can fail outright: a gated or \
691 now-private repository will not hand the file back, and a checkpoint from a \
692 revision that has since been deleted is gone. Worth looking at what is in it \
693 before emptying it.",
694 ),
695 },
696 Probe {
697 manager: "cypress",
698 kind: "binary cache",
699 query: None,
700 query_suffix: None,
701 clear_command: CYPRESS_CLEAR,
702 clear: Clear::Directory,
703 note: Some(
704 "one unpacked Electron application per Cypress version, around half a \
705 gigabyte each, and upgrading leaves the previous one in place. `npx cypress \
706 install` re-downloads the version the project pins.",
707 ),
708 },
709 Probe {
710 manager: "electron",
711 kind: "download cache",
712 query: None,
713 query_suffix: None,
714 clear_command: ELECTRON_CLEAR,
715 clear: Clear::Directory,
716 note: Some(
717 "the prebuilt Electron archives every `electron` dependency downloads, kept \
718 per version and per architecture. Re-downloaded on the next install.",
719 ),
720 },
721 Probe {
722 manager: "electron",
723 kind: "builder cache",
724 query: None,
725 query_suffix: None,
726 clear_command: ELECTRON_BUILDER_CLEAR,
727 clear: Clear::Directory,
728 note: Some(
729 "electron-builder's own store of signing tools, `winCodeSign`, `nsis` and the \
730 runtimes it packages with. Re-downloaded on the next build, which makes that \
731 build slow rather than broken.",
732 ),
733 },
734 Probe {
735 manager: "deno",
736 kind: "cache",
737 query: None,
738 query_suffix: None,
739 clear_command: "deno clean",
740 clear: Clear::Command("deno", &["clean"]),
741 note: None,
742 },
743];
744
745pub fn run(json_output: bool, volume: Option<&str>) -> Result<()> {
747 let volume = volume.map(resolve_volume).transpose()?;
749
750 let reg = registered();
751 let mut reports = collect(!json_output, reg.as_ref());
752 apply_caps(&mut reports, &caps());
753 let deps = reg.as_ref().map(|r| dependents(r, !json_output));
754 apply_dependents(&mut reports, deps.as_ref());
755
756 if let Some(root) = &volume {
761 reports = on_volume(reports, root);
762 }
763
764 let engines = if volume.is_some() {
773 Vec::new()
774 } else {
775 container_summary(!json_output)
776 };
777
778 if json_output {
779 return json::emit(&json::caches_document(
780 &reports,
781 deps.as_ref().map(|d| d.repositories),
782 &engines,
783 volume.as_deref(),
784 ));
785 }
786
787 print_report(&reports, deps.as_ref(), volume.as_deref());
788 crate::commands::containers::print_summary(&engines);
789 Ok(())
790}
791
792fn resolve_volume(arg: &str) -> Result<PathBuf> {
797 let path = absolutize(normalize_volume_arg(arg));
798 volume_root(&path).ok_or_else(|| {
799 anyhow::Error::new(crate::UsageError(format!(
800 "`{arg}` is not a drive or a path on one. Name a drive (`--volume V:`), a \
801 mount point (`--volume /mnt/data`), or any path that sits on the one you \
802 mean."
803 )))
804 })
805}
806
807fn absolutize(path: PathBuf) -> PathBuf {
817 if path.is_absolute() || !path.exists() {
818 return path;
819 }
820 std::path::absolute(&path).unwrap_or(path)
821}
822
823#[cfg(windows)]
830fn normalize_volume_arg(arg: &str) -> PathBuf {
831 let trimmed = arg.trim();
832 let letter = match trimmed.as_bytes() {
833 [c] if c.is_ascii_alphabetic() => Some(*c),
834 [c, b':'] if c.is_ascii_alphabetic() => Some(*c),
835 _ => None,
836 };
837 match letter {
838 Some(c) => PathBuf::from(format!("{}:\\", c.to_ascii_uppercase() as char)),
839 None => PathBuf::from(trimmed),
840 }
841}
842
843#[cfg(unix)]
845fn normalize_volume_arg(arg: &str) -> PathBuf {
846 PathBuf::from(arg.trim())
847}
848
849#[cfg(windows)]
856fn same_volume(a: &Path, b: &Path) -> bool {
857 match (volume_key(a), volume_key(b)) {
858 (Some(a), Some(b)) => a == b,
859 _ => false,
860 }
861}
862
863#[cfg(windows)]
865fn volume_key(path: &Path) -> Option<String> {
866 use std::path::{Component, Prefix};
867
868 let Some(Component::Prefix(prefix)) = path.components().next() else {
869 return None;
870 };
871 Some(match prefix.kind() {
872 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
873 (letter.to_ascii_uppercase() as char).to_string()
874 }
875 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => format!(
876 "{}\\{}",
877 server.to_string_lossy().to_uppercase(),
878 share.to_string_lossy().to_uppercase()
879 ),
880 other => format!("{other:?}").to_uppercase(),
881 })
882}
883
884#[cfg(unix)]
886fn same_volume(a: &Path, b: &Path) -> bool {
887 a == b
888}
889
890fn on_volume(reports: Vec<CacheReport>, root: &Path) -> Vec<CacheReport> {
895 reports
896 .into_iter()
897 .filter(|r| volume_root(&r.path).is_some_and(|v| same_volume(&v, root)))
898 .collect()
899}
900
901fn volume_totals(reports: &[CacheReport]) -> Vec<(String, u64)> {
907 let mut totals: Vec<(String, u64)> = Vec::new();
908 for r in reports {
909 let Some(root) = volume_root(&r.path) else {
910 continue;
911 };
912 let label = output::clean_path(&root);
913 match totals.iter_mut().find(|(l, _)| *l == label) {
914 Some((_, bytes)) => *bytes += r.bytes,
915 None => totals.push((label, r.bytes)),
916 }
917 }
918 totals.sort_by_key(|(_, bytes)| std::cmp::Reverse(*bytes));
919 totals
920}
921
922fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
924 let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
925 let engines = crate::commands::containers::collect(None);
926 if let Some(pb) = pb {
927 pb.finish_and_clear();
928 }
929 engines
930}
931
932fn caps() -> BTreeMap<String, u64> {
937 crate::config::Registry::load()
938 .map(|r| r.settings.cache_max_gb)
939 .unwrap_or_default()
940}
941
942fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
947 let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
948 for r in reports.iter() {
949 *totals.entry(r.manager).or_default() += r.bytes;
950 }
951 for r in reports.iter_mut() {
952 let Some(&gb) = caps
955 .get(r.manager)
956 .or_else(|| caps.get(constants::CACHE_CAP_DEFAULT_KEY))
957 else {
958 continue;
959 };
960 r.cap_gb = Some(gb);
961 r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
962 > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
963 }
964}
965
966struct Registered {
972 paths: Vec<PathBuf>,
974 scan_depth: usize,
976}
977
978fn registered() -> Option<Registered> {
986 let registry = crate::config::Registry::load().ok()?;
987 let paths: Vec<PathBuf> = registry
988 .repositories
989 .keys()
990 .filter(|p| p.exists())
991 .cloned()
992 .collect();
993 if paths.is_empty() {
994 return None;
995 }
996 Some(Registered {
997 paths,
998 scan_depth: registry.settings.scan_depth,
999 })
1000}
1001
1002struct Dependents {
1010 repositories: usize,
1013 by_manager: BTreeMap<&'static str, usize>,
1019}
1020
1021fn dependents(reg: &Registered, spinner: bool) -> Dependents {
1027 let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
1028
1029 let mut by_manager: BTreeMap<&'static str, usize> = PROBES
1037 .iter()
1038 .map(|p| p.manager)
1039 .filter(|m| adapters::is_adapter_name(m))
1040 .map(|m| (m, 0))
1041 .collect();
1042
1043 for path in ®.paths {
1044 let depth = crate::workspace::clamp_depth(
1049 crate::config::PerRepoConfig::load_with_diagnostics(path)
1050 .ok()
1051 .flatten()
1052 .and_then(|c| c.scan_depth)
1053 .unwrap_or(reg.scan_depth),
1054 );
1055 let mut here: HashSet<&'static str> = HashSet::new();
1056 for project in crate::workspace::discover_all_to_depth(path, depth) {
1057 for adapter in &project.adapters {
1058 here.insert(adapter.name());
1059 }
1060 }
1061 for (manager, count) in by_manager.iter_mut() {
1062 if here.contains(manager) {
1063 *count += 1;
1064 }
1065 }
1066 }
1067
1068 if let Some(pb) = pb {
1069 pb.finish_and_clear();
1070 }
1071
1072 Dependents {
1073 repositories: reg.paths.len(),
1074 by_manager,
1075 }
1076}
1077
1078fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
1080 let Some(deps) = deps else {
1081 return;
1082 };
1083 for r in reports.iter_mut() {
1084 r.dependents = deps.by_manager.get(r.manager).copied();
1085 }
1086}
1087
1088fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, (u64, usize)> {
1095 let mut totals: BTreeMap<&'static str, (u64, usize)> = BTreeMap::new();
1096 for r in reports {
1097 let entry = totals.entry(r.manager).or_default();
1098 entry.0 += r.bytes;
1099 entry.1 += 1;
1100 }
1101 totals
1102}
1103
1104fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
1106 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
1107 let from = query_dir();
1108
1109 let mut seen: HashSet<PathBuf> = HashSet::new();
1110 let mut reports = Vec::new();
1111
1112 for probe in PROBES {
1113 let Some(path) = locate(probe, &from) else {
1114 continue;
1115 };
1116 let path = path.canonicalize().unwrap_or(path);
1123 if !seen.insert(path.clone()) {
1124 continue;
1125 }
1126 reports.push(CacheReport {
1127 manager: probe.manager,
1128 kind: probe.kind,
1129 bytes: adapters::dir_size(&path),
1130 path,
1131 clear_command: probe.clear_command.to_string(),
1132 clear: probe.clear,
1133 note: probe.note,
1134 cap_gb: None,
1135 over_cap: false,
1136 dependents: None,
1137 extra_args: Vec::new(),
1138 });
1139 }
1140
1141 for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
1144 if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
1145 continue;
1146 }
1147 reports.push(volume_store_report(store));
1148 }
1149
1150 if let Some(pb) = pb {
1151 pb.finish_and_clear();
1152 }
1153
1154 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1155 reports
1156}
1157
1158const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
1160 this is the store for the projects on this volume";
1161
1162fn volume_store_report(store: PathBuf) -> CacheReport {
1168 let named = output::clean_path(&store);
1169 CacheReport {
1170 manager: "pnpm",
1171 kind: "store",
1172 bytes: adapters::dir_size(&store),
1173 clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
1174 extra_args: vec!["--store-dir".to_string(), named],
1175 path: store,
1176 clear: Clear::Command("pnpm", &["store", "prune"]),
1177 note: Some(PNPM_VOLUME_NOTE),
1178 cap_gb: None,
1179 over_cap: false,
1180 dependents: None,
1181 }
1182}
1183
1184fn shell_arg(named: &str) -> String {
1189 if named.contains(' ') {
1190 format!("\"{named}\"")
1191 } else {
1192 named.to_string()
1193 }
1194}
1195
1196fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
1211 let mut roots = volume_roots(repos);
1215 if let Ok(here) = std::env::current_dir()
1216 && let Some(root) = volume_root(&here)
1217 && !roots.contains(&root)
1218 {
1219 roots.push(root);
1220 }
1221 roots
1222 .into_iter()
1223 .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
1224 .filter(|store| store.is_dir())
1225 .collect()
1226}
1227
1228fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
1230 let mut roots: Vec<PathBuf> = Vec::new();
1231 for repo in repos {
1232 if let Some(root) = volume_root(repo)
1233 && !roots.contains(&root)
1234 {
1235 roots.push(root);
1236 }
1237 }
1238 roots
1239}
1240
1241#[cfg(unix)]
1248pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
1249 use std::os::unix::fs::MetadataExt;
1250
1251 let dev = std::fs::metadata(path).ok()?.dev();
1252 let mut root = path.to_path_buf();
1253 for ancestor in path.ancestors().skip(1) {
1254 match std::fs::metadata(ancestor) {
1255 Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
1256 _ => break,
1257 }
1258 }
1259 Some(root)
1260}
1261
1262#[cfg(windows)]
1269pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
1270 use std::path::Component;
1271
1272 let mut components = path.components();
1273 let Some(Component::Prefix(prefix)) = components.next() else {
1274 return None;
1275 };
1276 if components.next() != Some(Component::RootDir) {
1277 return None;
1278 }
1279 let mut root = PathBuf::from(prefix.as_os_str());
1280 root.push(Component::RootDir.as_os_str());
1281 Some(root)
1282}
1283
1284fn query_dir() -> PathBuf {
1291 dirs::home_dir()
1292 .or_else(|| std::env::current_dir().ok())
1293 .unwrap_or_else(|| PathBuf::from("."))
1294}
1295
1296fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
1298 if let Some((program, args)) = probe.query
1299 && adapters::binary_available(program)
1300 {
1301 let answered = adapters::capture_command_with_timeout(
1302 program,
1303 args,
1304 from,
1305 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
1306 )
1307 .ok()
1308 .and_then(|raw| path_from_output(&raw))
1309 .map(|p| match probe.query_suffix {
1310 Some(suffix) => suffix.split('/').fold(p, |acc, seg| acc.join(seg)),
1311 None => p,
1312 })
1313 .filter(|p| p.is_dir());
1314 if answered.is_some() {
1315 return answered;
1316 }
1317 }
1318
1319 fallbacks(probe.manager, probe.kind)
1324 .into_iter()
1325 .find(|p| p.is_dir())
1326}
1327
1328fn path_from_output(raw: &str) -> Option<PathBuf> {
1333 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
1334 let line = line.trim_matches('"');
1335 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
1338 return None;
1339 }
1340 Some(PathBuf::from(line))
1341}
1342
1343fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
1345 let home = dirs::home_dir();
1346 let local = dirs::data_local_dir();
1347 let cache = dirs::cache_dir();
1348 let under = |base: &Option<PathBuf>, rel: &str| {
1352 base.as_ref()
1353 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
1354 };
1355
1356 let candidates = match (manager, kind) {
1357 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
1360 ("pnpm", _) => vec![
1361 under(&local, "pnpm/store"),
1362 under(&home, ".local/share/pnpm/store"),
1363 under(&home, "Library/pnpm/store"),
1364 under(&home, ".pnpm-store"),
1365 ],
1366 ("yarn", _) => vec![
1367 under(&home, ".yarn/berry/cache"),
1368 under(&local, "Yarn/Cache"),
1369 under(&cache, "yarn"),
1370 ],
1371 ("bun", _) => vec![under(&home, ".bun/install/cache")],
1372 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
1373 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
1374 ("conda", _) => vec![
1382 std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
1383 std::env::var_os("CONDA_EXE")
1384 .map(PathBuf::from)
1385 .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
1386 .map(|root| root.join("pkgs")),
1387 under(&home, "miniconda3/pkgs"),
1388 under(&home, "anaconda3/pkgs"),
1389 under(&home, "miniforge3/pkgs"),
1390 under(&home, "mambaforge/pkgs"),
1391 under(&home, ".conda/pkgs"),
1392 ],
1393 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
1394 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
1395 ("go", "module cache") => vec![
1396 std::env::var_os("GOMODCACHE").map(PathBuf::from),
1397 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
1398 under(&home, "go/pkg/mod"),
1399 ],
1400 ("go", _) => vec![
1401 std::env::var_os("GOCACHE").map(PathBuf::from),
1402 under(&cache, "go-build"),
1403 under(&local, "go-build"),
1404 ],
1405 ("maven", _) => vec![under(&home, ".m2/repository")],
1406 ("gradle", "caches") => vec![
1408 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
1409 under(&home, ".gradle/caches"),
1410 ],
1411 ("gradle", _) => vec![
1412 std::env::var_os("GRADLE_USER_HOME")
1413 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
1414 under(&home, ".gradle/wrapper/dists"),
1415 ],
1416 ("nuget", _) => vec![
1417 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
1418 under(&home, ".nuget/packages"),
1419 ],
1420 ("vcpkg", _) => vec![
1421 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
1422 under(&local, "vcpkg/archives"),
1423 under(&cache, "vcpkg/archives"),
1424 ],
1425 ("conan", _) => vec![
1428 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
1429 under(&home, ".conan2/p"),
1430 ],
1431 ("ccache", _) => vec![
1434 std::env::var_os("CCACHE_DIR").map(PathBuf::from),
1435 under(&home, ".ccache"),
1436 under(&cache, "ccache"),
1437 under(&local, "ccache"),
1438 ],
1439 ("sccache", _) => vec![
1443 std::env::var_os("SCCACHE_DIR").map(PathBuf::from),
1444 under(&local, "Mozilla/sccache/cache"),
1445 under(&local, "Mozilla/sccache"),
1446 under(&cache, "Mozilla.sccache"),
1447 under(&cache, "sccache"),
1448 ],
1449 ("composer", _) => vec![
1452 std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
1453 std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
1454 under(&local, "Composer"),
1455 under(&cache, "composer"),
1456 under(&home, ".composer/cache"),
1457 ],
1458 ("cocoapods", _) => vec![
1462 std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
1463 under(&home, "Library/Caches/CocoaPods"),
1464 ],
1465 ("hex", _) => vec![
1468 std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
1469 under(&home, ".hex/packages"),
1470 under(&cache, "hex/packages"),
1471 ],
1472 ("bundler", _) => gem_cache_dirs(),
1477 ("dart", _) => vec![
1478 std::env::var_os("PUB_CACHE").map(|p| PathBuf::from(p).join("hosted")),
1479 under(&local, "Pub/Cache/hosted"),
1480 under(&home, ".pub-cache/hosted"),
1481 ],
1482 ("swift", _) => vec![
1485 under(&cache, "org.swift.swiftpm"),
1486 under(&home, ".swiftpm/cache"),
1487 ],
1488 ("terraform", _) => vec![
1493 std::env::var_os("TF_PLUGIN_CACHE_DIR").map(PathBuf::from),
1494 under(&home, ".terraform.d/plugin-cache"),
1495 under(&dirs::data_dir(), "terraform.d/plugin-cache"),
1496 ],
1497 ("poetry", _) => vec![
1498 std::env::var_os("POETRY_CACHE_DIR").map(|p| PathBuf::from(p).join("artifacts")),
1499 under(&cache, "pypoetry/Cache/artifacts"),
1500 under(&cache, "pypoetry/artifacts"),
1501 ],
1502 ("pdm", _) => vec![
1503 std::env::var_os("PDM_CACHE_DIR").map(PathBuf::from),
1504 under(&cache, "pdm/Cache"),
1505 under(&cache, "pdm"),
1506 ],
1507 ("playwright", _) => vec![
1508 std::env::var_os("PLAYWRIGHT_BROWSERS_PATH").map(PathBuf::from),
1509 under(&cache, "ms-playwright"),
1510 ],
1511 ("puppeteer", _) => vec![
1515 std::env::var_os("PUPPETEER_CACHE_DIR").map(PathBuf::from),
1516 under(&home, ".cache/puppeteer"),
1517 ],
1518 ("huggingface", _) => vec![
1521 std::env::var_os("HF_HUB_CACHE").map(PathBuf::from),
1522 std::env::var_os("HF_HOME").map(|p| PathBuf::from(p).join("hub")),
1523 under(&home, ".cache/huggingface/hub"),
1524 ],
1525 ("cypress", _) => vec![
1529 std::env::var_os("CYPRESS_CACHE_FOLDER").map(PathBuf::from),
1530 under(&cache, "Cypress/Cache"),
1531 under(&cache, "Cypress"),
1532 ],
1533 ("electron", "download cache") => vec![
1534 std::env::var_os("electron_config_cache").map(PathBuf::from),
1535 under(&cache, "electron/Cache"),
1536 under(&cache, "electron"),
1537 ],
1538 ("electron", _) => vec![
1539 under(&cache, "electron-builder/Cache"),
1540 under(&cache, "electron-builder"),
1541 ],
1542 ("deno", _) => vec![
1543 std::env::var_os("DENO_DIR").map(PathBuf::from),
1544 under(&cache, "deno"),
1545 ],
1546 _ => vec![],
1547 };
1548
1549 candidates.into_iter().flatten().collect()
1550}
1551
1552fn gem_cache_dirs() -> Vec<Option<PathBuf>> {
1560 let mut out = vec![std::env::var_os("GEM_HOME").map(|p| PathBuf::from(p).join("cache"))];
1561 let home = dirs::home_dir();
1562 for parent in [".gem/ruby", ".local/share/gem/ruby"] {
1563 let Some(base) = home
1564 .as_ref()
1565 .map(|h| parent.split('/').fold(h.clone(), |p, seg| p.join(seg)))
1566 else {
1567 continue;
1568 };
1569 if let Ok(entries) = std::fs::read_dir(&base) {
1570 out.extend(entries.flatten().map(|e| Some(e.path().join("cache"))));
1571 }
1572 }
1573 out.push(home.map(|h| h.join(".gem").join("cache")));
1574 out
1575}
1576
1577fn cargo_home() -> PathBuf {
1579 std::env::var_os("CARGO_HOME")
1580 .map(PathBuf::from)
1581 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
1582 .unwrap_or_else(|| PathBuf::from(".cargo"))
1583}
1584
1585fn print_report(reports: &[CacheReport], deps: Option<&Dependents>, volume: Option<&Path>) {
1586 output::print_header(i18n::t("caches.header"));
1587
1588 if reports.is_empty() {
1589 println!();
1590 match volume {
1591 Some(root) => output::print_info(&format!(
1592 "No package manager cache on this machine sits on {}. They are somewhere \
1593 else — run `devp caches` without `--volume` to see where.",
1594 output::clean_path(root)
1595 )),
1596 None => output::print_info(i18n::t("caches.nothing")),
1597 }
1598 return;
1599 }
1600
1601 println!();
1602 let totals = manager_totals(reports);
1603 let mut counted: HashSet<&'static str> = HashSet::new();
1606 for r in reports {
1607 let label = format!("{} {}", r.manager, r.kind);
1608 println!(
1609 " {:<30} {:>10} {}",
1610 label,
1611 output::format_bytes(r.bytes),
1612 output::clean_path(&r.path)
1613 );
1614 let clearable = !matches!(r.clear, Clear::Manual { .. });
1626 if clearable {
1627 println!(
1628 " {:<30} {:>10} clear: devp caches clear {}",
1629 "", "", r.manager
1630 );
1631 }
1632 let verb = if clearable { "runs: " } else { "clear:" };
1633 println!(" {:<30} {:>10} {verb} {}", "", "", r.clear_command);
1634 if let Some(note) = r.note {
1635 println!(" {:<30} {:>10} {}", "", "", note);
1636 }
1637 if r.over_cap
1638 && let Some(gb) = r.cap_gb
1639 {
1640 println!(
1641 " {:<30} {:>10} over the {gb} GiB cap you set for {}",
1642 "", "", r.manager
1643 );
1644 }
1645 if let Some(n) = r.dependents
1646 && counted.insert(r.manager)
1647 {
1648 println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
1649 }
1650 println!();
1651 }
1652
1653 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1654 println!(
1655 " {:<30} {:>10} across {} {}",
1656 "Total",
1657 output::format_bytes(total),
1658 reports.len(),
1659 output::plural(reports.len(), "cache", "caches")
1660 );
1661
1662 let by_volume = volume_totals(reports);
1668 if by_volume.len() > 1 {
1669 let named = by_volume
1670 .iter()
1671 .map(|(label, bytes)| format!("{label} {}", output::format_bytes(*bytes)))
1672 .collect::<Vec<_>>()
1673 .join(" · ");
1674 println!(" {:<30} {:>10} {named}", "By drive", "");
1675 }
1676
1677 let ranked = costliest_per_repository(reports);
1681 if ranked.len() > 1 {
1682 let named = ranked
1683 .iter()
1684 .take(3)
1685 .map(|(m, b)| format!("{m} {}", output::format_bytes_weighted(*b)))
1686 .collect::<Vec<_>>()
1687 .join(" · ");
1688 println!(" {:<30} {:>10} {named}", "Costliest per repository", "");
1689 }
1690
1691 if reports.iter().any(|r| r.over_cap) {
1692 println!();
1693 output::print_info(
1694 "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1695 --over-cap all` empties exactly those and leaves the rest alone.",
1696 );
1697 }
1698
1699 if reports.iter().any(|r| r.dependents == Some(0)) {
1700 println!();
1701 output::print_info(
1702 "The caches above that no registered repository uses were filled for projects that \
1703 are not here any more. `devp caches clear --unused all` empties exactly those. It \
1704 counts only repositories dev-prune knows about, so `devp link` anything you keep \
1705 outside the registry before trusting the number.",
1706 );
1707 }
1708
1709 if let Some(root) = volume {
1710 println!();
1711 output::print_info(&format!(
1712 "Only the caches on {} are above, and the container engines are not among \
1713 them: an engine reports its own disk from inside a VM image with no path on \
1714 this filesystem, so there is no drive to file it under — `devp caches \
1715 docker` has that number. The clear commands above are not drive-specific \
1716 either. They empty that manager's cache wherever it is, which for every \
1717 manager but pnpm is one place.",
1718 output::clean_path(root)
1719 ));
1720 }
1721
1722 println!();
1723 output::print_info(
1724 "Nothing above was deleted. A cache is shared by every project on the machine, so \
1725 no single repository's lockfile can prove it is recoverable — and it is what \
1726 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1727 will ever touch one. When you want the space more than the speed, run a clear \
1728 command yourself, or `devp caches clear <manager>` — only the second is counted \
1729 in `devp stats`, because dev-prune never sees the first.",
1730 );
1731}
1732
1733fn used_by(
1747 r: &CacheReport,
1748 dependents: usize,
1749 deps: Option<&Dependents>,
1750 totals: &BTreeMap<&'static str, (u64, usize)>,
1751) -> String {
1752 if dependents == 0 {
1753 return format!("no registered repository uses {}", r.manager)
1754 .bold()
1755 .to_string();
1756 }
1757 let registered = deps.map_or(dependents, |d| d.repositories);
1758 let (total, rows) = totals.get(r.manager).copied().unwrap_or((r.bytes, 1));
1759 let share = output::format_bytes(total / dependents as u64);
1764 let each = if rows > 1 {
1765 format!("{share} each across its {rows} caches")
1766 } else {
1767 format!("{share} each")
1768 };
1769 format!(
1770 "{} is used by {dependents} of {registered} registered {} · {each}",
1771 r.manager,
1772 output::plural(registered, "repository", "repositories"),
1773 )
1774 .bold()
1775 .to_string()
1776}
1777
1778fn costliest_per_repository(reports: &[CacheReport]) -> Vec<(&'static str, u64)> {
1785 let totals = manager_totals(reports);
1786 let mut per: BTreeMap<&'static str, u64> = BTreeMap::new();
1789 for r in reports {
1790 if let Some(n) = r.dependents.filter(|n| *n > 0) {
1791 let total = totals.get(r.manager).map_or(r.bytes, |t| t.0);
1792 per.insert(r.manager, total / n as u64);
1793 }
1794 }
1795 let mut ranked: Vec<(&'static str, u64)> = per.into_iter().collect();
1796 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
1798 ranked
1799}
1800
1801pub struct ClearOutcome {
1803 pub manager: &'static str,
1805 pub kind: &'static str,
1807 pub path: PathBuf,
1809 pub before: u64,
1811 pub after: u64,
1815 pub problem: Option<String>,
1817}
1818
1819impl ClearOutcome {
1820 pub fn freed(&self) -> u64 {
1822 self.before.saturating_sub(self.after)
1823 }
1824}
1825
1826pub fn run_clear(
1835 target: &str,
1836 except: Option<&str>,
1837 over_cap: bool,
1838 unused: bool,
1839 yes: bool,
1840 dry_run: bool,
1841 json_output: bool,
1842) -> Result<()> {
1843 let targets: Vec<&str> = target
1844 .split(',')
1845 .map(str::trim)
1846 .filter(|t| !t.is_empty())
1847 .collect();
1848 if targets.is_empty() {
1849 return Err(anyhow::Error::new(crate::UsageError(format!(
1850 "`{target}` names no cache. Name a manager, a comma-separated list of them, \
1851 or `all`."
1852 ))));
1853 }
1854 let all = targets.len() == 1 && targets[0].eq_ignore_ascii_case("all");
1855 if !all && targets.iter().any(|t| t.eq_ignore_ascii_case("all")) {
1858 return Err(anyhow::Error::new(crate::UsageError(
1859 "`all` already includes every manager, so a list containing it decides \
1860 nothing. Name just the managers to clear, or run `devp caches clear all \
1861 --except <managers>` to leave some out."
1862 .to_string(),
1863 )));
1864 }
1865 let excepts: Vec<&str> = match except {
1866 Some(_) if !all => {
1870 return Err(anyhow::Error::new(crate::UsageError(
1871 "`--except` leaves managers out of `all`. You already named exactly \
1872 what to clear — drop the unwanted names from the list instead."
1873 .to_string(),
1874 )));
1875 }
1876 Some(e) => e
1877 .split(',')
1878 .map(str::trim)
1879 .filter(|t| !t.is_empty())
1880 .collect(),
1881 None => Vec::new(),
1882 };
1883 let target = targets[0];
1884 if !all && targets.len() == 1 && crate::commands::containers::is_engine(target) {
1892 if over_cap || unused {
1893 return Err(anyhow::Error::new(crate::UsageError(format!(
1894 "`--over-cap` and `--unused` pick package manager caches by size cap and by \
1895 which repositories still need them. Neither applies to {target}: an image \
1896 belongs to no repository and `cache_max_gb` does not cover one. Run `devp \
1897 caches clear {target}` on its own."
1898 ))));
1899 }
1900 return crate::commands::containers::run_clear(target, yes, dry_run, json_output);
1901 }
1902 if targets.len() > 1
1906 && let Some(engine) = targets
1907 .iter()
1908 .find(|t| crate::commands::containers::is_engine(t))
1909 {
1910 return Err(anyhow::Error::new(crate::UsageError(format!(
1911 "`{engine}` is a container engine, and an engine is cleared by naming it \
1912 alone: `devp caches clear {engine}`. Run that separately from the \
1913 package-manager list."
1914 ))));
1915 }
1916 for t in &targets {
1917 if !all && !PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(t)) {
1918 return Err(anyhow::Error::new(crate::UsageError(format!(
1919 "`{t}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1920 known_managers().join(", ")
1921 ))));
1922 }
1923 }
1924 for e in &excepts {
1925 if crate::commands::containers::is_engine(e) {
1926 return Err(anyhow::Error::new(crate::UsageError(format!(
1927 "`all` never reaches a container engine, so there is no `{e}` to leave \
1928 out. Drop it from `--except`."
1929 ))));
1930 }
1931 if !PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(e)) {
1932 return Err(anyhow::Error::new(crate::UsageError(format!(
1933 "`{e}` is not a manager dev-prune knows a cache for, so `--except` has \
1934 nothing to leave out. Known managers: {}.",
1935 known_managers().join(", ")
1936 ))));
1937 }
1938 }
1939 for t in &targets {
1946 if !all
1947 && let Some(probe) = manual_only(t)
1948 && let Clear::Manual { why } = probe.clear
1949 {
1950 return Err(anyhow::Error::new(crate::UsageError(format!(
1951 "{why} The command is: {}",
1952 probe.clear_command
1953 ))));
1954 }
1955 }
1956
1957 if json_output && !yes && !dry_run {
1960 return Err(anyhow::Error::new(crate::UsageError(
1961 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1962 to see what would go."
1963 .to_string(),
1964 )));
1965 }
1966
1967 let reg = registered();
1971 let mut measured = collect(!json_output, reg.as_ref());
1972 apply_caps(&mut measured, &caps());
1973
1974 let deps = if unused {
1978 let Some(reg) = reg.as_ref() else {
1979 return Err(anyhow::Error::new(crate::UsageError(
1980 "`--unused` empties the caches no registered repository needs, and there are no \
1981 registered repositories on this disk to check against — every cache would look \
1982 unused. Register what you keep with `devp link` first."
1983 .to_string(),
1984 )));
1985 };
1986 Some(dependents(reg, !json_output))
1987 } else {
1988 None
1989 };
1990 apply_dependents(&mut measured, deps.as_ref());
1991
1992 let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1996 .into_iter()
1997 .filter(|r| {
1998 if all {
1999 !excepts.iter().any(|e| r.manager.eq_ignore_ascii_case(e))
2000 } else {
2001 targets.iter().any(|t| r.manager.eq_ignore_ascii_case(t))
2002 }
2003 })
2004 .filter(|r| !over_cap || r.over_cap)
2005 .filter(|r| !unused || r.dependents == Some(0))
2006 .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
2007
2008 if reports.is_empty() {
2009 if json_output {
2010 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
2011 }
2012 if unused {
2013 output::print_info(
2014 "Every cache on this machine is used by at least one registered repository, or \
2015 is one dev-prune cannot attribute to any — nothing to clear.",
2016 );
2017 return Ok(());
2018 }
2019 if over_cap {
2020 output::print_info(if caps().is_empty() {
2024 "No cache size caps are set, so nothing is over one. Set one for every manager \
2025 with `devp config set cache_max_gb default=10`, or pick them out one at a time \
2026 in `devp config wizard`."
2027 } else {
2028 "Every capped cache is under its cap — nothing to clear."
2029 });
2030 return Ok(());
2031 }
2032 output::print_info(&format!(
2033 "No {} cache on this machine — nothing to clear.",
2034 if all {
2035 "package manager".to_string()
2036 } else {
2037 targets.join(", ")
2038 }
2039 ));
2040 return Ok(());
2041 }
2042
2043 if dry_run {
2044 if json_output {
2045 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
2046 }
2047 print_kept(&kept);
2048 print_clear_plan(&reports, true);
2049 return Ok(());
2050 }
2051
2052 if !json_output {
2053 print_kept(&kept);
2054 print_clear_plan(&reports, false);
2055 if !confirm_clear(yes) {
2056 output::print_info("Nothing was cleared.");
2057 return Ok(());
2058 }
2059 }
2060
2061 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
2062 record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
2066
2067 if json_output {
2068 json::emit(&json::caches_clear_document(&outcomes, &kept))?;
2069 } else {
2070 print_clear_result(&outcomes);
2071 }
2072
2073 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
2076 if failed > 0 {
2077 anyhow::bail!(
2078 "{failed} {} could not be cleared.",
2079 output::plural(failed, "cache", "caches")
2080 );
2081 }
2082 Ok(())
2083}
2084
2085fn manual_only(target: &str) -> Option<&'static Probe> {
2090 let matching: Vec<&Probe> = PROBES
2091 .iter()
2092 .filter(|p| p.manager.eq_ignore_ascii_case(target))
2093 .collect();
2094 if matching.is_empty()
2095 || matching
2096 .iter()
2097 .any(|p| !matches!(p.clear, Clear::Manual { .. }))
2098 {
2099 return None;
2100 }
2101 matching.first().copied()
2102}
2103
2104pub fn is_cache_manager(name: &str) -> bool {
2106 PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
2107}
2108
2109pub fn known_managers() -> Vec<&'static str> {
2111 let mut names: Vec<&'static str> = Vec::new();
2112 for probe in PROBES {
2113 if !names.contains(&probe.manager) {
2114 names.push(probe.manager);
2115 }
2116 }
2117 names
2118}
2119
2120fn clear_one(report: &CacheReport) -> ClearOutcome {
2122 let problem = match report.clear {
2123 Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
2124 Clear::Directory => remove_cache_dir(&report.path),
2125 Clear::Manual { why } => Some(why.to_string()),
2129 };
2130 ClearOutcome {
2131 manager: report.manager,
2132 kind: report.kind,
2133 path: report.path.clone(),
2134 before: report.bytes,
2135 after: adapters::dir_size(&report.path),
2138 problem,
2139 }
2140}
2141
2142fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
2144 if !adapters::binary_available(program) {
2145 return Some(format!(
2146 "`{program}` is not on PATH — only it knows what in this cache is still \
2147 referenced, so dev-prune will not delete the directory in its place."
2148 ));
2149 }
2150 let mut all: Vec<&str> = args.to_vec();
2153 all.extend(extra.iter().map(String::as_str));
2154 adapters::run_command_with_timeout(
2155 program,
2156 &all,
2157 &query_dir(),
2158 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
2159 )
2160 .err()
2161 .map(|e| format!("{e:#}"))
2162}
2163
2164fn remove_cache_dir(path: &Path) -> Option<String> {
2166 std::fs::remove_dir_all(path)
2170 .or_else(|_| {
2171 std::thread::sleep(std::time::Duration::from_millis(250));
2172 std::fs::remove_dir_all(path)
2173 })
2174 .err()
2175 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
2177 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
2178}
2179
2180fn print_kept(kept: &[CacheReport]) {
2182 for r in kept {
2183 let Clear::Manual { why } = r.clear else {
2184 continue;
2185 };
2186 println!();
2187 output::print_info(&format!(
2188 "Keeping {} {} ({} at {}). {why}",
2189 r.manager,
2190 r.kind,
2191 output::format_bytes(r.bytes),
2192 output::clean_path(&r.path)
2193 ));
2194 }
2195}
2196
2197fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
2199 output::print_header(if dry_run {
2200 i18n::t("caches.header.would_clear")
2201 } else {
2202 i18n::t("caches.header.about_to_clear")
2203 });
2204
2205 println!();
2206 for r in reports {
2207 println!(
2208 " {:<30} {:>10} {}",
2209 format!("{} {}", r.manager, r.kind),
2210 output::format_bytes(r.bytes),
2211 output::clean_path(&r.path)
2212 );
2213 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
2214 }
2215
2216 println!();
2217 let total: u64 = reports.iter().map(|r| r.bytes).sum();
2218 println!(
2219 " {:<30} {:>10} across {} {}",
2220 "Total",
2221 output::format_bytes(total),
2222 reports.len(),
2223 output::plural(reports.len(), "cache", "caches")
2224 );
2225
2226 println!();
2227 output::print_info(
2228 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
2229 The cost is time: the next install, and the next `devp restore`, in every \
2230 project on this machine.",
2231 );
2232}
2233
2234fn record_cache_clear(bytes: u64) {
2241 if bytes == 0 {
2242 return;
2243 }
2244 if let Ok(mut registry) = crate::config::Registry::load() {
2245 registry.record_cache_clear(bytes);
2246 let _ = registry.save();
2247 }
2248}
2249
2250const REINSTALL_HINTS: &[(&str, &str)] = &[
2256 ("playwright", "npx playwright install"),
2257 ("puppeteer", "npx puppeteer browsers install"),
2258 ("cypress", "npx cypress install"),
2259];
2260
2261fn print_clear_result(outcomes: &[ClearOutcome]) {
2262 println!();
2263 for o in outcomes {
2264 let label = format!("{} {}", o.manager, o.kind);
2265 println!(
2266 " {:<30} {:>10} {}",
2267 label,
2268 output::format_bytes(o.freed()),
2269 if o.problem.is_some() {
2270 "not cleared"
2271 } else {
2272 "cleared"
2273 }
2274 );
2275 if let Some(why) = &o.problem {
2276 println!(" {:<30} {:>10} {why}", "", "");
2277 }
2278 }
2279
2280 println!();
2281 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
2282 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
2283
2284 for (manager, command) in REINSTALL_HINTS {
2285 if outcomes
2286 .iter()
2287 .any(|o| o.manager == *manager && o.problem.is_none())
2288 {
2289 output::print_info(&format!("{manager} comes back with `{command}`."));
2290 }
2291 }
2292}
2293
2294pub fn confirm_clear(yes: bool) -> bool {
2297 use std::io::{IsTerminal, Write};
2298 if yes {
2299 return true;
2300 }
2301 if !std::io::stdin().is_terminal() {
2302 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
2303 return false;
2304 }
2305 eprint!("Clear them? [y/N]: ");
2309 if std::io::stderr().flush().is_err() {
2310 return false;
2311 }
2312 let mut input = String::new();
2313 if std::io::stdin().read_line(&mut input).is_err() {
2314 return false;
2315 }
2316 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
2317}
2318
2319#[cfg(test)]
2320mod tests {
2321 use super::*;
2322
2323 #[test]
2324 fn every_probe_can_be_found_without_its_manager_installed() {
2325 for probe in PROBES {
2328 assert!(
2329 !fallbacks(probe.manager, probe.kind).is_empty(),
2330 "{} {} has no conventional location",
2331 probe.manager,
2332 probe.kind
2333 );
2334 }
2335 }
2336
2337 #[test]
2338 fn every_reinstall_hint_names_a_probed_manager() {
2339 for (manager, command) in REINSTALL_HINTS {
2342 assert!(
2343 PROBES.iter().any(|p| p.manager == *manager),
2344 "reinstall hint for {manager} ({command}) matches no probe"
2345 );
2346 }
2347 }
2348
2349 #[test]
2350 fn every_probe_names_the_command_that_clears_it() {
2351 for probe in PROBES {
2352 assert!(
2353 !probe.clear_command.trim().is_empty(),
2354 "{} {} reports a size with no way to act on it",
2355 probe.manager,
2356 probe.kind
2357 );
2358 }
2359 }
2360
2361 #[test]
2362 fn the_probed_managers_with_no_adapter_of_the_same_name_are_pinned() {
2363 let orphans: Vec<&str> = PROBES
2368 .iter()
2369 .map(|p| p.manager)
2370 .filter(|m| !adapters::is_adapter_name(m))
2371 .collect::<std::collections::BTreeSet<_>>()
2372 .into_iter()
2373 .collect();
2374 assert_eq!(
2375 orphans,
2376 [
2377 "ccache",
2378 "conan",
2379 "conda",
2380 "cypress",
2381 "electron",
2382 "hex",
2383 "huggingface",
2384 "nuget",
2385 "pip",
2386 "playwright",
2387 "puppeteer",
2388 "sccache",
2389 ]
2390 );
2391 }
2392
2393 #[test]
2394 fn no_two_probes_describe_the_same_cache() {
2395 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
2396 let count = keys.len();
2397 keys.sort_unstable();
2398 keys.dedup();
2399 assert_eq!(keys.len(), count, "two probes share a manager and kind");
2400 }
2401
2402 #[test]
2403 fn a_managers_answer_is_read_off_the_last_line() {
2404 let raw = if cfg!(windows) {
2406 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
2407 } else {
2408 "npm warn config global deprecated\n/home/dev/.npm\n"
2409 };
2410 assert!(path_from_output(raw).is_some());
2411 }
2412
2413 #[test]
2414 fn quoted_paths_lose_their_quotes() {
2415 let raw = if cfg!(windows) {
2416 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
2417 } else {
2418 "\"/opt/go path/pkg/mod\"\n"
2419 };
2420 let path = path_from_output(raw).expect("a quoted path is still a path");
2421 assert!(!path.to_string_lossy().contains('"'));
2422 }
2423
2424 #[test]
2425 fn a_non_answer_is_not_mistaken_for_a_path() {
2426 for raw in [
2429 "",
2430 "\n \n",
2431 "undefined\n",
2432 "not a command\n",
2433 "./relative\n",
2434 ] {
2435 assert!(
2436 path_from_output(raw).is_none(),
2437 "{raw:?} was accepted as a cache path"
2438 );
2439 }
2440 }
2441
2442 #[test]
2443 fn the_cargo_rows_point_inside_the_registry() {
2444 for kind in ["registry cache", "registry sources"] {
2447 let path = fallbacks("cargo", kind).remove(0);
2448 assert!(
2449 path.starts_with(cargo_home().join("registry")),
2450 "{kind} resolved outside the cargo registry: {}",
2451 path.display()
2452 );
2453 }
2454 }
2455
2456 #[test]
2457 fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
2458 let home = dirs::home_dir().expect("a home directory");
2464 let found = fallbacks("conda", "package cache");
2465
2466 for install in [
2467 "miniconda3",
2468 "anaconda3",
2469 "miniforge3",
2470 "mambaforge",
2471 ".conda",
2472 ] {
2473 let want = home.join(install).join("pkgs");
2474 assert!(
2475 found.contains(&want),
2476 "{} is not among conda's conventional locations",
2477 want.display()
2478 );
2479 assert!(
2480 !found.contains(&home.join(install)),
2481 "{} is the installation, not its package cache",
2482 home.join(install).display()
2483 );
2484 }
2485 }
2486
2487 #[test]
2488 fn the_report_is_ordered_by_what_is_worth_clearing() {
2489 let mut reports = [
2490 CacheReport {
2491 manager: "npm",
2492 kind: "cache",
2493 path: PathBuf::from("/a"),
2494 bytes: 10,
2495 clear_command: "x".to_string(),
2496 clear: Clear::Command("npm", &["cache"]),
2497 note: None,
2498 cap_gb: None,
2499 over_cap: false,
2500 dependents: None,
2501 extra_args: Vec::new(),
2502 },
2503 CacheReport {
2504 manager: "go",
2505 kind: "module cache",
2506 path: PathBuf::from("/b"),
2507 bytes: 4_000,
2508 clear_command: "y".to_string(),
2509 clear: Clear::Directory,
2510 note: None,
2511 cap_gb: None,
2512 over_cap: false,
2513 dependents: None,
2514 extra_args: Vec::new(),
2515 },
2516 ];
2517 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
2518 assert_eq!(reports[0].manager, "go");
2519 }
2520
2521 fn sized(manager: &'static str, bytes: u64, dependents: Option<usize>) -> CacheReport {
2523 CacheReport {
2524 manager,
2525 kind: "cache",
2526 path: PathBuf::from("/x"),
2527 bytes,
2528 clear_command: "x".to_string(),
2529 clear: Clear::Directory,
2530 note: None,
2531 cap_gb: None,
2532 over_cap: false,
2533 dependents,
2534 extra_args: Vec::new(),
2535 }
2536 }
2537
2538 #[test]
2539 fn the_costliest_cache_per_repository_is_not_the_biggest_one() {
2540 let reports = [
2543 sized("npm", 10_240, Some(18)),
2544 sized("pnpm", 2_048, Some(1)),
2545 sized("cargo", 300, Some(2)),
2546 sized("cargo", 100, Some(2)),
2547 sized("bun", 512, Some(0)),
2548 sized("nuget", 900, None),
2549 ];
2550 assert_eq!(
2551 costliest_per_repository(&reports),
2552 vec![("pnpm", 2_048), ("npm", 568), ("cargo", 200)],
2553 );
2554 }
2555
2556 #[test]
2557 fn every_probe_clears_with_the_command_it_prints() {
2558 for probe in PROBES {
2561 let printed = probe.clear_command;
2562 match probe.clear {
2563 Clear::Command(program, args) => {
2564 assert!(
2565 printed.starts_with(program),
2566 "{} {} prints `{printed}` but runs `{program}`",
2567 probe.manager,
2568 probe.kind
2569 );
2570 for arg in args {
2571 assert!(
2574 printed.contains(arg.trim_matches('"')),
2575 "{} {} prints `{printed}` but passes `{arg}`",
2576 probe.manager,
2577 probe.kind
2578 );
2579 }
2580 }
2581 Clear::Directory | Clear::Manual { .. } => assert!(
2585 printed.contains("rm -rf") || printed.contains("Remove-Item"),
2586 "{} {} deletes a directory but prints `{printed}`",
2587 probe.manager,
2588 probe.kind
2589 ),
2590 }
2591 }
2592 }
2593
2594 #[test]
2595 fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
2596 let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
2600 assert!(!maven.is_empty(), "maven is no longer reported at all");
2601 for probe in maven {
2602 assert!(
2603 matches!(probe.clear, Clear::Manual { .. }),
2604 "maven {} would be emptied by dev-prune",
2605 probe.kind
2606 );
2607 }
2608 }
2609
2610 #[test]
2611 fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
2612 let dir = tempfile::tempdir().unwrap();
2616 let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
2617 std::fs::write(&artifact, b"nowhere else").unwrap();
2618
2619 let outcome = clear_one(&CacheReport {
2620 manager: "maven",
2621 kind: "local repository",
2622 path: dir.path().to_path_buf(),
2623 bytes: 12,
2624 clear_command: MAVEN_REPO_CLEAR.to_string(),
2625 clear: Clear::Manual { why: MAVEN_MANUAL },
2626 note: None,
2627 cap_gb: None,
2628 over_cap: false,
2629 dependents: None,
2630 extra_args: Vec::new(),
2631 });
2632
2633 assert!(artifact.exists(), "the store was emptied after all");
2634 assert!(
2635 outcome.problem.is_some(),
2636 "it reported success without doing anything"
2637 );
2638 }
2639
2640 #[test]
2641 fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
2642 let err = run_clear("maven", None, false, false, true, true, false).unwrap_err();
2645 assert!(
2646 err.downcast_ref::<crate::UsageError>().is_some(),
2647 "expected a usage error, got: {err:#}"
2648 );
2649 let text = format!("{err}");
2650 assert!(
2651 text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
2652 "the refusal names neither the reason nor the command: {text}"
2653 );
2654 }
2655
2656 #[test]
2657 fn every_manager_in_the_report_can_be_named_to_clear() {
2658 let names = known_managers();
2659 for probe in PROBES {
2660 assert!(
2661 names.contains(&probe.manager),
2662 "{} is reported but `devp caches clear {}` would not find it",
2663 probe.manager,
2664 probe.manager
2665 );
2666 }
2667 let mut sorted = names.clone();
2670 sorted.sort_unstable();
2671 sorted.dedup();
2672 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
2673 }
2674
2675 #[test]
2676 fn an_unknown_manager_is_a_usage_error() {
2677 let err = run_clear("nonesuch", None, false, false, true, true, false).unwrap_err();
2679 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2680 }
2681
2682 #[test]
2683 fn a_manual_only_manager_in_a_list_gets_the_same_refusal_as_alone() {
2684 let err = run_clear("npm,maven", None, false, false, true, true, false).unwrap_err();
2687 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2688 let text = format!("{err}");
2689 assert!(
2690 text.contains(MAVEN_REPO_CLEAR),
2691 "the refusal does not name the command: {text}"
2692 );
2693 }
2694
2695 #[test]
2696 fn all_inside_a_list_is_a_usage_error() {
2697 let err = run_clear("npm,all", None, false, false, true, true, false).unwrap_err();
2698 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2699 let text = format!("{err}");
2700 assert!(
2701 text.contains("--except"),
2702 "the error should point at the fix: {text}"
2703 );
2704 }
2705
2706 #[test]
2707 fn except_with_a_named_target_is_a_usage_error() {
2708 let err = run_clear("npm", Some("uv"), false, false, true, true, false).unwrap_err();
2709 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2710 }
2711
2712 #[test]
2713 fn an_unknown_name_in_except_is_a_usage_error() {
2714 let err = run_clear("all", Some("nonesuch"), false, false, true, true, false).unwrap_err();
2715 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2716 }
2717
2718 #[test]
2719 fn an_engine_in_a_list_is_refused_with_the_standalone_command() {
2720 let err = run_clear("npm,docker", None, false, false, true, true, false).unwrap_err();
2721 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2722 let text = format!("{err}");
2723 assert!(
2724 text.contains("devp caches clear docker"),
2725 "the error should name the command that does work: {text}"
2726 );
2727 }
2728
2729 #[test]
2730 fn an_engine_in_except_is_a_usage_error() {
2731 let err = run_clear("all", Some("docker"), false, false, true, true, false).unwrap_err();
2734 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2735 }
2736
2737 #[test]
2738 fn a_target_of_only_commas_names_nothing() {
2739 let err = run_clear(",", None, false, false, true, true, false).unwrap_err();
2740 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2741 }
2742
2743 #[test]
2744 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
2745 let err = run_clear("npm", None, false, false, false, false, true).unwrap_err();
2746 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2747 }
2748
2749 #[test]
2750 fn removing_a_directory_reports_nothing_when_it_worked() {
2751 let dir = tempfile::tempdir().unwrap();
2752 let cache = dir.path().join("cache");
2753 std::fs::create_dir(&cache).unwrap();
2754 std::fs::write(cache.join("blob"), b"x").unwrap();
2755
2756 assert!(remove_cache_dir(&cache).is_none());
2757 assert!(!cache.exists());
2758 assert!(remove_cache_dir(&cache).is_none());
2761 }
2762
2763 #[test]
2764 fn clearing_a_directory_reports_what_actually_went() {
2765 let dir = tempfile::tempdir().unwrap();
2766 let cache = dir.path().join("store");
2767 std::fs::create_dir(&cache).unwrap();
2768 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
2769 let before = adapters::dir_size(&cache);
2770
2771 let outcome = clear_one(&CacheReport {
2772 manager: "cargo",
2773 kind: "registry cache",
2774 path: cache.clone(),
2775 bytes: before,
2776 clear_command: "rm -rf".to_string(),
2777 clear: Clear::Directory,
2778 note: None,
2779 cap_gb: None,
2780 over_cap: false,
2781 dependents: None,
2782 extra_args: Vec::new(),
2783 });
2784
2785 assert!(outcome.problem.is_none());
2786 assert_eq!(outcome.after, 0);
2787 assert_eq!(outcome.freed(), before);
2790 assert!(!cache.exists());
2791 }
2792
2793 #[test]
2794 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
2795 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
2798 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
2799 }
2800
2801 fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
2803 CacheReport {
2804 manager,
2805 kind,
2806 path: PathBuf::from("/cache").join(manager).join(kind),
2807 bytes: gib * crate::constants::BYTES_PER_GIB,
2808 clear_command: "x".to_string(),
2809 clear: Clear::Directory,
2810 note: None,
2811 cap_gb: None,
2812 over_cap: false,
2813 dependents: None,
2814 extra_args: Vec::new(),
2815 }
2816 }
2817
2818 fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
2820 Dependents {
2821 repositories,
2822 by_manager: counts.iter().copied().collect(),
2823 }
2824 }
2825
2826 #[test]
2827 fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
2828 let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
2833 apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
2834
2835 assert_eq!(reports[0].dependents, Some(2));
2836 assert_eq!(
2837 reports[1].dependents, None,
2838 "pip has no adapter of its name, so there is nothing to count"
2839 );
2840 }
2841
2842 #[test]
2843 fn no_registry_leaves_every_count_unanswered() {
2844 let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
2847 apply_dependents(&mut reports, None);
2848 assert!(reports.iter().all(|r| r.dependents.is_none()));
2849 }
2850
2851 #[test]
2852 fn a_manager_nothing_uses_is_a_counted_zero() {
2853 let mut reports = vec![row("go", "module cache", 3)];
2856 apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
2857 assert_eq!(reports[0].dependents, Some(0));
2858 assert!(
2859 used_by(&reports[0], 0, None, &manager_totals(&reports))
2860 .contains("no registered repository uses go")
2861 );
2862 }
2863
2864 #[test]
2865 fn the_per_repository_share_is_the_managers_whole_footprint() {
2866 let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2870 let line = used_by(
2871 &reports[0],
2872 2,
2873 Some(&counted(2, &[("cargo", 2)])),
2874 &manager_totals(&reports),
2875 );
2876 assert!(
2877 line.contains("cargo is used by 2 of 2 registered repositories")
2878 && line.contains("6 GiB each across its 2 caches"),
2879 "{line}"
2880 );
2881 }
2882
2883 #[test]
2884 fn one_cache_does_not_say_how_many_it_summed() {
2885 let reports = vec![row("bun", "cache", 4)];
2888 let line = used_by(
2889 &reports[0],
2890 2,
2891 Some(&counted(2, &[("bun", 2)])),
2892 &manager_totals(&reports),
2893 );
2894 assert!(line.ends_with("2 GiB each"), "{line}");
2895 }
2896
2897 #[test]
2898 fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
2899 let dir = tempfile::tempdir().unwrap();
2903 let nested = dir.path().join("a").join("b");
2904 std::fs::create_dir_all(&nested).unwrap();
2905
2906 let root = volume_root(&nested).expect("a real directory sits on some filesystem");
2907 assert!(
2908 nested.starts_with(&root),
2909 "{} is not under {}",
2910 nested.display(),
2911 root.display()
2912 );
2913 assert!(root.is_dir(), "{} is not a directory", root.display());
2914 }
2915
2916 #[cfg(windows)]
2917 #[test]
2918 fn a_windows_volume_root_is_the_drive_and_nothing_more() {
2919 let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
2923 assert_eq!(root, PathBuf::from("V:\\"));
2924 assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
2925 }
2926
2927 #[cfg(windows)]
2928 #[test]
2929 fn the_drive_someone_types_and_the_drive_on_disk_are_the_same_drive() {
2930 assert!(same_volume(Path::new(r"\\?\C:\"), Path::new(r"C:\")));
2935 assert!(same_volume(Path::new(r"c:\"), Path::new(r"C:\")));
2936 assert!(!same_volume(Path::new(r"C:\"), Path::new(r"V:\")));
2937 }
2938
2939 #[cfg(windows)]
2940 #[test]
2941 fn a_bare_drive_letter_is_what_people_type_and_has_to_resolve() {
2942 for typed in ["V", "v", "V:", "v:", r"V:\", r"V:\Code\ProjectCode"] {
2946 assert_eq!(
2947 resolve_volume(typed).unwrap(),
2948 PathBuf::from(r"V:\"),
2949 "{typed}"
2950 );
2951 }
2952 assert!(resolve_volume("not-a-path-at-all").is_err());
2953 }
2954
2955 #[test]
2956 fn the_drive_you_are_standing_on_is_a_dot() {
2957 let here = std::env::current_dir().unwrap();
2962 assert_eq!(resolve_volume(".").unwrap(), volume_root(&here).unwrap());
2963 assert!(resolve_volume("definitely-not-a-drive").is_err());
2966 }
2967
2968 #[test]
2969 fn narrowing_to_one_drive_keeps_only_what_is_on_it() {
2970 let dir = tempfile::tempdir().unwrap();
2973 let here = dir.path().join("cache");
2974 std::fs::create_dir_all(&here).unwrap();
2975 let root = volume_root(&here).unwrap();
2976
2977 let mut on_this_one = sized("pnpm", 2_048, Some(1));
2978 on_this_one.path = here;
2979 let mut nowhere = sized("npm", 10_240, Some(18));
2980 nowhere.path = PathBuf::from("relative/and/unresolvable");
2981
2982 let kept = on_volume(vec![on_this_one, nowhere], &root);
2983 assert_eq!(
2984 kept.len(),
2985 1,
2986 "a row whose drive is unknown is not on this one"
2987 );
2988 assert_eq!(kept[0].manager, "pnpm");
2989 }
2990
2991 #[test]
2992 fn the_per_drive_line_adds_up_and_leads_with_the_biggest() {
2993 let dir = tempfile::tempdir().unwrap();
2997 let here = dir.path().join("cache");
2998 std::fs::create_dir_all(&here).unwrap();
2999
3000 let mut small = sized("pnpm", 100, None);
3001 small.path = here.clone();
3002 let mut large = sized("npm", 900, None);
3003 large.path = here;
3004 let mut unknown = sized("uv", 500, None);
3005 unknown.path = PathBuf::from("relative/and/unresolvable");
3006
3007 let totals = volume_totals(&[small, large, unknown]);
3008 assert_eq!(totals.len(), 1);
3009 assert_eq!(totals[0].1, 1_000);
3010 }
3011
3012 #[test]
3013 fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
3014 let dir = tempfile::tempdir().unwrap();
3017 let a = dir.path().join("one");
3018 let b = dir.path().join("two");
3019 std::fs::create_dir_all(&a).unwrap();
3020 std::fs::create_dir_all(&b).unwrap();
3021
3022 assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
3023 assert!(volume_roots(&[]).is_empty());
3024 }
3025
3026 #[test]
3027 fn a_volume_stores_printed_command_is_the_one_that_runs() {
3028 let dir = tempfile::tempdir().unwrap();
3033 let store = dir.path().join(".pnpm-store");
3034 std::fs::create_dir_all(&store).unwrap();
3035
3036 let report = volume_store_report(store.clone());
3037 let named = output::clean_path(&store);
3038 assert_eq!(
3039 report.extra_args,
3040 vec!["--store-dir".to_string(), named.clone()]
3041 );
3042 assert!(
3043 report.clear_command.contains(&named),
3044 "the printed command does not name the store: {}",
3045 report.clear_command
3046 );
3047 assert!(matches!(
3048 report.clear,
3049 Clear::Command("pnpm", ["store", "prune"])
3050 ));
3051 }
3052
3053 #[test]
3054 fn only_a_path_with_a_space_in_it_is_quoted() {
3055 assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
3059 assert_eq!(
3060 shell_arg("/mnt/my data/.pnpm-store"),
3061 "\"/mnt/my data/.pnpm-store\""
3062 );
3063 }
3064
3065 #[test]
3066 fn a_cap_is_measured_against_the_managers_whole_footprint() {
3067 let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
3071 apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
3072 assert!(
3073 reports.iter().all(|r| r.over_cap),
3074 "both rows belong to the manager that went over"
3075 );
3076 assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
3077 }
3078
3079 #[test]
3080 fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
3081 let mut reports = vec![row("npm", "cache", 3)];
3082 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
3083 assert_eq!(reports[0].cap_gb, Some(10));
3086 assert!(!reports[0].over_cap);
3087 }
3088
3089 #[test]
3090 fn the_default_cap_covers_a_manager_nobody_named() {
3091 let caps = BTreeMap::from([(constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10)]);
3092 let mut reports = vec![row("uv", "cache", 40), row("npm", "cache", 3)];
3093 apply_caps(&mut reports, &caps);
3094 assert!(reports[0].over_cap);
3095 assert_eq!(reports[1].cap_gb, Some(10));
3096 assert!(!reports[1].over_cap);
3097 }
3098
3099 #[test]
3100 fn a_manager_named_outright_is_not_also_held_to_the_default() {
3101 let caps = BTreeMap::from([
3105 (constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10),
3106 ("npm".to_string(), 4),
3107 ]);
3108 let mut reports = vec![row("npm", "cache", 5)];
3109 apply_caps(&mut reports, &caps);
3110 assert_eq!(reports[0].cap_gb, Some(4));
3111 assert!(reports[0].over_cap);
3112 }
3113
3114 #[test]
3115 fn a_manager_with_no_cap_is_never_called_too_big() {
3116 let mut reports = vec![row("uv", "cache", 40)];
3119 apply_caps(&mut reports, &BTreeMap::new());
3120 assert_eq!(reports[0].cap_gb, None);
3121 assert!(!reports[0].over_cap);
3122 }
3123
3124 #[test]
3125 fn one_managers_cap_says_nothing_about_another() {
3126 let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
3127 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
3128 assert!(reports[0].over_cap);
3129 assert!(
3130 !reports[1].over_cap,
3131 "go has no cap and did not acquire npm's"
3132 );
3133 }
3134
3135 #[test]
3136 fn exactly_at_the_cap_is_not_over_it() {
3137 let mut reports = vec![row("pnpm", "store", 10)];
3140 apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
3141 assert!(!reports[0].over_cap);
3142 }
3143
3144 #[test]
3145 fn every_cache_manager_answers_to_its_own_name() {
3146 for probe in PROBES {
3149 assert!(
3150 is_cache_manager(probe.manager),
3151 "{} is reported but cannot be capped",
3152 probe.manager
3153 );
3154 }
3155 assert!(!is_cache_manager("dev-prune-no-such-manager"));
3156 }
3157}