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>,
95 pub extra_args: Vec<String>,
103}
104
105#[derive(Clone, Copy)]
107pub enum Clear {
108 Command(&'static str, &'static [&'static str]),
112 Directory,
115 Manual { why: &'static str },
119}
120
121struct Probe {
123 manager: &'static str,
124 kind: &'static str,
125 query: Option<(&'static str, &'static [&'static str])>,
131 clear_command: &'static str,
132 clear: Clear,
133 note: Option<&'static str>,
134}
135
136#[cfg(windows)]
139const CARGO_CACHE_CLEAR: &str =
140 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
141#[cfg(not(windows))]
142const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
143
144#[cfg(windows)]
145const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
146#[cfg(not(windows))]
147const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
148
149#[cfg(windows)]
154const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
155#[cfg(not(windows))]
156const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
157
158const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
178 download cache: `mvn install` and `install:install-file` write artifacts there \
179 that exist nowhere else, and nothing in the tree tells them apart from the \
180 downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
181 the command; running it is yours to decide.";
182
183#[cfg(windows)]
184const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
185#[cfg(not(windows))]
186const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
187
188#[cfg(windows)]
189const GRADLE_DISTS_CLEAR: &str =
190 r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
191#[cfg(not(windows))]
192const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
193
194#[cfg(windows)]
195const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
196#[cfg(not(windows))]
197const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
198
199#[cfg(windows)]
202const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
203#[cfg(not(windows))]
204const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
205
206const PROBES: &[Probe] = &[
207 Probe {
208 manager: "npm",
209 kind: "cache",
210 query: Some(("npm", &["config", "get", "cache"])),
211 clear_command: "npm cache clean --force",
212 clear: Clear::Command("npm", &["cache", "clean", "--force"]),
213 note: None,
214 },
215 Probe {
216 manager: "pnpm",
217 kind: "store",
218 query: Some(("pnpm", &["store", "path"])),
219 clear_command: "pnpm store prune",
220 clear: Clear::Command("pnpm", &["store", "prune"]),
221 note: Some(
222 "hardlinked into every node_modules it filled; emptying it is what makes the \
223 next pnpm install a download",
224 ),
225 },
226 Probe {
227 manager: "yarn",
228 kind: "cache",
229 query: Some(("yarn", &["cache", "dir"])),
230 clear_command: "yarn cache clean",
231 clear: Clear::Command("yarn", &["cache", "clean"]),
232 note: None,
233 },
234 Probe {
235 manager: "bun",
236 kind: "cache",
237 query: Some(("bun", &["pm", "cache"])),
238 clear_command: "bun pm cache rm",
239 clear: Clear::Command("bun", &["pm", "cache", "rm"]),
240 note: None,
241 },
242 Probe {
243 manager: "uv",
244 kind: "cache",
245 query: Some(("uv", &["cache", "dir"])),
246 clear_command: "uv cache prune",
249 clear: Clear::Command("uv", &["cache", "prune"]),
250 note: None,
251 },
252 Probe {
253 manager: "pip",
254 kind: "cache",
255 query: Some(("pip", &["cache", "dir"])),
256 clear_command: "pip cache purge",
257 clear: Clear::Command("pip", &["cache", "purge"]),
258 note: None,
259 },
260 Probe {
266 manager: "conda",
267 kind: "package cache",
268 query: None,
269 clear_command: "conda clean --packages --tarballs --yes",
270 clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
271 note: Some(
272 "unpacked packages and downloaded archives; conda keeps what its \
273 environments use, except any it linked by symlink rather than hardlink",
274 ),
275 },
276 Probe {
277 manager: "cargo",
278 kind: "registry cache",
279 query: None,
280 clear_command: CARGO_CACHE_CLEAR,
281 clear: Clear::Directory,
282 note: Some("the downloaded .crate archives; clearing them means downloading again"),
283 },
284 Probe {
285 manager: "cargo",
286 kind: "registry sources",
287 query: None,
288 clear_command: CARGO_SRC_CLEAR,
289 clear: Clear::Directory,
290 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
291 },
292 Probe {
293 manager: "go",
294 kind: "module cache",
295 query: Some(("go", &["env", "GOMODCACHE"])),
296 clear_command: "go clean -modcache",
297 clear: Clear::Command("go", &["clean", "-modcache"]),
298 note: None,
299 },
300 Probe {
301 manager: "go",
302 kind: "build cache",
303 query: Some(("go", &["env", "GOCACHE"])),
304 clear_command: "go clean -cache",
305 clear: Clear::Command("go", &["clean", "-cache"]),
306 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
307 },
308 Probe {
313 manager: "maven",
314 kind: "local repository",
315 query: None,
316 clear_command: MAVEN_REPO_CLEAR,
317 clear: Clear::Manual { why: MAVEN_MANUAL },
318 note: Some(
319 "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
320 ),
321 },
322 Probe {
323 manager: "gradle",
324 kind: "caches",
325 query: None,
326 clear_command: GRADLE_CACHE_CLEAR,
327 clear: Clear::Directory,
328 note: Some(
329 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
330 ),
331 },
332 Probe {
333 manager: "gradle",
334 kind: "wrapper distributions",
335 query: None,
336 clear_command: GRADLE_DISTS_CLEAR,
337 clear: Clear::Directory,
338 note: Some(
339 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
340 ),
341 },
342 Probe {
346 manager: "nuget",
347 kind: "global packages",
348 query: None,
349 clear_command: "dotnet nuget locals global-packages --clear",
350 clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
351 note: Some(
352 "every .NET project on the machine restores from here; re-downloaded on the next restore",
353 ),
354 },
355 Probe {
356 manager: "vcpkg",
357 kind: "binary cache",
358 query: None,
359 clear_command: VCPKG_ARCHIVES_CLEAR,
360 clear: Clear::Directory,
361 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
362 },
363 Probe {
364 manager: "conan",
365 kind: "package cache",
366 query: None,
367 clear_command: "conan remove \"*\" --confirm",
368 clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
369 note: Some(
370 "recipes and binaries shared by every Conan project; re-fetched on the next install",
371 ),
372 },
373 Probe {
378 manager: "composer",
379 kind: "cache",
380 query: Some(("composer", &["config", "--global", "cache-dir"])),
381 clear_command: "composer clear-cache",
382 clear: Clear::Command("composer", &["clear-cache"]),
383 note: Some(
384 "downloaded package archives and repository metadata; re-fetched by the next composer install",
385 ),
386 },
387 Probe {
392 manager: "cocoapods",
393 kind: "cache",
394 query: None,
395 clear_command: "pod cache clean --all",
396 clear: Clear::Command("pod", &["cache", "clean", "--all"]),
397 note: Some("downloaded pod sources, re-fetched by the next pod install"),
398 },
399 Probe {
400 manager: "hex",
401 kind: "package cache",
402 query: None,
403 clear_command: HEX_CACHE_CLEAR,
404 clear: Clear::Directory,
405 note: Some(
406 "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
407 ),
408 },
409];
410
411pub fn run(json_output: bool) -> Result<()> {
413 let reg = registered();
414 let mut reports = collect(!json_output, reg.as_ref());
415 apply_caps(&mut reports, &caps());
416 let deps = reg.as_ref().map(|r| dependents(r, !json_output));
417 apply_dependents(&mut reports, deps.as_ref());
418
419 let engines = container_summary(!json_output);
424
425 if json_output {
426 return json::emit(&json::caches_document(
427 &reports,
428 deps.as_ref().map(|d| d.repositories),
429 &engines,
430 ));
431 }
432
433 print_report(&reports, deps.as_ref());
434 crate::commands::containers::print_summary(&engines);
435 Ok(())
436}
437
438fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
440 let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
441 let engines = crate::commands::containers::collect(None);
442 if let Some(pb) = pb {
443 pb.finish_and_clear();
444 }
445 engines
446}
447
448fn caps() -> BTreeMap<String, u64> {
453 crate::config::Registry::load()
454 .map(|r| r.settings.cache_max_gb)
455 .unwrap_or_default()
456}
457
458fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
463 let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
464 for r in reports.iter() {
465 *totals.entry(r.manager).or_default() += r.bytes;
466 }
467 for r in reports.iter_mut() {
468 let Some(&gb) = caps.get(r.manager) else {
469 continue;
470 };
471 r.cap_gb = Some(gb);
472 r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
473 > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
474 }
475}
476
477struct Registered {
483 paths: Vec<PathBuf>,
485 scan_depth: usize,
487}
488
489fn registered() -> Option<Registered> {
497 let registry = crate::config::Registry::load().ok()?;
498 let paths: Vec<PathBuf> = registry
499 .repositories
500 .keys()
501 .filter(|p| p.exists())
502 .cloned()
503 .collect();
504 if paths.is_empty() {
505 return None;
506 }
507 Some(Registered {
508 paths,
509 scan_depth: registry.settings.scan_depth,
510 })
511}
512
513struct Dependents {
521 repositories: usize,
524 by_manager: BTreeMap<&'static str, usize>,
530}
531
532fn dependents(reg: &Registered, spinner: bool) -> Dependents {
538 let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
539
540 let mut by_manager: BTreeMap<&'static str, usize> = PROBES
546 .iter()
547 .map(|p| p.manager)
548 .filter(|m| adapters::is_adapter_name(m))
549 .map(|m| (m, 0))
550 .collect();
551
552 for path in ®.paths {
553 let depth = crate::workspace::clamp_depth(
558 crate::config::PerRepoConfig::load_with_diagnostics(path)
559 .ok()
560 .flatten()
561 .and_then(|c| c.scan_depth)
562 .unwrap_or(reg.scan_depth),
563 );
564 let mut here: HashSet<&'static str> = HashSet::new();
565 for project in crate::workspace::discover_all_to_depth(path, depth) {
566 for adapter in &project.adapters {
567 here.insert(adapter.name());
568 }
569 }
570 for (manager, count) in by_manager.iter_mut() {
571 if here.contains(manager) {
572 *count += 1;
573 }
574 }
575 }
576
577 if let Some(pb) = pb {
578 pb.finish_and_clear();
579 }
580
581 Dependents {
582 repositories: reg.paths.len(),
583 by_manager,
584 }
585}
586
587fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
589 let Some(deps) = deps else {
590 return;
591 };
592 for r in reports.iter_mut() {
593 r.dependents = deps.by_manager.get(r.manager).copied();
594 }
595}
596
597fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, u64> {
602 let mut totals: BTreeMap<&'static str, u64> = BTreeMap::new();
603 for r in reports {
604 *totals.entry(r.manager).or_default() += r.bytes;
605 }
606 totals
607}
608
609fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
611 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
612 let from = query_dir();
613
614 let mut seen: HashSet<PathBuf> = HashSet::new();
615 let mut reports = Vec::new();
616
617 for probe in PROBES {
618 let Some(path) = locate(probe, &from) else {
619 continue;
620 };
621 let path = path.canonicalize().unwrap_or(path);
628 if !seen.insert(path.clone()) {
629 continue;
630 }
631 reports.push(CacheReport {
632 manager: probe.manager,
633 kind: probe.kind,
634 bytes: adapters::dir_size(&path),
635 path,
636 clear_command: probe.clear_command.to_string(),
637 clear: probe.clear,
638 note: probe.note,
639 cap_gb: None,
640 over_cap: false,
641 dependents: None,
642 extra_args: Vec::new(),
643 });
644 }
645
646 for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
649 if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
650 continue;
651 }
652 reports.push(volume_store_report(store));
653 }
654
655 if let Some(pb) = pb {
656 pb.finish_and_clear();
657 }
658
659 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
660 reports
661}
662
663const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
665 this is the store for the projects on this volume";
666
667fn volume_store_report(store: PathBuf) -> CacheReport {
673 let named = output::clean_path(&store);
674 CacheReport {
675 manager: "pnpm",
676 kind: "store",
677 bytes: adapters::dir_size(&store),
678 clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
679 extra_args: vec!["--store-dir".to_string(), named],
680 path: store,
681 clear: Clear::Command("pnpm", &["store", "prune"]),
682 note: Some(PNPM_VOLUME_NOTE),
683 cap_gb: None,
684 over_cap: false,
685 dependents: None,
686 }
687}
688
689fn shell_arg(named: &str) -> String {
694 if named.contains(' ') {
695 format!("\"{named}\"")
696 } else {
697 named.to_string()
698 }
699}
700
701fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
716 let mut roots = volume_roots(repos);
720 if let Ok(here) = std::env::current_dir()
721 && let Some(root) = volume_root(&here)
722 && !roots.contains(&root)
723 {
724 roots.push(root);
725 }
726 roots
727 .into_iter()
728 .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
729 .filter(|store| store.is_dir())
730 .collect()
731}
732
733fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
735 let mut roots: Vec<PathBuf> = Vec::new();
736 for repo in repos {
737 if let Some(root) = volume_root(repo)
738 && !roots.contains(&root)
739 {
740 roots.push(root);
741 }
742 }
743 roots
744}
745
746#[cfg(unix)]
753fn volume_root(path: &Path) -> Option<PathBuf> {
754 use std::os::unix::fs::MetadataExt;
755
756 let dev = std::fs::metadata(path).ok()?.dev();
757 let mut root = path.to_path_buf();
758 for ancestor in path.ancestors().skip(1) {
759 match std::fs::metadata(ancestor) {
760 Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
761 _ => break,
762 }
763 }
764 Some(root)
765}
766
767#[cfg(windows)]
774fn volume_root(path: &Path) -> Option<PathBuf> {
775 use std::path::Component;
776
777 let mut components = path.components();
778 let Some(Component::Prefix(prefix)) = components.next() else {
779 return None;
780 };
781 if components.next() != Some(Component::RootDir) {
782 return None;
783 }
784 let mut root = PathBuf::from(prefix.as_os_str());
785 root.push(Component::RootDir.as_os_str());
786 Some(root)
787}
788
789fn query_dir() -> PathBuf {
796 dirs::home_dir()
797 .or_else(|| std::env::current_dir().ok())
798 .unwrap_or_else(|| PathBuf::from("."))
799}
800
801fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
803 if let Some((program, args)) = probe.query
804 && adapters::binary_available(program)
805 {
806 let answered = adapters::capture_command_with_timeout(
807 program,
808 args,
809 from,
810 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
811 )
812 .ok()
813 .and_then(|raw| path_from_output(&raw))
814 .filter(|p| p.is_dir());
815 if answered.is_some() {
816 return answered;
817 }
818 }
819
820 fallbacks(probe.manager, probe.kind)
825 .into_iter()
826 .find(|p| p.is_dir())
827}
828
829fn path_from_output(raw: &str) -> Option<PathBuf> {
834 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
835 let line = line.trim_matches('"');
836 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
839 return None;
840 }
841 Some(PathBuf::from(line))
842}
843
844fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
846 let home = dirs::home_dir();
847 let local = dirs::data_local_dir();
848 let cache = dirs::cache_dir();
849 let under = |base: &Option<PathBuf>, rel: &str| {
853 base.as_ref()
854 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
855 };
856
857 let candidates = match (manager, kind) {
858 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
861 ("pnpm", _) => vec![
862 under(&local, "pnpm/store"),
863 under(&home, ".local/share/pnpm/store"),
864 under(&home, "Library/pnpm/store"),
865 under(&home, ".pnpm-store"),
866 ],
867 ("yarn", _) => vec![
868 under(&home, ".yarn/berry/cache"),
869 under(&local, "Yarn/Cache"),
870 under(&cache, "yarn"),
871 ],
872 ("bun", _) => vec![under(&home, ".bun/install/cache")],
873 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
874 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
875 ("conda", _) => vec![
883 std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
884 std::env::var_os("CONDA_EXE")
885 .map(PathBuf::from)
886 .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
887 .map(|root| root.join("pkgs")),
888 under(&home, "miniconda3/pkgs"),
889 under(&home, "anaconda3/pkgs"),
890 under(&home, "miniforge3/pkgs"),
891 under(&home, "mambaforge/pkgs"),
892 under(&home, ".conda/pkgs"),
893 ],
894 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
895 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
896 ("go", "module cache") => vec![
897 std::env::var_os("GOMODCACHE").map(PathBuf::from),
898 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
899 under(&home, "go/pkg/mod"),
900 ],
901 ("go", _) => vec![
902 std::env::var_os("GOCACHE").map(PathBuf::from),
903 under(&cache, "go-build"),
904 under(&local, "go-build"),
905 ],
906 ("maven", _) => vec![under(&home, ".m2/repository")],
907 ("gradle", "caches") => vec![
909 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
910 under(&home, ".gradle/caches"),
911 ],
912 ("gradle", _) => vec![
913 std::env::var_os("GRADLE_USER_HOME")
914 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
915 under(&home, ".gradle/wrapper/dists"),
916 ],
917 ("nuget", _) => vec![
918 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
919 under(&home, ".nuget/packages"),
920 ],
921 ("vcpkg", _) => vec![
922 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
923 under(&local, "vcpkg/archives"),
924 under(&cache, "vcpkg/archives"),
925 ],
926 ("conan", _) => vec![
929 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
930 under(&home, ".conan2/p"),
931 ],
932 ("composer", _) => vec![
935 std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
936 std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
937 under(&local, "Composer"),
938 under(&cache, "composer"),
939 under(&home, ".composer/cache"),
940 ],
941 ("cocoapods", _) => vec![
945 std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
946 under(&home, "Library/Caches/CocoaPods"),
947 ],
948 ("hex", _) => vec![
951 std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
952 under(&home, ".hex/packages"),
953 under(&cache, "hex/packages"),
954 ],
955 _ => vec![],
956 };
957
958 candidates.into_iter().flatten().collect()
959}
960
961fn cargo_home() -> PathBuf {
963 std::env::var_os("CARGO_HOME")
964 .map(PathBuf::from)
965 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
966 .unwrap_or_else(|| PathBuf::from(".cargo"))
967}
968
969fn print_report(reports: &[CacheReport], deps: Option<&Dependents>) {
970 output::print_header(i18n::t("caches.header"));
971
972 if reports.is_empty() {
973 println!();
974 output::print_info(i18n::t("caches.nothing"));
975 return;
976 }
977
978 println!();
979 let totals = manager_totals(reports);
980 let mut counted: HashSet<&'static str> = HashSet::new();
983 for r in reports {
984 let label = format!("{} {}", r.manager, r.kind);
985 println!(
986 " {:<30} {:>10} {}",
987 label,
988 output::format_bytes(r.bytes),
989 output::clean_path(&r.path)
990 );
991 println!(" {:<30} {:>10} clear: {}", "", "", r.clear_command);
992 if let Some(note) = r.note {
993 println!(" {:<30} {:>10} {}", "", "", note);
994 }
995 if r.over_cap
996 && let Some(gb) = r.cap_gb
997 {
998 println!(
999 " {:<30} {:>10} over the {gb} GiB cap you set for {}",
1000 "", "", r.manager
1001 );
1002 }
1003 if let Some(n) = r.dependents
1004 && counted.insert(r.manager)
1005 {
1006 println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
1007 }
1008 println!();
1009 }
1010
1011 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1012 println!(
1013 " {:<30} {:>10} across {} {}",
1014 "Total",
1015 output::format_bytes(total),
1016 reports.len(),
1017 output::plural(reports.len(), "cache", "caches")
1018 );
1019
1020 let ranked = costliest_per_repository(reports);
1024 if ranked.len() > 1 {
1025 let named = ranked
1026 .iter()
1027 .take(3)
1028 .map(|(m, b)| format!("{m} {}", output::format_bytes_weighted(*b)))
1029 .collect::<Vec<_>>()
1030 .join(" · ");
1031 println!(" {:<30} {:>10} {named}", "Costliest per repository", "");
1032 }
1033
1034 if reports.iter().any(|r| r.over_cap) {
1035 println!();
1036 output::print_info(
1037 "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1038 --over-cap all` empties exactly those and leaves the rest alone.",
1039 );
1040 }
1041
1042 if reports.iter().any(|r| r.dependents == Some(0)) {
1043 println!();
1044 output::print_info(
1045 "The caches above that no registered repository uses were filled for projects that \
1046 are not here any more. `devp caches clear --unused all` empties exactly those. It \
1047 counts only repositories dev-prune knows about, so `devp link` anything you keep \
1048 outside the registry before trusting the number.",
1049 );
1050 }
1051
1052 println!();
1053 output::print_info(
1054 "Nothing above was deleted. A cache is shared by every project on the machine, so \
1055 no single repository's lockfile can prove it is recoverable — and it is what \
1056 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1057 will ever touch one. When you want the space more than the speed, run a clear \
1058 command yourself, or `devp caches clear <manager>`.",
1059 );
1060}
1061
1062fn used_by(
1076 r: &CacheReport,
1077 dependents: usize,
1078 deps: Option<&Dependents>,
1079 totals: &BTreeMap<&'static str, u64>,
1080) -> String {
1081 if dependents == 0 {
1082 return format!("no registered repository uses {}", r.manager)
1083 .bold()
1084 .to_string();
1085 }
1086 let registered = deps.map_or(dependents, |d| d.repositories);
1087 let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1088 format!(
1093 "{} is used by {dependents} of {registered} registered {} · {} each",
1094 r.manager,
1095 output::plural(registered, "repository", "repositories"),
1096 output::format_bytes(total / dependents as u64)
1097 )
1098 .bold()
1099 .to_string()
1100}
1101
1102fn costliest_per_repository(reports: &[CacheReport]) -> Vec<(&'static str, u64)> {
1109 let totals = manager_totals(reports);
1110 let mut per: BTreeMap<&'static str, u64> = BTreeMap::new();
1113 for r in reports {
1114 if let Some(n) = r.dependents.filter(|n| *n > 0) {
1115 let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1116 per.insert(r.manager, total / n as u64);
1117 }
1118 }
1119 let mut ranked: Vec<(&'static str, u64)> = per.into_iter().collect();
1120 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
1122 ranked
1123}
1124
1125pub struct ClearOutcome {
1127 pub manager: &'static str,
1129 pub kind: &'static str,
1131 pub path: PathBuf,
1133 pub before: u64,
1135 pub after: u64,
1139 pub problem: Option<String>,
1141}
1142
1143impl ClearOutcome {
1144 pub fn freed(&self) -> u64 {
1146 self.before.saturating_sub(self.after)
1147 }
1148}
1149
1150pub fn run_clear(
1157 target: &str,
1158 over_cap: bool,
1159 unused: bool,
1160 yes: bool,
1161 dry_run: bool,
1162 json_output: bool,
1163) -> Result<()> {
1164 let all = target.eq_ignore_ascii_case("all");
1165 if !all && crate::commands::containers::is_engine(target) {
1170 return Err(anyhow::Error::new(crate::UsageError(format!(
1171 "dev-prune reports {target}'s disk use and never deletes it — an image has no \
1172 lockfile to prove it can be rebuilt, and a volume cannot be rebuilt at all. \
1173 `devp caches {target}` shows what it is holding and prints the prune commands \
1174 for you to run."
1175 ))));
1176 }
1177 if !all
1178 && !PROBES
1179 .iter()
1180 .any(|p| p.manager.eq_ignore_ascii_case(target))
1181 {
1182 return Err(anyhow::Error::new(crate::UsageError(format!(
1183 "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1184 known_managers().join(", ")
1185 ))));
1186 }
1187 if !all
1192 && let Some(probe) = manual_only(target)
1193 && let Clear::Manual { why } = probe.clear
1194 {
1195 return Err(anyhow::Error::new(crate::UsageError(format!(
1196 "{why} The command is: {}",
1197 probe.clear_command
1198 ))));
1199 }
1200
1201 if json_output && !yes && !dry_run {
1204 return Err(anyhow::Error::new(crate::UsageError(
1205 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1206 to see what would go."
1207 .to_string(),
1208 )));
1209 }
1210
1211 let reg = registered();
1215 let mut measured = collect(!json_output, reg.as_ref());
1216 apply_caps(&mut measured, &caps());
1217
1218 let deps = if unused {
1222 let Some(reg) = reg.as_ref() else {
1223 return Err(anyhow::Error::new(crate::UsageError(
1224 "`--unused` empties the caches no registered repository needs, and there are no \
1225 registered repositories on this disk to check against — every cache would look \
1226 unused. Register what you keep with `devp link` first."
1227 .to_string(),
1228 )));
1229 };
1230 Some(dependents(reg, !json_output))
1231 } else {
1232 None
1233 };
1234 apply_dependents(&mut measured, deps.as_ref());
1235
1236 let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1240 .into_iter()
1241 .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
1242 .filter(|r| !over_cap || r.over_cap)
1243 .filter(|r| !unused || r.dependents == Some(0))
1244 .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
1245
1246 if reports.is_empty() {
1247 if json_output {
1248 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1249 }
1250 if unused {
1251 output::print_info(
1252 "Every cache on this machine is used by at least one registered repository, or \
1253 is one dev-prune cannot attribute to any — nothing to clear.",
1254 );
1255 return Ok(());
1256 }
1257 if over_cap {
1258 output::print_info(if caps().is_empty() {
1262 "No cache size caps are set, so nothing is over one. Set them with `devp config \
1263 set cache_max_gb npm=10,uv=10`, or in `devp config wizard`."
1264 } else {
1265 "Every capped cache is under its cap — nothing to clear."
1266 });
1267 return Ok(());
1268 }
1269 output::print_info(&format!(
1270 "No {} cache on this machine — nothing to clear.",
1271 if all { "package manager" } else { target }
1272 ));
1273 return Ok(());
1274 }
1275
1276 if dry_run {
1277 if json_output {
1278 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1279 }
1280 print_kept(&kept);
1281 print_clear_plan(&reports, true);
1282 return Ok(());
1283 }
1284
1285 if !json_output {
1286 print_kept(&kept);
1287 print_clear_plan(&reports, false);
1288 if !confirm_clear(yes) {
1289 output::print_info("Nothing was cleared.");
1290 return Ok(());
1291 }
1292 }
1293
1294 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
1295 record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
1299
1300 if json_output {
1301 json::emit(&json::caches_clear_document(&outcomes, &kept))?;
1302 } else {
1303 print_clear_result(&outcomes);
1304 }
1305
1306 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
1309 if failed > 0 {
1310 anyhow::bail!(
1311 "{failed} {} could not be cleared.",
1312 output::plural(failed, "cache", "caches")
1313 );
1314 }
1315 Ok(())
1316}
1317
1318fn manual_only(target: &str) -> Option<&'static Probe> {
1323 let matching: Vec<&Probe> = PROBES
1324 .iter()
1325 .filter(|p| p.manager.eq_ignore_ascii_case(target))
1326 .collect();
1327 if matching.is_empty()
1328 || matching
1329 .iter()
1330 .any(|p| !matches!(p.clear, Clear::Manual { .. }))
1331 {
1332 return None;
1333 }
1334 matching.first().copied()
1335}
1336
1337pub fn is_cache_manager(name: &str) -> bool {
1339 PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
1340}
1341
1342pub fn known_managers() -> Vec<&'static str> {
1344 let mut names: Vec<&'static str> = Vec::new();
1345 for probe in PROBES {
1346 if !names.contains(&probe.manager) {
1347 names.push(probe.manager);
1348 }
1349 }
1350 names
1351}
1352
1353fn clear_one(report: &CacheReport) -> ClearOutcome {
1355 let problem = match report.clear {
1356 Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
1357 Clear::Directory => remove_cache_dir(&report.path),
1358 Clear::Manual { why } => Some(why.to_string()),
1362 };
1363 ClearOutcome {
1364 manager: report.manager,
1365 kind: report.kind,
1366 path: report.path.clone(),
1367 before: report.bytes,
1368 after: adapters::dir_size(&report.path),
1371 problem,
1372 }
1373}
1374
1375fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
1377 if !adapters::binary_available(program) {
1378 return Some(format!(
1379 "`{program}` is not on PATH — only it knows what in this cache is still \
1380 referenced, so dev-prune will not delete the directory in its place."
1381 ));
1382 }
1383 let mut all: Vec<&str> = args.to_vec();
1386 all.extend(extra.iter().map(String::as_str));
1387 adapters::run_command_with_timeout(
1388 program,
1389 &all,
1390 &query_dir(),
1391 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
1392 )
1393 .err()
1394 .map(|e| format!("{e:#}"))
1395}
1396
1397fn remove_cache_dir(path: &Path) -> Option<String> {
1399 std::fs::remove_dir_all(path)
1403 .or_else(|_| {
1404 std::thread::sleep(std::time::Duration::from_millis(250));
1405 std::fs::remove_dir_all(path)
1406 })
1407 .err()
1408 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
1410 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
1411}
1412
1413fn print_kept(kept: &[CacheReport]) {
1415 for r in kept {
1416 let Clear::Manual { why } = r.clear else {
1417 continue;
1418 };
1419 println!();
1420 output::print_info(&format!(
1421 "Keeping {} {} ({} at {}). {why}",
1422 r.manager,
1423 r.kind,
1424 output::format_bytes(r.bytes),
1425 output::clean_path(&r.path)
1426 ));
1427 }
1428}
1429
1430fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
1432 output::print_header(if dry_run {
1433 i18n::t("caches.header.would_clear")
1434 } else {
1435 i18n::t("caches.header.about_to_clear")
1436 });
1437
1438 println!();
1439 for r in reports {
1440 println!(
1441 " {:<30} {:>10} {}",
1442 format!("{} {}", r.manager, r.kind),
1443 output::format_bytes(r.bytes),
1444 output::clean_path(&r.path)
1445 );
1446 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
1447 }
1448
1449 println!();
1450 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1451 println!(
1452 " {:<30} {:>10} across {} {}",
1453 "Total",
1454 output::format_bytes(total),
1455 reports.len(),
1456 output::plural(reports.len(), "cache", "caches")
1457 );
1458
1459 println!();
1460 output::print_info(
1461 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
1462 The cost is time: the next install, and the next `devp restore`, in every \
1463 project on this machine.",
1464 );
1465}
1466
1467fn record_cache_clear(bytes: u64) {
1474 if bytes == 0 {
1475 return;
1476 }
1477 if let Ok(mut registry) = crate::config::Registry::load() {
1478 registry.record_cache_clear(bytes);
1479 let _ = registry.save();
1480 }
1481}
1482
1483fn print_clear_result(outcomes: &[ClearOutcome]) {
1485 println!();
1486 for o in outcomes {
1487 let label = format!("{} {}", o.manager, o.kind);
1488 println!(
1489 " {:<30} {:>10} {}",
1490 label,
1491 output::format_bytes(o.freed()),
1492 if o.problem.is_some() {
1493 "not cleared"
1494 } else {
1495 "cleared"
1496 }
1497 );
1498 if let Some(why) = &o.problem {
1499 println!(" {:<30} {:>10} {why}", "", "");
1500 }
1501 }
1502
1503 println!();
1504 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
1505 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
1506}
1507
1508fn confirm_clear(yes: bool) -> bool {
1511 use std::io::{IsTerminal, Write};
1512 if yes {
1513 return true;
1514 }
1515 if !std::io::stdin().is_terminal() {
1516 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
1517 return false;
1518 }
1519 eprint!("Clear them? [y/N]: ");
1523 if std::io::stderr().flush().is_err() {
1524 return false;
1525 }
1526 let mut input = String::new();
1527 if std::io::stdin().read_line(&mut input).is_err() {
1528 return false;
1529 }
1530 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
1531}
1532
1533#[cfg(test)]
1534mod tests {
1535 use super::*;
1536
1537 #[test]
1538 fn every_probe_can_be_found_without_its_manager_installed() {
1539 for probe in PROBES {
1542 assert!(
1543 !fallbacks(probe.manager, probe.kind).is_empty(),
1544 "{} {} has no conventional location",
1545 probe.manager,
1546 probe.kind
1547 );
1548 }
1549 }
1550
1551 #[test]
1552 fn every_probe_names_the_command_that_clears_it() {
1553 for probe in PROBES {
1554 assert!(
1555 !probe.clear_command.trim().is_empty(),
1556 "{} {} reports a size with no way to act on it",
1557 probe.manager,
1558 probe.kind
1559 );
1560 }
1561 }
1562
1563 #[test]
1564 fn only_five_probed_managers_have_no_adapter_of_the_same_name() {
1565 let orphans: Vec<&str> = PROBES
1570 .iter()
1571 .map(|p| p.manager)
1572 .filter(|m| !adapters::is_adapter_name(m))
1573 .collect::<std::collections::BTreeSet<_>>()
1574 .into_iter()
1575 .collect();
1576 assert_eq!(orphans, ["conan", "conda", "hex", "nuget", "pip"]);
1577 }
1578
1579 #[test]
1580 fn no_two_probes_describe_the_same_cache() {
1581 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
1582 let count = keys.len();
1583 keys.sort_unstable();
1584 keys.dedup();
1585 assert_eq!(keys.len(), count, "two probes share a manager and kind");
1586 }
1587
1588 #[test]
1589 fn a_managers_answer_is_read_off_the_last_line() {
1590 let raw = if cfg!(windows) {
1592 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
1593 } else {
1594 "npm warn config global deprecated\n/home/dev/.npm\n"
1595 };
1596 assert!(path_from_output(raw).is_some());
1597 }
1598
1599 #[test]
1600 fn quoted_paths_lose_their_quotes() {
1601 let raw = if cfg!(windows) {
1602 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
1603 } else {
1604 "\"/opt/go path/pkg/mod\"\n"
1605 };
1606 let path = path_from_output(raw).expect("a quoted path is still a path");
1607 assert!(!path.to_string_lossy().contains('"'));
1608 }
1609
1610 #[test]
1611 fn a_non_answer_is_not_mistaken_for_a_path() {
1612 for raw in [
1615 "",
1616 "\n \n",
1617 "undefined\n",
1618 "not a command\n",
1619 "./relative\n",
1620 ] {
1621 assert!(
1622 path_from_output(raw).is_none(),
1623 "{raw:?} was accepted as a cache path"
1624 );
1625 }
1626 }
1627
1628 #[test]
1629 fn the_cargo_rows_point_inside_the_registry() {
1630 for kind in ["registry cache", "registry sources"] {
1633 let path = fallbacks("cargo", kind).remove(0);
1634 assert!(
1635 path.starts_with(cargo_home().join("registry")),
1636 "{kind} resolved outside the cargo registry: {}",
1637 path.display()
1638 );
1639 }
1640 }
1641
1642 #[test]
1643 fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
1644 let home = dirs::home_dir().expect("a home directory");
1650 let found = fallbacks("conda", "package cache");
1651
1652 for install in [
1653 "miniconda3",
1654 "anaconda3",
1655 "miniforge3",
1656 "mambaforge",
1657 ".conda",
1658 ] {
1659 let want = home.join(install).join("pkgs");
1660 assert!(
1661 found.contains(&want),
1662 "{} is not among conda's conventional locations",
1663 want.display()
1664 );
1665 assert!(
1666 !found.contains(&home.join(install)),
1667 "{} is the installation, not its package cache",
1668 home.join(install).display()
1669 );
1670 }
1671 }
1672
1673 #[test]
1674 fn the_report_is_ordered_by_what_is_worth_clearing() {
1675 let mut reports = [
1676 CacheReport {
1677 manager: "npm",
1678 kind: "cache",
1679 path: PathBuf::from("/a"),
1680 bytes: 10,
1681 clear_command: "x".to_string(),
1682 clear: Clear::Command("npm", &["cache"]),
1683 note: None,
1684 cap_gb: None,
1685 over_cap: false,
1686 dependents: None,
1687 extra_args: Vec::new(),
1688 },
1689 CacheReport {
1690 manager: "go",
1691 kind: "module cache",
1692 path: PathBuf::from("/b"),
1693 bytes: 4_000,
1694 clear_command: "y".to_string(),
1695 clear: Clear::Directory,
1696 note: None,
1697 cap_gb: None,
1698 over_cap: false,
1699 dependents: None,
1700 extra_args: Vec::new(),
1701 },
1702 ];
1703 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1704 assert_eq!(reports[0].manager, "go");
1705 }
1706
1707 fn sized(manager: &'static str, bytes: u64, dependents: Option<usize>) -> CacheReport {
1709 CacheReport {
1710 manager,
1711 kind: "cache",
1712 path: PathBuf::from("/x"),
1713 bytes,
1714 clear_command: "x".to_string(),
1715 clear: Clear::Directory,
1716 note: None,
1717 cap_gb: None,
1718 over_cap: false,
1719 dependents,
1720 extra_args: Vec::new(),
1721 }
1722 }
1723
1724 #[test]
1725 fn the_costliest_cache_per_repository_is_not_the_biggest_one() {
1726 let reports = [
1729 sized("npm", 10_240, Some(18)),
1730 sized("pnpm", 2_048, Some(1)),
1731 sized("cargo", 300, Some(2)),
1732 sized("cargo", 100, Some(2)),
1733 sized("bun", 512, Some(0)),
1734 sized("nuget", 900, None),
1735 ];
1736 assert_eq!(
1737 costliest_per_repository(&reports),
1738 vec![("pnpm", 2_048), ("npm", 568), ("cargo", 200)],
1739 );
1740 }
1741
1742 #[test]
1743 fn every_probe_clears_with_the_command_it_prints() {
1744 for probe in PROBES {
1747 let printed = probe.clear_command;
1748 match probe.clear {
1749 Clear::Command(program, args) => {
1750 assert!(
1751 printed.starts_with(program),
1752 "{} {} prints `{printed}` but runs `{program}`",
1753 probe.manager,
1754 probe.kind
1755 );
1756 for arg in args {
1757 assert!(
1760 printed.contains(arg.trim_matches('"')),
1761 "{} {} prints `{printed}` but passes `{arg}`",
1762 probe.manager,
1763 probe.kind
1764 );
1765 }
1766 }
1767 Clear::Directory | Clear::Manual { .. } => assert!(
1771 printed.contains("rm -rf") || printed.contains("Remove-Item"),
1772 "{} {} deletes a directory but prints `{printed}`",
1773 probe.manager,
1774 probe.kind
1775 ),
1776 }
1777 }
1778 }
1779
1780 #[test]
1781 fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
1782 let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
1786 assert!(!maven.is_empty(), "maven is no longer reported at all");
1787 for probe in maven {
1788 assert!(
1789 matches!(probe.clear, Clear::Manual { .. }),
1790 "maven {} would be emptied by dev-prune",
1791 probe.kind
1792 );
1793 }
1794 }
1795
1796 #[test]
1797 fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
1798 let dir = tempfile::tempdir().unwrap();
1802 let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
1803 std::fs::write(&artifact, b"nowhere else").unwrap();
1804
1805 let outcome = clear_one(&CacheReport {
1806 manager: "maven",
1807 kind: "local repository",
1808 path: dir.path().to_path_buf(),
1809 bytes: 12,
1810 clear_command: MAVEN_REPO_CLEAR.to_string(),
1811 clear: Clear::Manual { why: MAVEN_MANUAL },
1812 note: None,
1813 cap_gb: None,
1814 over_cap: false,
1815 dependents: None,
1816 extra_args: Vec::new(),
1817 });
1818
1819 assert!(artifact.exists(), "the store was emptied after all");
1820 assert!(
1821 outcome.problem.is_some(),
1822 "it reported success without doing anything"
1823 );
1824 }
1825
1826 #[test]
1827 fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
1828 let err = run_clear("maven", false, false, true, true, false).unwrap_err();
1831 assert!(
1832 err.downcast_ref::<crate::UsageError>().is_some(),
1833 "expected a usage error, got: {err:#}"
1834 );
1835 let text = format!("{err}");
1836 assert!(
1837 text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
1838 "the refusal names neither the reason nor the command: {text}"
1839 );
1840 }
1841
1842 #[test]
1843 fn every_manager_in_the_report_can_be_named_to_clear() {
1844 let names = known_managers();
1845 for probe in PROBES {
1846 assert!(
1847 names.contains(&probe.manager),
1848 "{} is reported but `devp caches clear {}` would not find it",
1849 probe.manager,
1850 probe.manager
1851 );
1852 }
1853 let mut sorted = names.clone();
1856 sorted.sort_unstable();
1857 sorted.dedup();
1858 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
1859 }
1860
1861 #[test]
1862 fn an_unknown_manager_is_a_usage_error() {
1863 let err = run_clear("nonesuch", false, false, true, true, false).unwrap_err();
1865 assert!(err.downcast_ref::<crate::UsageError>().is_some());
1866 }
1867
1868 #[test]
1869 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
1870 let err = run_clear("npm", false, false, false, false, true).unwrap_err();
1871 assert!(err.downcast_ref::<crate::UsageError>().is_some());
1872 }
1873
1874 #[test]
1875 fn removing_a_directory_reports_nothing_when_it_worked() {
1876 let dir = tempfile::tempdir().unwrap();
1877 let cache = dir.path().join("cache");
1878 std::fs::create_dir(&cache).unwrap();
1879 std::fs::write(cache.join("blob"), b"x").unwrap();
1880
1881 assert!(remove_cache_dir(&cache).is_none());
1882 assert!(!cache.exists());
1883 assert!(remove_cache_dir(&cache).is_none());
1886 }
1887
1888 #[test]
1889 fn clearing_a_directory_reports_what_actually_went() {
1890 let dir = tempfile::tempdir().unwrap();
1891 let cache = dir.path().join("store");
1892 std::fs::create_dir(&cache).unwrap();
1893 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
1894 let before = adapters::dir_size(&cache);
1895
1896 let outcome = clear_one(&CacheReport {
1897 manager: "cargo",
1898 kind: "registry cache",
1899 path: cache.clone(),
1900 bytes: before,
1901 clear_command: "rm -rf".to_string(),
1902 clear: Clear::Directory,
1903 note: None,
1904 cap_gb: None,
1905 over_cap: false,
1906 dependents: None,
1907 extra_args: Vec::new(),
1908 });
1909
1910 assert!(outcome.problem.is_none());
1911 assert_eq!(outcome.after, 0);
1912 assert_eq!(outcome.freed(), before);
1915 assert!(!cache.exists());
1916 }
1917
1918 #[test]
1919 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
1920 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
1923 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
1924 }
1925
1926 fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
1928 CacheReport {
1929 manager,
1930 kind,
1931 path: PathBuf::from("/cache").join(manager).join(kind),
1932 bytes: gib * crate::constants::BYTES_PER_GIB,
1933 clear_command: "x".to_string(),
1934 clear: Clear::Directory,
1935 note: None,
1936 cap_gb: None,
1937 over_cap: false,
1938 dependents: None,
1939 extra_args: Vec::new(),
1940 }
1941 }
1942
1943 fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
1945 Dependents {
1946 repositories,
1947 by_manager: counts.iter().copied().collect(),
1948 }
1949 }
1950
1951 #[test]
1952 fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
1953 let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
1958 apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
1959
1960 assert_eq!(reports[0].dependents, Some(2));
1961 assert_eq!(
1962 reports[1].dependents, None,
1963 "pip has no adapter of its name, so there is nothing to count"
1964 );
1965 }
1966
1967 #[test]
1968 fn no_registry_leaves_every_count_unanswered() {
1969 let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
1972 apply_dependents(&mut reports, None);
1973 assert!(reports.iter().all(|r| r.dependents.is_none()));
1974 }
1975
1976 #[test]
1977 fn a_manager_nothing_uses_is_a_counted_zero() {
1978 let mut reports = vec![row("go", "module cache", 3)];
1981 apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
1982 assert_eq!(reports[0].dependents, Some(0));
1983 assert!(
1984 used_by(&reports[0], 0, None, &manager_totals(&reports))
1985 .contains("no registered repository uses go")
1986 );
1987 }
1988
1989 #[test]
1990 fn the_per_repository_share_is_the_managers_whole_footprint() {
1991 let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
1995 let line = used_by(
1996 &reports[0],
1997 2,
1998 Some(&counted(2, &[("cargo", 2)])),
1999 &manager_totals(&reports),
2000 );
2001 assert!(
2002 line.contains("cargo is used by 2 of 2 registered repositories")
2003 && line.contains("6 GiB"),
2004 "{line}"
2005 );
2006 }
2007
2008 #[test]
2009 fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
2010 let dir = tempfile::tempdir().unwrap();
2014 let nested = dir.path().join("a").join("b");
2015 std::fs::create_dir_all(&nested).unwrap();
2016
2017 let root = volume_root(&nested).expect("a real directory sits on some filesystem");
2018 assert!(
2019 nested.starts_with(&root),
2020 "{} is not under {}",
2021 nested.display(),
2022 root.display()
2023 );
2024 assert!(root.is_dir(), "{} is not a directory", root.display());
2025 }
2026
2027 #[cfg(windows)]
2028 #[test]
2029 fn a_windows_volume_root_is_the_drive_and_nothing_more() {
2030 let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
2034 assert_eq!(root, PathBuf::from("V:\\"));
2035 assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
2036 }
2037
2038 #[test]
2039 fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
2040 let dir = tempfile::tempdir().unwrap();
2043 let a = dir.path().join("one");
2044 let b = dir.path().join("two");
2045 std::fs::create_dir_all(&a).unwrap();
2046 std::fs::create_dir_all(&b).unwrap();
2047
2048 assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
2049 assert!(volume_roots(&[]).is_empty());
2050 }
2051
2052 #[test]
2053 fn a_volume_stores_printed_command_is_the_one_that_runs() {
2054 let dir = tempfile::tempdir().unwrap();
2059 let store = dir.path().join(".pnpm-store");
2060 std::fs::create_dir_all(&store).unwrap();
2061
2062 let report = volume_store_report(store.clone());
2063 let named = output::clean_path(&store);
2064 assert_eq!(
2065 report.extra_args,
2066 vec!["--store-dir".to_string(), named.clone()]
2067 );
2068 assert!(
2069 report.clear_command.contains(&named),
2070 "the printed command does not name the store: {}",
2071 report.clear_command
2072 );
2073 assert!(matches!(
2074 report.clear,
2075 Clear::Command("pnpm", ["store", "prune"])
2076 ));
2077 }
2078
2079 #[test]
2080 fn only_a_path_with_a_space_in_it_is_quoted() {
2081 assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
2085 assert_eq!(
2086 shell_arg("/mnt/my data/.pnpm-store"),
2087 "\"/mnt/my data/.pnpm-store\""
2088 );
2089 }
2090
2091 #[test]
2092 fn a_cap_is_measured_against_the_managers_whole_footprint() {
2093 let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2097 apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
2098 assert!(
2099 reports.iter().all(|r| r.over_cap),
2100 "both rows belong to the manager that went over"
2101 );
2102 assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
2103 }
2104
2105 #[test]
2106 fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
2107 let mut reports = vec![row("npm", "cache", 3)];
2108 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2109 assert_eq!(reports[0].cap_gb, Some(10));
2112 assert!(!reports[0].over_cap);
2113 }
2114
2115 #[test]
2116 fn a_manager_with_no_cap_is_never_called_too_big() {
2117 let mut reports = vec![row("uv", "cache", 40)];
2120 apply_caps(&mut reports, &BTreeMap::new());
2121 assert_eq!(reports[0].cap_gb, None);
2122 assert!(!reports[0].over_cap);
2123 }
2124
2125 #[test]
2126 fn one_managers_cap_says_nothing_about_another() {
2127 let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
2128 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2129 assert!(reports[0].over_cap);
2130 assert!(
2131 !reports[1].over_cap,
2132 "go has no cap and did not acquire npm's"
2133 );
2134 }
2135
2136 #[test]
2137 fn exactly_at_the_cap_is_not_over_it() {
2138 let mut reports = vec![row("pnpm", "store", 10)];
2141 apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
2142 assert!(!reports[0].over_cap);
2143 }
2144
2145 #[test]
2146 fn every_cache_manager_answers_to_its_own_name() {
2147 for probe in PROBES {
2150 assert!(
2151 is_cache_manager(probe.manager),
2152 "{} is reported but cannot be capped",
2153 probe.manager
2154 );
2155 }
2156 assert!(!is_cache_manager("dev-prune-no-such-manager"));
2157 }
2158}