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