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 PLAYWRIGHT_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\ms-playwright";
265#[cfg(target_os = "macos")]
266const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/Library/Caches/ms-playwright";
267#[cfg(not(any(windows, target_os = "macos")))]
268const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/.cache/ms-playwright";
269
270#[cfg(windows)]
273const PUPPETEER_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\puppeteer";
274#[cfg(not(windows))]
275const PUPPETEER_CLEAR: &str = "rm -rf ~/.cache/puppeteer";
276
277#[cfg(windows)]
282const HUGGINGFACE_CLEAR: &str =
283 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\huggingface\hub";
284#[cfg(not(windows))]
285const HUGGINGFACE_CLEAR: &str = "rm -rf ~/.cache/huggingface/hub";
286
287#[cfg(windows)]
288const CYPRESS_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Cypress\Cache";
289#[cfg(target_os = "macos")]
290const CYPRESS_CLEAR: &str = "rm -rf ~/Library/Caches/Cypress";
291#[cfg(not(any(windows, target_os = "macos")))]
292const CYPRESS_CLEAR: &str = "rm -rf ~/.cache/Cypress";
293
294#[cfg(windows)]
295const ELECTRON_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron\Cache";
296#[cfg(target_os = "macos")]
297const ELECTRON_CLEAR: &str = "rm -rf ~/Library/Caches/electron";
298#[cfg(not(any(windows, target_os = "macos")))]
299const ELECTRON_CLEAR: &str = "rm -rf ~/.cache/electron";
300
301#[cfg(windows)]
302const ELECTRON_BUILDER_CLEAR: &str =
303 r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron-builder\Cache";
304#[cfg(target_os = "macos")]
305const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/Library/Caches/electron-builder";
306#[cfg(not(any(windows, target_os = "macos")))]
307const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/.cache/electron-builder";
308
309const PROBES: &[Probe] = &[
310 Probe {
311 manager: "npm",
312 kind: "cache",
313 query: Some(("npm", &["config", "get", "cache"])),
314 query_suffix: None,
315 clear_command: "npm cache clean --force",
316 clear: Clear::Command("npm", &["cache", "clean", "--force"]),
317 note: None,
318 },
319 Probe {
320 manager: "pnpm",
321 kind: "store",
322 query: Some(("pnpm", &["store", "path"])),
323 query_suffix: None,
324 clear_command: "pnpm store prune",
325 clear: Clear::Command("pnpm", &["store", "prune"]),
326 note: Some(
327 "hardlinked into every node_modules it filled; emptying it is what makes the \
328 next pnpm install a download",
329 ),
330 },
331 Probe {
332 manager: "yarn",
333 kind: "cache",
334 query: Some(("yarn", &["cache", "dir"])),
335 query_suffix: None,
336 clear_command: "yarn cache clean",
337 clear: Clear::Command("yarn", &["cache", "clean"]),
338 note: None,
339 },
340 Probe {
341 manager: "bun",
342 kind: "cache",
343 query: Some(("bun", &["pm", "cache"])),
344 query_suffix: None,
345 clear_command: "bun pm cache rm",
346 clear: Clear::Command("bun", &["pm", "cache", "rm"]),
347 note: None,
348 },
349 Probe {
350 manager: "uv",
351 kind: "cache",
352 query: Some(("uv", &["cache", "dir"])),
353 query_suffix: None,
354 clear_command: "uv cache prune",
357 clear: Clear::Command("uv", &["cache", "prune"]),
358 note: None,
359 },
360 Probe {
361 manager: "pip",
362 kind: "cache",
363 query: Some(("pip", &["cache", "dir"])),
364 query_suffix: None,
365 clear_command: "pip cache purge",
366 clear: Clear::Command("pip", &["cache", "purge"]),
367 note: None,
368 },
369 Probe {
375 manager: "conda",
376 kind: "package cache",
377 query: None,
378 query_suffix: None,
379 clear_command: "conda clean --packages --tarballs --yes",
380 clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
381 note: Some(
382 "unpacked packages and downloaded archives; conda keeps what its \
383 environments use, except any it linked by symlink rather than hardlink",
384 ),
385 },
386 Probe {
387 manager: "cargo",
388 kind: "registry cache",
389 query: None,
390 query_suffix: None,
391 clear_command: CARGO_CACHE_CLEAR,
392 clear: Clear::Directory,
393 note: Some("the downloaded .crate archives; clearing them means downloading again"),
394 },
395 Probe {
396 manager: "cargo",
397 kind: "registry sources",
398 query: None,
399 query_suffix: None,
400 clear_command: CARGO_SRC_CLEAR,
401 clear: Clear::Directory,
402 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
403 },
404 Probe {
405 manager: "go",
406 kind: "module cache",
407 query: Some(("go", &["env", "GOMODCACHE"])),
408 query_suffix: None,
409 clear_command: "go clean -modcache",
410 clear: Clear::Command("go", &["clean", "-modcache"]),
411 note: None,
412 },
413 Probe {
414 manager: "go",
415 kind: "build cache",
416 query: Some(("go", &["env", "GOCACHE"])),
417 query_suffix: None,
418 clear_command: "go clean -cache",
419 clear: Clear::Command("go", &["clean", "-cache"]),
420 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
421 },
422 Probe {
427 manager: "maven",
428 kind: "local repository",
429 query: None,
430 query_suffix: None,
431 clear_command: MAVEN_REPO_CLEAR,
432 clear: Clear::Manual { why: MAVEN_MANUAL },
433 note: Some(
434 "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
435 ),
436 },
437 Probe {
438 manager: "gradle",
439 kind: "caches",
440 query: None,
441 query_suffix: None,
442 clear_command: GRADLE_CACHE_CLEAR,
443 clear: Clear::Directory,
444 note: Some(
445 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
446 ),
447 },
448 Probe {
449 manager: "gradle",
450 kind: "wrapper distributions",
451 query: None,
452 query_suffix: None,
453 clear_command: GRADLE_DISTS_CLEAR,
454 clear: Clear::Directory,
455 note: Some(
456 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
457 ),
458 },
459 Probe {
463 manager: "nuget",
464 kind: "global packages",
465 query: None,
466 query_suffix: None,
467 clear_command: "dotnet nuget locals global-packages --clear",
468 clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
469 note: Some(
470 "every .NET project on the machine restores from here; re-downloaded on the next restore",
471 ),
472 },
473 Probe {
474 manager: "vcpkg",
475 kind: "binary cache",
476 query: None,
477 query_suffix: None,
478 clear_command: VCPKG_ARCHIVES_CLEAR,
479 clear: Clear::Directory,
480 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
481 },
482 Probe {
483 manager: "conan",
484 kind: "package cache",
485 query: None,
486 query_suffix: None,
487 clear_command: "conan remove \"*\" --confirm",
488 clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
489 note: Some(
490 "recipes and binaries shared by every Conan project; re-fetched on the next install",
491 ),
492 },
493 Probe {
498 manager: "composer",
499 kind: "cache",
500 query: Some(("composer", &["config", "--global", "cache-dir"])),
501 query_suffix: None,
502 clear_command: "composer clear-cache",
503 clear: Clear::Command("composer", &["clear-cache"]),
504 note: Some(
505 "downloaded package archives and repository metadata; re-fetched by the next composer install",
506 ),
507 },
508 Probe {
513 manager: "cocoapods",
514 kind: "cache",
515 query: None,
516 query_suffix: None,
517 clear_command: "pod cache clean --all",
518 clear: Clear::Command("pod", &["cache", "clean", "--all"]),
519 note: Some("downloaded pod sources, re-fetched by the next pod install"),
520 },
521 Probe {
522 manager: "hex",
523 kind: "package cache",
524 query: None,
525 query_suffix: None,
526 clear_command: HEX_CACHE_CLEAR,
527 clear: Clear::Directory,
528 note: Some(
529 "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
530 ),
531 },
532 Probe {
538 manager: "bundler",
539 kind: "gem cache",
540 query: Some(("gem", &["env", "gemdir"])),
541 query_suffix: Some("cache"),
542 clear_command: BUNDLER_CACHE_CLEAR,
543 clear: Clear::Directory,
544 note: Some(
545 "only the `cache/` subdirectory of the gem home, which holds the downloaded \
546 `.gem` archives. The installed gems beside it, and the binstubs in `bin/`, \
547 are untouched — including anything put there by `gem install ./local.gem`, \
548 which no remote could hand back.",
549 ),
550 },
551 Probe {
552 manager: "dart",
553 kind: "pub cache",
554 query: None,
555 query_suffix: None,
556 clear_command: DART_HOSTED_CLEAR,
557 clear: Clear::Directory,
558 note: Some(
559 "only `hosted/`, the packages pub downloaded from a registry. The `git/` \
560 checkouts and the executables `dart pub global activate` installed live \
561 beside it and are left alone; `dart pub cache clean` would take all three.",
562 ),
563 },
564 Probe {
565 manager: "swift",
566 kind: "swiftpm cache",
567 query: None,
568 query_suffix: None,
569 clear_command: SWIFTPM_CACHE_CLEAR,
570 clear: Clear::Directory,
571 note: Some(
572 "the shared repository and manifest cache SwiftPM fills for every package on \
573 the machine. `swift build` re-clones what it needs, so the first build after \
574 this one is a network build.",
575 ),
576 },
577 Probe {
578 manager: "terraform",
579 kind: "plugin cache",
580 query: None,
581 query_suffix: None,
582 clear_command: TERRAFORM_PLUGIN_CLEAR,
583 clear: Clear::Directory,
584 note: Some(
585 "this exists only if you switched it on with `TF_PLUGIN_CACHE_DIR` or \
586 `plugin_cache_dir` in `.terraformrc`. Without it Terraform downloads a \
587 private copy of every provider into each project's `.terraform/` — which is \
588 what `devp run` prunes — and there is no shared store to report.",
589 ),
590 },
591 Probe {
592 manager: "poetry",
593 kind: "artifact cache",
594 query: Some(("poetry", &["config", "cache-dir"])),
595 query_suffix: Some("artifacts"),
596 clear_command: POETRY_ARTIFACTS_CLEAR,
597 clear: Clear::Directory,
598 note: Some(
599 "only `artifacts/`, the wheels and sdists Poetry downloaded. `virtualenvs/` \
600 sits in the same cache directory and holds the environments themselves — \
601 deleting that would not be clearing a cache.",
602 ),
603 },
604 Probe {
605 manager: "pdm",
606 kind: "cache",
607 query: Some(("pdm", &["config", "cache_dir"])),
608 query_suffix: None,
609 clear_command: "pdm cache clear",
610 clear: Clear::Command("pdm", &["cache", "clear"]),
611 note: None,
612 },
613 Probe {
619 manager: "playwright",
620 kind: "browser bundles",
621 query: None,
622 query_suffix: None,
623 clear_command: PLAYWRIGHT_CLEAR,
624 clear: Clear::Directory,
625 note: Some(
626 "a full Chromium, Firefox and WebKit build per Playwright version, and the \
627 old ones are never removed when the dependency moves. `npx playwright \
628 install` re-downloads only the versions the installed Playwright asks for, \
629 which is usually why this comes back smaller than it was.",
630 ),
631 },
632 Probe {
633 manager: "puppeteer",
634 kind: "browser bundles",
635 query: None,
636 query_suffix: None,
637 clear_command: PUPPETEER_CLEAR,
638 clear: Clear::Directory,
639 note: Some(
640 "one Chrome build per Puppeteer version. `npx puppeteer browsers install` \
641 re-downloads the current one.",
642 ),
643 },
644 Probe {
645 manager: "huggingface",
646 kind: "hub cache",
647 query: None,
648 query_suffix: None,
649 clear_command: HUGGINGFACE_CLEAR,
650 clear: Clear::Directory,
651 note: Some(
652 "model weights, not packages. This is the one row here where re-downloading \
653 is measured in tens of gigabytes and can fail outright: a gated or \
654 now-private repository will not hand the file back, and a checkpoint from a \
655 revision that has since been deleted is gone. Worth looking at what is in it \
656 before emptying it.",
657 ),
658 },
659 Probe {
660 manager: "cypress",
661 kind: "binary cache",
662 query: None,
663 query_suffix: None,
664 clear_command: CYPRESS_CLEAR,
665 clear: Clear::Directory,
666 note: Some(
667 "one unpacked Electron application per Cypress version, around half a \
668 gigabyte each, and upgrading leaves the previous one in place. `npx cypress \
669 install` re-downloads the version the project pins.",
670 ),
671 },
672 Probe {
673 manager: "electron",
674 kind: "download cache",
675 query: None,
676 query_suffix: None,
677 clear_command: ELECTRON_CLEAR,
678 clear: Clear::Directory,
679 note: Some(
680 "the prebuilt Electron archives every `electron` dependency downloads, kept \
681 per version and per architecture. Re-downloaded on the next install.",
682 ),
683 },
684 Probe {
685 manager: "electron",
686 kind: "builder cache",
687 query: None,
688 query_suffix: None,
689 clear_command: ELECTRON_BUILDER_CLEAR,
690 clear: Clear::Directory,
691 note: Some(
692 "electron-builder's own store of signing tools, `winCodeSign`, `nsis` and the \
693 runtimes it packages with. Re-downloaded on the next build, which makes that \
694 build slow rather than broken.",
695 ),
696 },
697 Probe {
698 manager: "deno",
699 kind: "cache",
700 query: None,
701 query_suffix: None,
702 clear_command: "deno clean",
703 clear: Clear::Command("deno", &["clean"]),
704 note: None,
705 },
706];
707
708pub fn run(json_output: bool, volume: Option<&str>) -> Result<()> {
710 let volume = volume.map(resolve_volume).transpose()?;
712
713 let reg = registered();
714 let mut reports = collect(!json_output, reg.as_ref());
715 apply_caps(&mut reports, &caps());
716 let deps = reg.as_ref().map(|r| dependents(r, !json_output));
717 apply_dependents(&mut reports, deps.as_ref());
718
719 if let Some(root) = &volume {
724 reports = on_volume(reports, root);
725 }
726
727 let engines = if volume.is_some() {
736 Vec::new()
737 } else {
738 container_summary(!json_output)
739 };
740
741 if json_output {
742 return json::emit(&json::caches_document(
743 &reports,
744 deps.as_ref().map(|d| d.repositories),
745 &engines,
746 volume.as_deref(),
747 ));
748 }
749
750 print_report(&reports, deps.as_ref(), volume.as_deref());
751 crate::commands::containers::print_summary(&engines);
752 Ok(())
753}
754
755fn resolve_volume(arg: &str) -> Result<PathBuf> {
760 let path = absolutize(normalize_volume_arg(arg));
761 volume_root(&path).ok_or_else(|| {
762 anyhow::Error::new(crate::UsageError(format!(
763 "`{arg}` is not a drive or a path on one. Name a drive (`--volume V:`), a \
764 mount point (`--volume /mnt/data`), or any path that sits on the one you \
765 mean."
766 )))
767 })
768}
769
770fn absolutize(path: PathBuf) -> PathBuf {
780 if path.is_absolute() || !path.exists() {
781 return path;
782 }
783 std::path::absolute(&path).unwrap_or(path)
784}
785
786#[cfg(windows)]
793fn normalize_volume_arg(arg: &str) -> PathBuf {
794 let trimmed = arg.trim();
795 let letter = match trimmed.as_bytes() {
796 [c] if c.is_ascii_alphabetic() => Some(*c),
797 [c, b':'] if c.is_ascii_alphabetic() => Some(*c),
798 _ => None,
799 };
800 match letter {
801 Some(c) => PathBuf::from(format!("{}:\\", c.to_ascii_uppercase() as char)),
802 None => PathBuf::from(trimmed),
803 }
804}
805
806#[cfg(unix)]
808fn normalize_volume_arg(arg: &str) -> PathBuf {
809 PathBuf::from(arg.trim())
810}
811
812#[cfg(windows)]
819fn same_volume(a: &Path, b: &Path) -> bool {
820 match (volume_key(a), volume_key(b)) {
821 (Some(a), Some(b)) => a == b,
822 _ => false,
823 }
824}
825
826#[cfg(windows)]
828fn volume_key(path: &Path) -> Option<String> {
829 use std::path::{Component, Prefix};
830
831 let Some(Component::Prefix(prefix)) = path.components().next() else {
832 return None;
833 };
834 Some(match prefix.kind() {
835 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
836 (letter.to_ascii_uppercase() as char).to_string()
837 }
838 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => format!(
839 "{}\\{}",
840 server.to_string_lossy().to_uppercase(),
841 share.to_string_lossy().to_uppercase()
842 ),
843 other => format!("{other:?}").to_uppercase(),
844 })
845}
846
847#[cfg(unix)]
849fn same_volume(a: &Path, b: &Path) -> bool {
850 a == b
851}
852
853fn on_volume(reports: Vec<CacheReport>, root: &Path) -> Vec<CacheReport> {
858 reports
859 .into_iter()
860 .filter(|r| volume_root(&r.path).is_some_and(|v| same_volume(&v, root)))
861 .collect()
862}
863
864fn volume_totals(reports: &[CacheReport]) -> Vec<(String, u64)> {
870 let mut totals: Vec<(String, u64)> = Vec::new();
871 for r in reports {
872 let Some(root) = volume_root(&r.path) else {
873 continue;
874 };
875 let label = output::clean_path(&root);
876 match totals.iter_mut().find(|(l, _)| *l == label) {
877 Some((_, bytes)) => *bytes += r.bytes,
878 None => totals.push((label, r.bytes)),
879 }
880 }
881 totals.sort_by_key(|(_, bytes)| std::cmp::Reverse(*bytes));
882 totals
883}
884
885fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
887 let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
888 let engines = crate::commands::containers::collect(None);
889 if let Some(pb) = pb {
890 pb.finish_and_clear();
891 }
892 engines
893}
894
895fn caps() -> BTreeMap<String, u64> {
900 crate::config::Registry::load()
901 .map(|r| r.settings.cache_max_gb)
902 .unwrap_or_default()
903}
904
905fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
910 let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
911 for r in reports.iter() {
912 *totals.entry(r.manager).or_default() += r.bytes;
913 }
914 for r in reports.iter_mut() {
915 let Some(&gb) = caps
918 .get(r.manager)
919 .or_else(|| caps.get(constants::CACHE_CAP_DEFAULT_KEY))
920 else {
921 continue;
922 };
923 r.cap_gb = Some(gb);
924 r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
925 > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
926 }
927}
928
929struct Registered {
935 paths: Vec<PathBuf>,
937 scan_depth: usize,
939}
940
941fn registered() -> Option<Registered> {
949 let registry = crate::config::Registry::load().ok()?;
950 let paths: Vec<PathBuf> = registry
951 .repositories
952 .keys()
953 .filter(|p| p.exists())
954 .cloned()
955 .collect();
956 if paths.is_empty() {
957 return None;
958 }
959 Some(Registered {
960 paths,
961 scan_depth: registry.settings.scan_depth,
962 })
963}
964
965struct Dependents {
973 repositories: usize,
976 by_manager: BTreeMap<&'static str, usize>,
982}
983
984fn dependents(reg: &Registered, spinner: bool) -> Dependents {
990 let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
991
992 let mut by_manager: BTreeMap<&'static str, usize> = PROBES
1000 .iter()
1001 .map(|p| p.manager)
1002 .filter(|m| adapters::is_adapter_name(m))
1003 .map(|m| (m, 0))
1004 .collect();
1005
1006 for path in ®.paths {
1007 let depth = crate::workspace::clamp_depth(
1012 crate::config::PerRepoConfig::load_with_diagnostics(path)
1013 .ok()
1014 .flatten()
1015 .and_then(|c| c.scan_depth)
1016 .unwrap_or(reg.scan_depth),
1017 );
1018 let mut here: HashSet<&'static str> = HashSet::new();
1019 for project in crate::workspace::discover_all_to_depth(path, depth) {
1020 for adapter in &project.adapters {
1021 here.insert(adapter.name());
1022 }
1023 }
1024 for (manager, count) in by_manager.iter_mut() {
1025 if here.contains(manager) {
1026 *count += 1;
1027 }
1028 }
1029 }
1030
1031 if let Some(pb) = pb {
1032 pb.finish_and_clear();
1033 }
1034
1035 Dependents {
1036 repositories: reg.paths.len(),
1037 by_manager,
1038 }
1039}
1040
1041fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
1043 let Some(deps) = deps else {
1044 return;
1045 };
1046 for r in reports.iter_mut() {
1047 r.dependents = deps.by_manager.get(r.manager).copied();
1048 }
1049}
1050
1051fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, (u64, usize)> {
1058 let mut totals: BTreeMap<&'static str, (u64, usize)> = BTreeMap::new();
1059 for r in reports {
1060 let entry = totals.entry(r.manager).or_default();
1061 entry.0 += r.bytes;
1062 entry.1 += 1;
1063 }
1064 totals
1065}
1066
1067fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
1069 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
1070 let from = query_dir();
1071
1072 let mut seen: HashSet<PathBuf> = HashSet::new();
1073 let mut reports = Vec::new();
1074
1075 for probe in PROBES {
1076 let Some(path) = locate(probe, &from) else {
1077 continue;
1078 };
1079 let path = path.canonicalize().unwrap_or(path);
1086 if !seen.insert(path.clone()) {
1087 continue;
1088 }
1089 reports.push(CacheReport {
1090 manager: probe.manager,
1091 kind: probe.kind,
1092 bytes: adapters::dir_size(&path),
1093 path,
1094 clear_command: probe.clear_command.to_string(),
1095 clear: probe.clear,
1096 note: probe.note,
1097 cap_gb: None,
1098 over_cap: false,
1099 dependents: None,
1100 extra_args: Vec::new(),
1101 });
1102 }
1103
1104 for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
1107 if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
1108 continue;
1109 }
1110 reports.push(volume_store_report(store));
1111 }
1112
1113 if let Some(pb) = pb {
1114 pb.finish_and_clear();
1115 }
1116
1117 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1118 reports
1119}
1120
1121const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
1123 this is the store for the projects on this volume";
1124
1125fn volume_store_report(store: PathBuf) -> CacheReport {
1131 let named = output::clean_path(&store);
1132 CacheReport {
1133 manager: "pnpm",
1134 kind: "store",
1135 bytes: adapters::dir_size(&store),
1136 clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
1137 extra_args: vec!["--store-dir".to_string(), named],
1138 path: store,
1139 clear: Clear::Command("pnpm", &["store", "prune"]),
1140 note: Some(PNPM_VOLUME_NOTE),
1141 cap_gb: None,
1142 over_cap: false,
1143 dependents: None,
1144 }
1145}
1146
1147fn shell_arg(named: &str) -> String {
1152 if named.contains(' ') {
1153 format!("\"{named}\"")
1154 } else {
1155 named.to_string()
1156 }
1157}
1158
1159fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
1174 let mut roots = volume_roots(repos);
1178 if let Ok(here) = std::env::current_dir()
1179 && let Some(root) = volume_root(&here)
1180 && !roots.contains(&root)
1181 {
1182 roots.push(root);
1183 }
1184 roots
1185 .into_iter()
1186 .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
1187 .filter(|store| store.is_dir())
1188 .collect()
1189}
1190
1191fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
1193 let mut roots: Vec<PathBuf> = Vec::new();
1194 for repo in repos {
1195 if let Some(root) = volume_root(repo)
1196 && !roots.contains(&root)
1197 {
1198 roots.push(root);
1199 }
1200 }
1201 roots
1202}
1203
1204#[cfg(unix)]
1211pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
1212 use std::os::unix::fs::MetadataExt;
1213
1214 let dev = std::fs::metadata(path).ok()?.dev();
1215 let mut root = path.to_path_buf();
1216 for ancestor in path.ancestors().skip(1) {
1217 match std::fs::metadata(ancestor) {
1218 Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
1219 _ => break,
1220 }
1221 }
1222 Some(root)
1223}
1224
1225#[cfg(windows)]
1232pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
1233 use std::path::Component;
1234
1235 let mut components = path.components();
1236 let Some(Component::Prefix(prefix)) = components.next() else {
1237 return None;
1238 };
1239 if components.next() != Some(Component::RootDir) {
1240 return None;
1241 }
1242 let mut root = PathBuf::from(prefix.as_os_str());
1243 root.push(Component::RootDir.as_os_str());
1244 Some(root)
1245}
1246
1247fn query_dir() -> PathBuf {
1254 dirs::home_dir()
1255 .or_else(|| std::env::current_dir().ok())
1256 .unwrap_or_else(|| PathBuf::from("."))
1257}
1258
1259fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
1261 if let Some((program, args)) = probe.query
1262 && adapters::binary_available(program)
1263 {
1264 let answered = adapters::capture_command_with_timeout(
1265 program,
1266 args,
1267 from,
1268 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
1269 )
1270 .ok()
1271 .and_then(|raw| path_from_output(&raw))
1272 .map(|p| match probe.query_suffix {
1273 Some(suffix) => suffix.split('/').fold(p, |acc, seg| acc.join(seg)),
1274 None => p,
1275 })
1276 .filter(|p| p.is_dir());
1277 if answered.is_some() {
1278 return answered;
1279 }
1280 }
1281
1282 fallbacks(probe.manager, probe.kind)
1287 .into_iter()
1288 .find(|p| p.is_dir())
1289}
1290
1291fn path_from_output(raw: &str) -> Option<PathBuf> {
1296 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
1297 let line = line.trim_matches('"');
1298 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
1301 return None;
1302 }
1303 Some(PathBuf::from(line))
1304}
1305
1306fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
1308 let home = dirs::home_dir();
1309 let local = dirs::data_local_dir();
1310 let cache = dirs::cache_dir();
1311 let under = |base: &Option<PathBuf>, rel: &str| {
1315 base.as_ref()
1316 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
1317 };
1318
1319 let candidates = match (manager, kind) {
1320 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
1323 ("pnpm", _) => vec![
1324 under(&local, "pnpm/store"),
1325 under(&home, ".local/share/pnpm/store"),
1326 under(&home, "Library/pnpm/store"),
1327 under(&home, ".pnpm-store"),
1328 ],
1329 ("yarn", _) => vec![
1330 under(&home, ".yarn/berry/cache"),
1331 under(&local, "Yarn/Cache"),
1332 under(&cache, "yarn"),
1333 ],
1334 ("bun", _) => vec![under(&home, ".bun/install/cache")],
1335 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
1336 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
1337 ("conda", _) => vec![
1345 std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
1346 std::env::var_os("CONDA_EXE")
1347 .map(PathBuf::from)
1348 .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
1349 .map(|root| root.join("pkgs")),
1350 under(&home, "miniconda3/pkgs"),
1351 under(&home, "anaconda3/pkgs"),
1352 under(&home, "miniforge3/pkgs"),
1353 under(&home, "mambaforge/pkgs"),
1354 under(&home, ".conda/pkgs"),
1355 ],
1356 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
1357 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
1358 ("go", "module cache") => vec![
1359 std::env::var_os("GOMODCACHE").map(PathBuf::from),
1360 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
1361 under(&home, "go/pkg/mod"),
1362 ],
1363 ("go", _) => vec![
1364 std::env::var_os("GOCACHE").map(PathBuf::from),
1365 under(&cache, "go-build"),
1366 under(&local, "go-build"),
1367 ],
1368 ("maven", _) => vec![under(&home, ".m2/repository")],
1369 ("gradle", "caches") => vec![
1371 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
1372 under(&home, ".gradle/caches"),
1373 ],
1374 ("gradle", _) => vec![
1375 std::env::var_os("GRADLE_USER_HOME")
1376 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
1377 under(&home, ".gradle/wrapper/dists"),
1378 ],
1379 ("nuget", _) => vec![
1380 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
1381 under(&home, ".nuget/packages"),
1382 ],
1383 ("vcpkg", _) => vec![
1384 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
1385 under(&local, "vcpkg/archives"),
1386 under(&cache, "vcpkg/archives"),
1387 ],
1388 ("conan", _) => vec![
1391 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
1392 under(&home, ".conan2/p"),
1393 ],
1394 ("composer", _) => vec![
1397 std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
1398 std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
1399 under(&local, "Composer"),
1400 under(&cache, "composer"),
1401 under(&home, ".composer/cache"),
1402 ],
1403 ("cocoapods", _) => vec![
1407 std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
1408 under(&home, "Library/Caches/CocoaPods"),
1409 ],
1410 ("hex", _) => vec![
1413 std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
1414 under(&home, ".hex/packages"),
1415 under(&cache, "hex/packages"),
1416 ],
1417 ("bundler", _) => gem_cache_dirs(),
1422 ("dart", _) => vec![
1423 std::env::var_os("PUB_CACHE").map(|p| PathBuf::from(p).join("hosted")),
1424 under(&local, "Pub/Cache/hosted"),
1425 under(&home, ".pub-cache/hosted"),
1426 ],
1427 ("swift", _) => vec![
1430 under(&cache, "org.swift.swiftpm"),
1431 under(&home, ".swiftpm/cache"),
1432 ],
1433 ("terraform", _) => vec![
1438 std::env::var_os("TF_PLUGIN_CACHE_DIR").map(PathBuf::from),
1439 under(&home, ".terraform.d/plugin-cache"),
1440 under(&dirs::data_dir(), "terraform.d/plugin-cache"),
1441 ],
1442 ("poetry", _) => vec![
1443 std::env::var_os("POETRY_CACHE_DIR").map(|p| PathBuf::from(p).join("artifacts")),
1444 under(&cache, "pypoetry/Cache/artifacts"),
1445 under(&cache, "pypoetry/artifacts"),
1446 ],
1447 ("pdm", _) => vec![
1448 std::env::var_os("PDM_CACHE_DIR").map(PathBuf::from),
1449 under(&cache, "pdm/Cache"),
1450 under(&cache, "pdm"),
1451 ],
1452 ("playwright", _) => vec![
1453 std::env::var_os("PLAYWRIGHT_BROWSERS_PATH").map(PathBuf::from),
1454 under(&cache, "ms-playwright"),
1455 ],
1456 ("puppeteer", _) => vec![
1460 std::env::var_os("PUPPETEER_CACHE_DIR").map(PathBuf::from),
1461 under(&home, ".cache/puppeteer"),
1462 ],
1463 ("huggingface", _) => vec![
1466 std::env::var_os("HF_HUB_CACHE").map(PathBuf::from),
1467 std::env::var_os("HF_HOME").map(|p| PathBuf::from(p).join("hub")),
1468 under(&home, ".cache/huggingface/hub"),
1469 ],
1470 ("cypress", _) => vec![
1474 std::env::var_os("CYPRESS_CACHE_FOLDER").map(PathBuf::from),
1475 under(&cache, "Cypress/Cache"),
1476 under(&cache, "Cypress"),
1477 ],
1478 ("electron", "download cache") => vec![
1479 std::env::var_os("electron_config_cache").map(PathBuf::from),
1480 under(&cache, "electron/Cache"),
1481 under(&cache, "electron"),
1482 ],
1483 ("electron", _) => vec![
1484 under(&cache, "electron-builder/Cache"),
1485 under(&cache, "electron-builder"),
1486 ],
1487 ("deno", _) => vec![
1488 std::env::var_os("DENO_DIR").map(PathBuf::from),
1489 under(&cache, "deno"),
1490 ],
1491 _ => vec![],
1492 };
1493
1494 candidates.into_iter().flatten().collect()
1495}
1496
1497fn gem_cache_dirs() -> Vec<Option<PathBuf>> {
1505 let mut out = vec![std::env::var_os("GEM_HOME").map(|p| PathBuf::from(p).join("cache"))];
1506 let home = dirs::home_dir();
1507 for parent in [".gem/ruby", ".local/share/gem/ruby"] {
1508 let Some(base) = home
1509 .as_ref()
1510 .map(|h| parent.split('/').fold(h.clone(), |p, seg| p.join(seg)))
1511 else {
1512 continue;
1513 };
1514 if let Ok(entries) = std::fs::read_dir(&base) {
1515 out.extend(entries.flatten().map(|e| Some(e.path().join("cache"))));
1516 }
1517 }
1518 out.push(home.map(|h| h.join(".gem").join("cache")));
1519 out
1520}
1521
1522fn cargo_home() -> PathBuf {
1524 std::env::var_os("CARGO_HOME")
1525 .map(PathBuf::from)
1526 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
1527 .unwrap_or_else(|| PathBuf::from(".cargo"))
1528}
1529
1530fn print_report(reports: &[CacheReport], deps: Option<&Dependents>, volume: Option<&Path>) {
1531 output::print_header(i18n::t("caches.header"));
1532
1533 if reports.is_empty() {
1534 println!();
1535 match volume {
1536 Some(root) => output::print_info(&format!(
1537 "No package manager cache on this machine sits on {}. They are somewhere \
1538 else — run `devp caches` without `--volume` to see where.",
1539 output::clean_path(root)
1540 )),
1541 None => output::print_info(i18n::t("caches.nothing")),
1542 }
1543 return;
1544 }
1545
1546 println!();
1547 let totals = manager_totals(reports);
1548 let mut counted: HashSet<&'static str> = HashSet::new();
1551 for r in reports {
1552 let label = format!("{} {}", r.manager, r.kind);
1553 println!(
1554 " {:<30} {:>10} {}",
1555 label,
1556 output::format_bytes(r.bytes),
1557 output::clean_path(&r.path)
1558 );
1559 let clearable = !matches!(r.clear, Clear::Manual { .. });
1571 if clearable {
1572 println!(
1573 " {:<30} {:>10} clear: devp caches clear {}",
1574 "", "", r.manager
1575 );
1576 }
1577 let verb = if clearable { "runs: " } else { "clear:" };
1578 println!(" {:<30} {:>10} {verb} {}", "", "", r.clear_command);
1579 if let Some(note) = r.note {
1580 println!(" {:<30} {:>10} {}", "", "", note);
1581 }
1582 if r.over_cap
1583 && let Some(gb) = r.cap_gb
1584 {
1585 println!(
1586 " {:<30} {:>10} over the {gb} GiB cap you set for {}",
1587 "", "", r.manager
1588 );
1589 }
1590 if let Some(n) = r.dependents
1591 && counted.insert(r.manager)
1592 {
1593 println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
1594 }
1595 println!();
1596 }
1597
1598 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1599 println!(
1600 " {:<30} {:>10} across {} {}",
1601 "Total",
1602 output::format_bytes(total),
1603 reports.len(),
1604 output::plural(reports.len(), "cache", "caches")
1605 );
1606
1607 let by_volume = volume_totals(reports);
1613 if by_volume.len() > 1 {
1614 let named = by_volume
1615 .iter()
1616 .map(|(label, bytes)| format!("{label} {}", output::format_bytes(*bytes)))
1617 .collect::<Vec<_>>()
1618 .join(" · ");
1619 println!(" {:<30} {:>10} {named}", "By drive", "");
1620 }
1621
1622 let ranked = costliest_per_repository(reports);
1626 if ranked.len() > 1 {
1627 let named = ranked
1628 .iter()
1629 .take(3)
1630 .map(|(m, b)| format!("{m} {}", output::format_bytes_weighted(*b)))
1631 .collect::<Vec<_>>()
1632 .join(" · ");
1633 println!(" {:<30} {:>10} {named}", "Costliest per repository", "");
1634 }
1635
1636 if reports.iter().any(|r| r.over_cap) {
1637 println!();
1638 output::print_info(
1639 "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1640 --over-cap all` empties exactly those and leaves the rest alone.",
1641 );
1642 }
1643
1644 if reports.iter().any(|r| r.dependents == Some(0)) {
1645 println!();
1646 output::print_info(
1647 "The caches above that no registered repository uses were filled for projects that \
1648 are not here any more. `devp caches clear --unused all` empties exactly those. It \
1649 counts only repositories dev-prune knows about, so `devp link` anything you keep \
1650 outside the registry before trusting the number.",
1651 );
1652 }
1653
1654 if let Some(root) = volume {
1655 println!();
1656 output::print_info(&format!(
1657 "Only the caches on {} are above, and the container engines are not among \
1658 them: an engine reports its own disk from inside a VM image with no path on \
1659 this filesystem, so there is no drive to file it under — `devp caches \
1660 docker` has that number. The clear commands above are not drive-specific \
1661 either. They empty that manager's cache wherever it is, which for every \
1662 manager but pnpm is one place.",
1663 output::clean_path(root)
1664 ));
1665 }
1666
1667 println!();
1668 output::print_info(
1669 "Nothing above was deleted. A cache is shared by every project on the machine, so \
1670 no single repository's lockfile can prove it is recoverable — and it is what \
1671 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1672 will ever touch one. When you want the space more than the speed, run a clear \
1673 command yourself, or `devp caches clear <manager>` — only the second is counted \
1674 in `devp stats`, because dev-prune never sees the first.",
1675 );
1676}
1677
1678fn used_by(
1692 r: &CacheReport,
1693 dependents: usize,
1694 deps: Option<&Dependents>,
1695 totals: &BTreeMap<&'static str, (u64, usize)>,
1696) -> String {
1697 if dependents == 0 {
1698 return format!("no registered repository uses {}", r.manager)
1699 .bold()
1700 .to_string();
1701 }
1702 let registered = deps.map_or(dependents, |d| d.repositories);
1703 let (total, rows) = totals.get(r.manager).copied().unwrap_or((r.bytes, 1));
1704 let share = output::format_bytes(total / dependents as u64);
1709 let each = if rows > 1 {
1710 format!("{share} each across its {rows} caches")
1711 } else {
1712 format!("{share} each")
1713 };
1714 format!(
1715 "{} is used by {dependents} of {registered} registered {} · {each}",
1716 r.manager,
1717 output::plural(registered, "repository", "repositories"),
1718 )
1719 .bold()
1720 .to_string()
1721}
1722
1723fn costliest_per_repository(reports: &[CacheReport]) -> Vec<(&'static str, u64)> {
1730 let totals = manager_totals(reports);
1731 let mut per: BTreeMap<&'static str, u64> = BTreeMap::new();
1734 for r in reports {
1735 if let Some(n) = r.dependents.filter(|n| *n > 0) {
1736 let total = totals.get(r.manager).map_or(r.bytes, |t| t.0);
1737 per.insert(r.manager, total / n as u64);
1738 }
1739 }
1740 let mut ranked: Vec<(&'static str, u64)> = per.into_iter().collect();
1741 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
1743 ranked
1744}
1745
1746pub struct ClearOutcome {
1748 pub manager: &'static str,
1750 pub kind: &'static str,
1752 pub path: PathBuf,
1754 pub before: u64,
1756 pub after: u64,
1760 pub problem: Option<String>,
1762}
1763
1764impl ClearOutcome {
1765 pub fn freed(&self) -> u64 {
1767 self.before.saturating_sub(self.after)
1768 }
1769}
1770
1771pub fn run_clear(
1778 target: &str,
1779 over_cap: bool,
1780 unused: bool,
1781 yes: bool,
1782 dry_run: bool,
1783 json_output: bool,
1784) -> Result<()> {
1785 let all = target.eq_ignore_ascii_case("all");
1786 if !all && crate::commands::containers::is_engine(target) {
1794 if over_cap || unused {
1795 return Err(anyhow::Error::new(crate::UsageError(format!(
1796 "`--over-cap` and `--unused` pick package manager caches by size cap and by \
1797 which repositories still need them. Neither applies to {target}: an image \
1798 belongs to no repository and `cache_max_gb` does not cover one. Run `devp \
1799 caches clear {target}` on its own."
1800 ))));
1801 }
1802 return crate::commands::containers::run_clear(target, yes, dry_run, json_output);
1803 }
1804 if !all
1805 && !PROBES
1806 .iter()
1807 .any(|p| p.manager.eq_ignore_ascii_case(target))
1808 {
1809 return Err(anyhow::Error::new(crate::UsageError(format!(
1810 "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1811 known_managers().join(", ")
1812 ))));
1813 }
1814 if !all
1819 && let Some(probe) = manual_only(target)
1820 && let Clear::Manual { why } = probe.clear
1821 {
1822 return Err(anyhow::Error::new(crate::UsageError(format!(
1823 "{why} The command is: {}",
1824 probe.clear_command
1825 ))));
1826 }
1827
1828 if json_output && !yes && !dry_run {
1831 return Err(anyhow::Error::new(crate::UsageError(
1832 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1833 to see what would go."
1834 .to_string(),
1835 )));
1836 }
1837
1838 let reg = registered();
1842 let mut measured = collect(!json_output, reg.as_ref());
1843 apply_caps(&mut measured, &caps());
1844
1845 let deps = if unused {
1849 let Some(reg) = reg.as_ref() else {
1850 return Err(anyhow::Error::new(crate::UsageError(
1851 "`--unused` empties the caches no registered repository needs, and there are no \
1852 registered repositories on this disk to check against — every cache would look \
1853 unused. Register what you keep with `devp link` first."
1854 .to_string(),
1855 )));
1856 };
1857 Some(dependents(reg, !json_output))
1858 } else {
1859 None
1860 };
1861 apply_dependents(&mut measured, deps.as_ref());
1862
1863 let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1867 .into_iter()
1868 .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
1869 .filter(|r| !over_cap || r.over_cap)
1870 .filter(|r| !unused || r.dependents == Some(0))
1871 .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
1872
1873 if reports.is_empty() {
1874 if json_output {
1875 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1876 }
1877 if unused {
1878 output::print_info(
1879 "Every cache on this machine is used by at least one registered repository, or \
1880 is one dev-prune cannot attribute to any — nothing to clear.",
1881 );
1882 return Ok(());
1883 }
1884 if over_cap {
1885 output::print_info(if caps().is_empty() {
1889 "No cache size caps are set, so nothing is over one. Set one for every manager \
1890 with `devp config set cache_max_gb default=10`, or pick them out one at a time \
1891 in `devp config wizard`."
1892 } else {
1893 "Every capped cache is under its cap — nothing to clear."
1894 });
1895 return Ok(());
1896 }
1897 output::print_info(&format!(
1898 "No {} cache on this machine — nothing to clear.",
1899 if all { "package manager" } else { target }
1900 ));
1901 return Ok(());
1902 }
1903
1904 if dry_run {
1905 if json_output {
1906 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1907 }
1908 print_kept(&kept);
1909 print_clear_plan(&reports, true);
1910 return Ok(());
1911 }
1912
1913 if !json_output {
1914 print_kept(&kept);
1915 print_clear_plan(&reports, false);
1916 if !confirm_clear(yes) {
1917 output::print_info("Nothing was cleared.");
1918 return Ok(());
1919 }
1920 }
1921
1922 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
1923 record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
1927
1928 if json_output {
1929 json::emit(&json::caches_clear_document(&outcomes, &kept))?;
1930 } else {
1931 print_clear_result(&outcomes);
1932 }
1933
1934 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
1937 if failed > 0 {
1938 anyhow::bail!(
1939 "{failed} {} could not be cleared.",
1940 output::plural(failed, "cache", "caches")
1941 );
1942 }
1943 Ok(())
1944}
1945
1946fn manual_only(target: &str) -> Option<&'static Probe> {
1951 let matching: Vec<&Probe> = PROBES
1952 .iter()
1953 .filter(|p| p.manager.eq_ignore_ascii_case(target))
1954 .collect();
1955 if matching.is_empty()
1956 || matching
1957 .iter()
1958 .any(|p| !matches!(p.clear, Clear::Manual { .. }))
1959 {
1960 return None;
1961 }
1962 matching.first().copied()
1963}
1964
1965pub fn is_cache_manager(name: &str) -> bool {
1967 PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
1968}
1969
1970pub fn known_managers() -> Vec<&'static str> {
1972 let mut names: Vec<&'static str> = Vec::new();
1973 for probe in PROBES {
1974 if !names.contains(&probe.manager) {
1975 names.push(probe.manager);
1976 }
1977 }
1978 names
1979}
1980
1981fn clear_one(report: &CacheReport) -> ClearOutcome {
1983 let problem = match report.clear {
1984 Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
1985 Clear::Directory => remove_cache_dir(&report.path),
1986 Clear::Manual { why } => Some(why.to_string()),
1990 };
1991 ClearOutcome {
1992 manager: report.manager,
1993 kind: report.kind,
1994 path: report.path.clone(),
1995 before: report.bytes,
1996 after: adapters::dir_size(&report.path),
1999 problem,
2000 }
2001}
2002
2003fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
2005 if !adapters::binary_available(program) {
2006 return Some(format!(
2007 "`{program}` is not on PATH — only it knows what in this cache is still \
2008 referenced, so dev-prune will not delete the directory in its place."
2009 ));
2010 }
2011 let mut all: Vec<&str> = args.to_vec();
2014 all.extend(extra.iter().map(String::as_str));
2015 adapters::run_command_with_timeout(
2016 program,
2017 &all,
2018 &query_dir(),
2019 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
2020 )
2021 .err()
2022 .map(|e| format!("{e:#}"))
2023}
2024
2025fn remove_cache_dir(path: &Path) -> Option<String> {
2027 std::fs::remove_dir_all(path)
2031 .or_else(|_| {
2032 std::thread::sleep(std::time::Duration::from_millis(250));
2033 std::fs::remove_dir_all(path)
2034 })
2035 .err()
2036 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
2038 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
2039}
2040
2041fn print_kept(kept: &[CacheReport]) {
2043 for r in kept {
2044 let Clear::Manual { why } = r.clear else {
2045 continue;
2046 };
2047 println!();
2048 output::print_info(&format!(
2049 "Keeping {} {} ({} at {}). {why}",
2050 r.manager,
2051 r.kind,
2052 output::format_bytes(r.bytes),
2053 output::clean_path(&r.path)
2054 ));
2055 }
2056}
2057
2058fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
2060 output::print_header(if dry_run {
2061 i18n::t("caches.header.would_clear")
2062 } else {
2063 i18n::t("caches.header.about_to_clear")
2064 });
2065
2066 println!();
2067 for r in reports {
2068 println!(
2069 " {:<30} {:>10} {}",
2070 format!("{} {}", r.manager, r.kind),
2071 output::format_bytes(r.bytes),
2072 output::clean_path(&r.path)
2073 );
2074 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
2075 }
2076
2077 println!();
2078 let total: u64 = reports.iter().map(|r| r.bytes).sum();
2079 println!(
2080 " {:<30} {:>10} across {} {}",
2081 "Total",
2082 output::format_bytes(total),
2083 reports.len(),
2084 output::plural(reports.len(), "cache", "caches")
2085 );
2086
2087 println!();
2088 output::print_info(
2089 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
2090 The cost is time: the next install, and the next `devp restore`, in every \
2091 project on this machine.",
2092 );
2093}
2094
2095fn record_cache_clear(bytes: u64) {
2102 if bytes == 0 {
2103 return;
2104 }
2105 if let Ok(mut registry) = crate::config::Registry::load() {
2106 registry.record_cache_clear(bytes);
2107 let _ = registry.save();
2108 }
2109}
2110
2111fn print_clear_result(outcomes: &[ClearOutcome]) {
2113 println!();
2114 for o in outcomes {
2115 let label = format!("{} {}", o.manager, o.kind);
2116 println!(
2117 " {:<30} {:>10} {}",
2118 label,
2119 output::format_bytes(o.freed()),
2120 if o.problem.is_some() {
2121 "not cleared"
2122 } else {
2123 "cleared"
2124 }
2125 );
2126 if let Some(why) = &o.problem {
2127 println!(" {:<30} {:>10} {why}", "", "");
2128 }
2129 }
2130
2131 println!();
2132 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
2133 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
2134}
2135
2136pub fn confirm_clear(yes: bool) -> bool {
2139 use std::io::{IsTerminal, Write};
2140 if yes {
2141 return true;
2142 }
2143 if !std::io::stdin().is_terminal() {
2144 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
2145 return false;
2146 }
2147 eprint!("Clear them? [y/N]: ");
2151 if std::io::stderr().flush().is_err() {
2152 return false;
2153 }
2154 let mut input = String::new();
2155 if std::io::stdin().read_line(&mut input).is_err() {
2156 return false;
2157 }
2158 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163 use super::*;
2164
2165 #[test]
2166 fn every_probe_can_be_found_without_its_manager_installed() {
2167 for probe in PROBES {
2170 assert!(
2171 !fallbacks(probe.manager, probe.kind).is_empty(),
2172 "{} {} has no conventional location",
2173 probe.manager,
2174 probe.kind
2175 );
2176 }
2177 }
2178
2179 #[test]
2180 fn every_probe_names_the_command_that_clears_it() {
2181 for probe in PROBES {
2182 assert!(
2183 !probe.clear_command.trim().is_empty(),
2184 "{} {} reports a size with no way to act on it",
2185 probe.manager,
2186 probe.kind
2187 );
2188 }
2189 }
2190
2191 #[test]
2192 fn the_probed_managers_with_no_adapter_of_the_same_name_are_pinned() {
2193 let orphans: Vec<&str> = PROBES
2198 .iter()
2199 .map(|p| p.manager)
2200 .filter(|m| !adapters::is_adapter_name(m))
2201 .collect::<std::collections::BTreeSet<_>>()
2202 .into_iter()
2203 .collect();
2204 assert_eq!(
2205 orphans,
2206 [
2207 "conan",
2208 "conda",
2209 "cypress",
2210 "electron",
2211 "hex",
2212 "huggingface",
2213 "nuget",
2214 "pip",
2215 "playwright",
2216 "puppeteer",
2217 ]
2218 );
2219 }
2220
2221 #[test]
2222 fn no_two_probes_describe_the_same_cache() {
2223 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
2224 let count = keys.len();
2225 keys.sort_unstable();
2226 keys.dedup();
2227 assert_eq!(keys.len(), count, "two probes share a manager and kind");
2228 }
2229
2230 #[test]
2231 fn a_managers_answer_is_read_off_the_last_line() {
2232 let raw = if cfg!(windows) {
2234 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
2235 } else {
2236 "npm warn config global deprecated\n/home/dev/.npm\n"
2237 };
2238 assert!(path_from_output(raw).is_some());
2239 }
2240
2241 #[test]
2242 fn quoted_paths_lose_their_quotes() {
2243 let raw = if cfg!(windows) {
2244 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
2245 } else {
2246 "\"/opt/go path/pkg/mod\"\n"
2247 };
2248 let path = path_from_output(raw).expect("a quoted path is still a path");
2249 assert!(!path.to_string_lossy().contains('"'));
2250 }
2251
2252 #[test]
2253 fn a_non_answer_is_not_mistaken_for_a_path() {
2254 for raw in [
2257 "",
2258 "\n \n",
2259 "undefined\n",
2260 "not a command\n",
2261 "./relative\n",
2262 ] {
2263 assert!(
2264 path_from_output(raw).is_none(),
2265 "{raw:?} was accepted as a cache path"
2266 );
2267 }
2268 }
2269
2270 #[test]
2271 fn the_cargo_rows_point_inside_the_registry() {
2272 for kind in ["registry cache", "registry sources"] {
2275 let path = fallbacks("cargo", kind).remove(0);
2276 assert!(
2277 path.starts_with(cargo_home().join("registry")),
2278 "{kind} resolved outside the cargo registry: {}",
2279 path.display()
2280 );
2281 }
2282 }
2283
2284 #[test]
2285 fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
2286 let home = dirs::home_dir().expect("a home directory");
2292 let found = fallbacks("conda", "package cache");
2293
2294 for install in [
2295 "miniconda3",
2296 "anaconda3",
2297 "miniforge3",
2298 "mambaforge",
2299 ".conda",
2300 ] {
2301 let want = home.join(install).join("pkgs");
2302 assert!(
2303 found.contains(&want),
2304 "{} is not among conda's conventional locations",
2305 want.display()
2306 );
2307 assert!(
2308 !found.contains(&home.join(install)),
2309 "{} is the installation, not its package cache",
2310 home.join(install).display()
2311 );
2312 }
2313 }
2314
2315 #[test]
2316 fn the_report_is_ordered_by_what_is_worth_clearing() {
2317 let mut reports = [
2318 CacheReport {
2319 manager: "npm",
2320 kind: "cache",
2321 path: PathBuf::from("/a"),
2322 bytes: 10,
2323 clear_command: "x".to_string(),
2324 clear: Clear::Command("npm", &["cache"]),
2325 note: None,
2326 cap_gb: None,
2327 over_cap: false,
2328 dependents: None,
2329 extra_args: Vec::new(),
2330 },
2331 CacheReport {
2332 manager: "go",
2333 kind: "module cache",
2334 path: PathBuf::from("/b"),
2335 bytes: 4_000,
2336 clear_command: "y".to_string(),
2337 clear: Clear::Directory,
2338 note: None,
2339 cap_gb: None,
2340 over_cap: false,
2341 dependents: None,
2342 extra_args: Vec::new(),
2343 },
2344 ];
2345 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
2346 assert_eq!(reports[0].manager, "go");
2347 }
2348
2349 fn sized(manager: &'static str, bytes: u64, dependents: Option<usize>) -> CacheReport {
2351 CacheReport {
2352 manager,
2353 kind: "cache",
2354 path: PathBuf::from("/x"),
2355 bytes,
2356 clear_command: "x".to_string(),
2357 clear: Clear::Directory,
2358 note: None,
2359 cap_gb: None,
2360 over_cap: false,
2361 dependents,
2362 extra_args: Vec::new(),
2363 }
2364 }
2365
2366 #[test]
2367 fn the_costliest_cache_per_repository_is_not_the_biggest_one() {
2368 let reports = [
2371 sized("npm", 10_240, Some(18)),
2372 sized("pnpm", 2_048, Some(1)),
2373 sized("cargo", 300, Some(2)),
2374 sized("cargo", 100, Some(2)),
2375 sized("bun", 512, Some(0)),
2376 sized("nuget", 900, None),
2377 ];
2378 assert_eq!(
2379 costliest_per_repository(&reports),
2380 vec![("pnpm", 2_048), ("npm", 568), ("cargo", 200)],
2381 );
2382 }
2383
2384 #[test]
2385 fn every_probe_clears_with_the_command_it_prints() {
2386 for probe in PROBES {
2389 let printed = probe.clear_command;
2390 match probe.clear {
2391 Clear::Command(program, args) => {
2392 assert!(
2393 printed.starts_with(program),
2394 "{} {} prints `{printed}` but runs `{program}`",
2395 probe.manager,
2396 probe.kind
2397 );
2398 for arg in args {
2399 assert!(
2402 printed.contains(arg.trim_matches('"')),
2403 "{} {} prints `{printed}` but passes `{arg}`",
2404 probe.manager,
2405 probe.kind
2406 );
2407 }
2408 }
2409 Clear::Directory | Clear::Manual { .. } => assert!(
2413 printed.contains("rm -rf") || printed.contains("Remove-Item"),
2414 "{} {} deletes a directory but prints `{printed}`",
2415 probe.manager,
2416 probe.kind
2417 ),
2418 }
2419 }
2420 }
2421
2422 #[test]
2423 fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
2424 let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
2428 assert!(!maven.is_empty(), "maven is no longer reported at all");
2429 for probe in maven {
2430 assert!(
2431 matches!(probe.clear, Clear::Manual { .. }),
2432 "maven {} would be emptied by dev-prune",
2433 probe.kind
2434 );
2435 }
2436 }
2437
2438 #[test]
2439 fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
2440 let dir = tempfile::tempdir().unwrap();
2444 let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
2445 std::fs::write(&artifact, b"nowhere else").unwrap();
2446
2447 let outcome = clear_one(&CacheReport {
2448 manager: "maven",
2449 kind: "local repository",
2450 path: dir.path().to_path_buf(),
2451 bytes: 12,
2452 clear_command: MAVEN_REPO_CLEAR.to_string(),
2453 clear: Clear::Manual { why: MAVEN_MANUAL },
2454 note: None,
2455 cap_gb: None,
2456 over_cap: false,
2457 dependents: None,
2458 extra_args: Vec::new(),
2459 });
2460
2461 assert!(artifact.exists(), "the store was emptied after all");
2462 assert!(
2463 outcome.problem.is_some(),
2464 "it reported success without doing anything"
2465 );
2466 }
2467
2468 #[test]
2469 fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
2470 let err = run_clear("maven", false, false, true, true, false).unwrap_err();
2473 assert!(
2474 err.downcast_ref::<crate::UsageError>().is_some(),
2475 "expected a usage error, got: {err:#}"
2476 );
2477 let text = format!("{err}");
2478 assert!(
2479 text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
2480 "the refusal names neither the reason nor the command: {text}"
2481 );
2482 }
2483
2484 #[test]
2485 fn every_manager_in_the_report_can_be_named_to_clear() {
2486 let names = known_managers();
2487 for probe in PROBES {
2488 assert!(
2489 names.contains(&probe.manager),
2490 "{} is reported but `devp caches clear {}` would not find it",
2491 probe.manager,
2492 probe.manager
2493 );
2494 }
2495 let mut sorted = names.clone();
2498 sorted.sort_unstable();
2499 sorted.dedup();
2500 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
2501 }
2502
2503 #[test]
2504 fn an_unknown_manager_is_a_usage_error() {
2505 let err = run_clear("nonesuch", false, false, true, true, false).unwrap_err();
2507 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2508 }
2509
2510 #[test]
2511 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
2512 let err = run_clear("npm", false, false, false, false, true).unwrap_err();
2513 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2514 }
2515
2516 #[test]
2517 fn removing_a_directory_reports_nothing_when_it_worked() {
2518 let dir = tempfile::tempdir().unwrap();
2519 let cache = dir.path().join("cache");
2520 std::fs::create_dir(&cache).unwrap();
2521 std::fs::write(cache.join("blob"), b"x").unwrap();
2522
2523 assert!(remove_cache_dir(&cache).is_none());
2524 assert!(!cache.exists());
2525 assert!(remove_cache_dir(&cache).is_none());
2528 }
2529
2530 #[test]
2531 fn clearing_a_directory_reports_what_actually_went() {
2532 let dir = tempfile::tempdir().unwrap();
2533 let cache = dir.path().join("store");
2534 std::fs::create_dir(&cache).unwrap();
2535 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
2536 let before = adapters::dir_size(&cache);
2537
2538 let outcome = clear_one(&CacheReport {
2539 manager: "cargo",
2540 kind: "registry cache",
2541 path: cache.clone(),
2542 bytes: before,
2543 clear_command: "rm -rf".to_string(),
2544 clear: Clear::Directory,
2545 note: None,
2546 cap_gb: None,
2547 over_cap: false,
2548 dependents: None,
2549 extra_args: Vec::new(),
2550 });
2551
2552 assert!(outcome.problem.is_none());
2553 assert_eq!(outcome.after, 0);
2554 assert_eq!(outcome.freed(), before);
2557 assert!(!cache.exists());
2558 }
2559
2560 #[test]
2561 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
2562 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
2565 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
2566 }
2567
2568 fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
2570 CacheReport {
2571 manager,
2572 kind,
2573 path: PathBuf::from("/cache").join(manager).join(kind),
2574 bytes: gib * crate::constants::BYTES_PER_GIB,
2575 clear_command: "x".to_string(),
2576 clear: Clear::Directory,
2577 note: None,
2578 cap_gb: None,
2579 over_cap: false,
2580 dependents: None,
2581 extra_args: Vec::new(),
2582 }
2583 }
2584
2585 fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
2587 Dependents {
2588 repositories,
2589 by_manager: counts.iter().copied().collect(),
2590 }
2591 }
2592
2593 #[test]
2594 fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
2595 let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
2600 apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
2601
2602 assert_eq!(reports[0].dependents, Some(2));
2603 assert_eq!(
2604 reports[1].dependents, None,
2605 "pip has no adapter of its name, so there is nothing to count"
2606 );
2607 }
2608
2609 #[test]
2610 fn no_registry_leaves_every_count_unanswered() {
2611 let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
2614 apply_dependents(&mut reports, None);
2615 assert!(reports.iter().all(|r| r.dependents.is_none()));
2616 }
2617
2618 #[test]
2619 fn a_manager_nothing_uses_is_a_counted_zero() {
2620 let mut reports = vec![row("go", "module cache", 3)];
2623 apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
2624 assert_eq!(reports[0].dependents, Some(0));
2625 assert!(
2626 used_by(&reports[0], 0, None, &manager_totals(&reports))
2627 .contains("no registered repository uses go")
2628 );
2629 }
2630
2631 #[test]
2632 fn the_per_repository_share_is_the_managers_whole_footprint() {
2633 let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2637 let line = used_by(
2638 &reports[0],
2639 2,
2640 Some(&counted(2, &[("cargo", 2)])),
2641 &manager_totals(&reports),
2642 );
2643 assert!(
2644 line.contains("cargo is used by 2 of 2 registered repositories")
2645 && line.contains("6 GiB each across its 2 caches"),
2646 "{line}"
2647 );
2648 }
2649
2650 #[test]
2651 fn one_cache_does_not_say_how_many_it_summed() {
2652 let reports = vec![row("bun", "cache", 4)];
2655 let line = used_by(
2656 &reports[0],
2657 2,
2658 Some(&counted(2, &[("bun", 2)])),
2659 &manager_totals(&reports),
2660 );
2661 assert!(line.ends_with("2 GiB each"), "{line}");
2662 }
2663
2664 #[test]
2665 fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
2666 let dir = tempfile::tempdir().unwrap();
2670 let nested = dir.path().join("a").join("b");
2671 std::fs::create_dir_all(&nested).unwrap();
2672
2673 let root = volume_root(&nested).expect("a real directory sits on some filesystem");
2674 assert!(
2675 nested.starts_with(&root),
2676 "{} is not under {}",
2677 nested.display(),
2678 root.display()
2679 );
2680 assert!(root.is_dir(), "{} is not a directory", root.display());
2681 }
2682
2683 #[cfg(windows)]
2684 #[test]
2685 fn a_windows_volume_root_is_the_drive_and_nothing_more() {
2686 let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
2690 assert_eq!(root, PathBuf::from("V:\\"));
2691 assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
2692 }
2693
2694 #[cfg(windows)]
2695 #[test]
2696 fn the_drive_someone_types_and_the_drive_on_disk_are_the_same_drive() {
2697 assert!(same_volume(Path::new(r"\\?\C:\"), Path::new(r"C:\")));
2702 assert!(same_volume(Path::new(r"c:\"), Path::new(r"C:\")));
2703 assert!(!same_volume(Path::new(r"C:\"), Path::new(r"V:\")));
2704 }
2705
2706 #[cfg(windows)]
2707 #[test]
2708 fn a_bare_drive_letter_is_what_people_type_and_has_to_resolve() {
2709 for typed in ["V", "v", "V:", "v:", r"V:\", r"V:\Code\ProjectCode"] {
2713 assert_eq!(
2714 resolve_volume(typed).unwrap(),
2715 PathBuf::from(r"V:\"),
2716 "{typed}"
2717 );
2718 }
2719 assert!(resolve_volume("not-a-path-at-all").is_err());
2720 }
2721
2722 #[test]
2723 fn the_drive_you_are_standing_on_is_a_dot() {
2724 let here = std::env::current_dir().unwrap();
2729 assert_eq!(resolve_volume(".").unwrap(), volume_root(&here).unwrap());
2730 assert!(resolve_volume("definitely-not-a-drive").is_err());
2733 }
2734
2735 #[test]
2736 fn narrowing_to_one_drive_keeps_only_what_is_on_it() {
2737 let dir = tempfile::tempdir().unwrap();
2740 let here = dir.path().join("cache");
2741 std::fs::create_dir_all(&here).unwrap();
2742 let root = volume_root(&here).unwrap();
2743
2744 let mut on_this_one = sized("pnpm", 2_048, Some(1));
2745 on_this_one.path = here;
2746 let mut nowhere = sized("npm", 10_240, Some(18));
2747 nowhere.path = PathBuf::from("relative/and/unresolvable");
2748
2749 let kept = on_volume(vec![on_this_one, nowhere], &root);
2750 assert_eq!(
2751 kept.len(),
2752 1,
2753 "a row whose drive is unknown is not on this one"
2754 );
2755 assert_eq!(kept[0].manager, "pnpm");
2756 }
2757
2758 #[test]
2759 fn the_per_drive_line_adds_up_and_leads_with_the_biggest() {
2760 let dir = tempfile::tempdir().unwrap();
2764 let here = dir.path().join("cache");
2765 std::fs::create_dir_all(&here).unwrap();
2766
2767 let mut small = sized("pnpm", 100, None);
2768 small.path = here.clone();
2769 let mut large = sized("npm", 900, None);
2770 large.path = here;
2771 let mut unknown = sized("uv", 500, None);
2772 unknown.path = PathBuf::from("relative/and/unresolvable");
2773
2774 let totals = volume_totals(&[small, large, unknown]);
2775 assert_eq!(totals.len(), 1);
2776 assert_eq!(totals[0].1, 1_000);
2777 }
2778
2779 #[test]
2780 fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
2781 let dir = tempfile::tempdir().unwrap();
2784 let a = dir.path().join("one");
2785 let b = dir.path().join("two");
2786 std::fs::create_dir_all(&a).unwrap();
2787 std::fs::create_dir_all(&b).unwrap();
2788
2789 assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
2790 assert!(volume_roots(&[]).is_empty());
2791 }
2792
2793 #[test]
2794 fn a_volume_stores_printed_command_is_the_one_that_runs() {
2795 let dir = tempfile::tempdir().unwrap();
2800 let store = dir.path().join(".pnpm-store");
2801 std::fs::create_dir_all(&store).unwrap();
2802
2803 let report = volume_store_report(store.clone());
2804 let named = output::clean_path(&store);
2805 assert_eq!(
2806 report.extra_args,
2807 vec!["--store-dir".to_string(), named.clone()]
2808 );
2809 assert!(
2810 report.clear_command.contains(&named),
2811 "the printed command does not name the store: {}",
2812 report.clear_command
2813 );
2814 assert!(matches!(
2815 report.clear,
2816 Clear::Command("pnpm", ["store", "prune"])
2817 ));
2818 }
2819
2820 #[test]
2821 fn only_a_path_with_a_space_in_it_is_quoted() {
2822 assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
2826 assert_eq!(
2827 shell_arg("/mnt/my data/.pnpm-store"),
2828 "\"/mnt/my data/.pnpm-store\""
2829 );
2830 }
2831
2832 #[test]
2833 fn a_cap_is_measured_against_the_managers_whole_footprint() {
2834 let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2838 apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
2839 assert!(
2840 reports.iter().all(|r| r.over_cap),
2841 "both rows belong to the manager that went over"
2842 );
2843 assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
2844 }
2845
2846 #[test]
2847 fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
2848 let mut reports = vec![row("npm", "cache", 3)];
2849 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2850 assert_eq!(reports[0].cap_gb, Some(10));
2853 assert!(!reports[0].over_cap);
2854 }
2855
2856 #[test]
2857 fn the_default_cap_covers_a_manager_nobody_named() {
2858 let caps = BTreeMap::from([(constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10)]);
2859 let mut reports = vec![row("uv", "cache", 40), row("npm", "cache", 3)];
2860 apply_caps(&mut reports, &caps);
2861 assert!(reports[0].over_cap);
2862 assert_eq!(reports[1].cap_gb, Some(10));
2863 assert!(!reports[1].over_cap);
2864 }
2865
2866 #[test]
2867 fn a_manager_named_outright_is_not_also_held_to_the_default() {
2868 let caps = BTreeMap::from([
2872 (constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10),
2873 ("npm".to_string(), 4),
2874 ]);
2875 let mut reports = vec![row("npm", "cache", 5)];
2876 apply_caps(&mut reports, &caps);
2877 assert_eq!(reports[0].cap_gb, Some(4));
2878 assert!(reports[0].over_cap);
2879 }
2880
2881 #[test]
2882 fn a_manager_with_no_cap_is_never_called_too_big() {
2883 let mut reports = vec![row("uv", "cache", 40)];
2886 apply_caps(&mut reports, &BTreeMap::new());
2887 assert_eq!(reports[0].cap_gb, None);
2888 assert!(!reports[0].over_cap);
2889 }
2890
2891 #[test]
2892 fn one_managers_cap_says_nothing_about_another() {
2893 let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
2894 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2895 assert!(reports[0].over_cap);
2896 assert!(
2897 !reports[1].over_cap,
2898 "go has no cap and did not acquire npm's"
2899 );
2900 }
2901
2902 #[test]
2903 fn exactly_at_the_cap_is_not_over_it() {
2904 let mut reports = vec![row("pnpm", "store", 10)];
2907 apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
2908 assert!(!reports[0].over_cap);
2909 }
2910
2911 #[test]
2912 fn every_cache_manager_answers_to_its_own_name() {
2913 for probe in PROBES {
2916 assert!(
2917 is_cache_manager(probe.manager),
2918 "{} is reported but cannot be capped",
2919 probe.manager
2920 );
2921 }
2922 assert!(!is_cache_manager("dev-prune-no-such-manager"));
2923 }
2924}