1use std::collections::{BTreeMap, HashSet};
42use std::path::{Path, PathBuf};
43
44use anyhow::Result;
45
46use crate::adapters;
47use crate::constants;
48use crate::json;
49use crate::output;
50
51pub struct CacheReport {
53 pub manager: &'static str,
55 pub kind: &'static str,
57 pub path: PathBuf,
59 pub bytes: u64,
61 pub clear_command: String,
67 pub clear: Clear,
69 pub note: Option<&'static str>,
71 pub cap_gb: Option<u64>,
76 pub over_cap: bool,
83 pub dependents: Option<usize>,
93 pub extra_args: Vec<String>,
101}
102
103#[derive(Clone, Copy)]
105pub enum Clear {
106 Command(&'static str, &'static [&'static str]),
110 Directory,
113 Manual { why: &'static str },
117}
118
119struct Probe {
121 manager: &'static str,
122 kind: &'static str,
123 query: Option<(&'static str, &'static [&'static str])>,
129 clear_command: &'static str,
130 clear: Clear,
131 note: Option<&'static str>,
132}
133
134#[cfg(windows)]
137const CARGO_CACHE_CLEAR: &str =
138 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
139#[cfg(not(windows))]
140const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
141
142#[cfg(windows)]
143const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
144#[cfg(not(windows))]
145const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
146
147#[cfg(windows)]
152const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
153#[cfg(not(windows))]
154const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
155
156const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
176 download cache: `mvn install` and `install:install-file` write artifacts there \
177 that exist nowhere else, and nothing in the tree tells them apart from the \
178 downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
179 the command; running it is yours to decide.";
180
181#[cfg(windows)]
182const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
183#[cfg(not(windows))]
184const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
185
186#[cfg(windows)]
187const GRADLE_DISTS_CLEAR: &str =
188 r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
189#[cfg(not(windows))]
190const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
191
192#[cfg(windows)]
193const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
194#[cfg(not(windows))]
195const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
196
197#[cfg(windows)]
200const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
201#[cfg(not(windows))]
202const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
203
204const PROBES: &[Probe] = &[
205 Probe {
206 manager: "npm",
207 kind: "cache",
208 query: Some(("npm", &["config", "get", "cache"])),
209 clear_command: "npm cache clean --force",
210 clear: Clear::Command("npm", &["cache", "clean", "--force"]),
211 note: None,
212 },
213 Probe {
214 manager: "pnpm",
215 kind: "store",
216 query: Some(("pnpm", &["store", "path"])),
217 clear_command: "pnpm store prune",
218 clear: Clear::Command("pnpm", &["store", "prune"]),
219 note: Some(
220 "hardlinked into every node_modules it filled; emptying it is what makes the \
221 next pnpm install a download",
222 ),
223 },
224 Probe {
225 manager: "yarn",
226 kind: "cache",
227 query: Some(("yarn", &["cache", "dir"])),
228 clear_command: "yarn cache clean",
229 clear: Clear::Command("yarn", &["cache", "clean"]),
230 note: None,
231 },
232 Probe {
233 manager: "bun",
234 kind: "cache",
235 query: Some(("bun", &["pm", "cache"])),
236 clear_command: "bun pm cache rm",
237 clear: Clear::Command("bun", &["pm", "cache", "rm"]),
238 note: None,
239 },
240 Probe {
241 manager: "uv",
242 kind: "cache",
243 query: Some(("uv", &["cache", "dir"])),
244 clear_command: "uv cache prune",
247 clear: Clear::Command("uv", &["cache", "prune"]),
248 note: None,
249 },
250 Probe {
251 manager: "pip",
252 kind: "cache",
253 query: Some(("pip", &["cache", "dir"])),
254 clear_command: "pip cache purge",
255 clear: Clear::Command("pip", &["cache", "purge"]),
256 note: None,
257 },
258 Probe {
264 manager: "conda",
265 kind: "package cache",
266 query: None,
267 clear_command: "conda clean --packages --tarballs --yes",
268 clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
269 note: Some(
270 "unpacked packages and downloaded archives; conda keeps what its \
271 environments use, except any it linked by symlink rather than hardlink",
272 ),
273 },
274 Probe {
275 manager: "cargo",
276 kind: "registry cache",
277 query: None,
278 clear_command: CARGO_CACHE_CLEAR,
279 clear: Clear::Directory,
280 note: Some("the downloaded .crate archives; clearing them means downloading again"),
281 },
282 Probe {
283 manager: "cargo",
284 kind: "registry sources",
285 query: None,
286 clear_command: CARGO_SRC_CLEAR,
287 clear: Clear::Directory,
288 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
289 },
290 Probe {
291 manager: "go",
292 kind: "module cache",
293 query: Some(("go", &["env", "GOMODCACHE"])),
294 clear_command: "go clean -modcache",
295 clear: Clear::Command("go", &["clean", "-modcache"]),
296 note: None,
297 },
298 Probe {
299 manager: "go",
300 kind: "build cache",
301 query: Some(("go", &["env", "GOCACHE"])),
302 clear_command: "go clean -cache",
303 clear: Clear::Command("go", &["clean", "-cache"]),
304 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
305 },
306 Probe {
311 manager: "maven",
312 kind: "local repository",
313 query: None,
314 clear_command: MAVEN_REPO_CLEAR,
315 clear: Clear::Manual { why: MAVEN_MANUAL },
316 note: Some(
317 "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
318 ),
319 },
320 Probe {
321 manager: "gradle",
322 kind: "caches",
323 query: None,
324 clear_command: GRADLE_CACHE_CLEAR,
325 clear: Clear::Directory,
326 note: Some(
327 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
328 ),
329 },
330 Probe {
331 manager: "gradle",
332 kind: "wrapper distributions",
333 query: None,
334 clear_command: GRADLE_DISTS_CLEAR,
335 clear: Clear::Directory,
336 note: Some(
337 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
338 ),
339 },
340 Probe {
344 manager: "nuget",
345 kind: "global packages",
346 query: None,
347 clear_command: "dotnet nuget locals global-packages --clear",
348 clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
349 note: Some(
350 "every .NET project on the machine restores from here; re-downloaded on the next restore",
351 ),
352 },
353 Probe {
354 manager: "vcpkg",
355 kind: "binary cache",
356 query: None,
357 clear_command: VCPKG_ARCHIVES_CLEAR,
358 clear: Clear::Directory,
359 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
360 },
361 Probe {
362 manager: "conan",
363 kind: "package cache",
364 query: None,
365 clear_command: "conan remove \"*\" --confirm",
366 clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
367 note: Some(
368 "recipes and binaries shared by every Conan project; re-fetched on the next install",
369 ),
370 },
371 Probe {
376 manager: "composer",
377 kind: "cache",
378 query: Some(("composer", &["config", "--global", "cache-dir"])),
379 clear_command: "composer clear-cache",
380 clear: Clear::Command("composer", &["clear-cache"]),
381 note: Some(
382 "downloaded package archives and repository metadata; re-fetched by the next composer install",
383 ),
384 },
385 Probe {
390 manager: "cocoapods",
391 kind: "cache",
392 query: None,
393 clear_command: "pod cache clean --all",
394 clear: Clear::Command("pod", &["cache", "clean", "--all"]),
395 note: Some("downloaded pod sources, re-fetched by the next pod install"),
396 },
397 Probe {
398 manager: "hex",
399 kind: "package cache",
400 query: None,
401 clear_command: HEX_CACHE_CLEAR,
402 clear: Clear::Directory,
403 note: Some(
404 "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
405 ),
406 },
407];
408
409pub fn run(json_output: bool) -> Result<()> {
411 let reg = registered();
412 let mut reports = collect(!json_output, reg.as_ref());
413 apply_caps(&mut reports, &caps());
414 let deps = reg.as_ref().map(|r| dependents(r, !json_output));
415 apply_dependents(&mut reports, deps.as_ref());
416
417 if json_output {
418 return json::emit(&json::caches_document(
419 &reports,
420 deps.as_ref().map(|d| d.repositories),
421 ));
422 }
423
424 print_report(&reports, deps.as_ref());
425 Ok(())
426}
427
428fn caps() -> BTreeMap<String, u64> {
433 crate::config::Registry::load()
434 .map(|r| r.settings.cache_max_gb)
435 .unwrap_or_default()
436}
437
438fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
443 let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
444 for r in reports.iter() {
445 *totals.entry(r.manager).or_default() += r.bytes;
446 }
447 for r in reports.iter_mut() {
448 let Some(&gb) = caps.get(r.manager) else {
449 continue;
450 };
451 r.cap_gb = Some(gb);
452 r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
453 > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
454 }
455}
456
457struct Registered {
463 paths: Vec<PathBuf>,
465 scan_depth: usize,
467}
468
469fn registered() -> Option<Registered> {
477 let registry = crate::config::Registry::load().ok()?;
478 let paths: Vec<PathBuf> = registry
479 .repositories
480 .keys()
481 .filter(|p| p.exists())
482 .cloned()
483 .collect();
484 if paths.is_empty() {
485 return None;
486 }
487 Some(Registered {
488 paths,
489 scan_depth: registry.settings.scan_depth,
490 })
491}
492
493struct Dependents {
501 repositories: usize,
504 by_manager: BTreeMap<&'static str, usize>,
510}
511
512fn dependents(reg: &Registered, spinner: bool) -> Dependents {
518 let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
519
520 let mut by_manager: BTreeMap<&'static str, usize> = PROBES
526 .iter()
527 .map(|p| p.manager)
528 .filter(|m| adapters::is_adapter_name(m))
529 .map(|m| (m, 0))
530 .collect();
531
532 for path in ®.paths {
533 let depth = crate::workspace::clamp_depth(
538 crate::config::PerRepoConfig::load_with_diagnostics(path)
539 .ok()
540 .flatten()
541 .and_then(|c| c.scan_depth)
542 .unwrap_or(reg.scan_depth),
543 );
544 let mut here: HashSet<&'static str> = HashSet::new();
545 for project in crate::workspace::discover_all_to_depth(path, depth) {
546 for adapter in &project.adapters {
547 here.insert(adapter.name());
548 }
549 }
550 for (manager, count) in by_manager.iter_mut() {
551 if here.contains(manager) {
552 *count += 1;
553 }
554 }
555 }
556
557 if let Some(pb) = pb {
558 pb.finish_and_clear();
559 }
560
561 Dependents {
562 repositories: reg.paths.len(),
563 by_manager,
564 }
565}
566
567fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
569 let Some(deps) = deps else {
570 return;
571 };
572 for r in reports.iter_mut() {
573 r.dependents = deps.by_manager.get(r.manager).copied();
574 }
575}
576
577fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, u64> {
582 let mut totals: BTreeMap<&'static str, u64> = BTreeMap::new();
583 for r in reports {
584 *totals.entry(r.manager).or_default() += r.bytes;
585 }
586 totals
587}
588
589fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
591 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
592 let from = query_dir();
593
594 let mut seen: HashSet<PathBuf> = HashSet::new();
595 let mut reports = Vec::new();
596
597 for probe in PROBES {
598 let Some(path) = locate(probe, &from) else {
599 continue;
600 };
601 let path = path.canonicalize().unwrap_or(path);
608 if !seen.insert(path.clone()) {
609 continue;
610 }
611 reports.push(CacheReport {
612 manager: probe.manager,
613 kind: probe.kind,
614 bytes: adapters::dir_size(&path),
615 path,
616 clear_command: probe.clear_command.to_string(),
617 clear: probe.clear,
618 note: probe.note,
619 cap_gb: None,
620 over_cap: false,
621 dependents: None,
622 extra_args: Vec::new(),
623 });
624 }
625
626 for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
629 if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
630 continue;
631 }
632 reports.push(volume_store_report(store));
633 }
634
635 if let Some(pb) = pb {
636 pb.finish_and_clear();
637 }
638
639 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
640 reports
641}
642
643const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
645 this is the store for the projects on this volume";
646
647fn volume_store_report(store: PathBuf) -> CacheReport {
653 let named = output::clean_path(&store);
654 CacheReport {
655 manager: "pnpm",
656 kind: "store",
657 bytes: adapters::dir_size(&store),
658 clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
659 extra_args: vec!["--store-dir".to_string(), named],
660 path: store,
661 clear: Clear::Command("pnpm", &["store", "prune"]),
662 note: Some(PNPM_VOLUME_NOTE),
663 cap_gb: None,
664 over_cap: false,
665 dependents: None,
666 }
667}
668
669fn shell_arg(named: &str) -> String {
674 if named.contains(' ') {
675 format!("\"{named}\"")
676 } else {
677 named.to_string()
678 }
679}
680
681fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
696 let mut roots = volume_roots(repos);
700 if let Ok(here) = std::env::current_dir()
701 && let Some(root) = volume_root(&here)
702 && !roots.contains(&root)
703 {
704 roots.push(root);
705 }
706 roots
707 .into_iter()
708 .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
709 .filter(|store| store.is_dir())
710 .collect()
711}
712
713fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
715 let mut roots: Vec<PathBuf> = Vec::new();
716 for repo in repos {
717 if let Some(root) = volume_root(repo)
718 && !roots.contains(&root)
719 {
720 roots.push(root);
721 }
722 }
723 roots
724}
725
726#[cfg(unix)]
733fn volume_root(path: &Path) -> Option<PathBuf> {
734 use std::os::unix::fs::MetadataExt;
735
736 let dev = std::fs::metadata(path).ok()?.dev();
737 let mut root = path.to_path_buf();
738 for ancestor in path.ancestors().skip(1) {
739 match std::fs::metadata(ancestor) {
740 Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
741 _ => break,
742 }
743 }
744 Some(root)
745}
746
747#[cfg(windows)]
754fn volume_root(path: &Path) -> Option<PathBuf> {
755 use std::path::Component;
756
757 let mut components = path.components();
758 let Some(Component::Prefix(prefix)) = components.next() else {
759 return None;
760 };
761 if components.next() != Some(Component::RootDir) {
762 return None;
763 }
764 let mut root = PathBuf::from(prefix.as_os_str());
765 root.push(Component::RootDir.as_os_str());
766 Some(root)
767}
768
769fn query_dir() -> PathBuf {
776 dirs::home_dir()
777 .or_else(|| std::env::current_dir().ok())
778 .unwrap_or_else(|| PathBuf::from("."))
779}
780
781fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
783 if let Some((program, args)) = probe.query
784 && adapters::binary_available(program)
785 {
786 let answered = adapters::capture_command_with_timeout(
787 program,
788 args,
789 from,
790 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
791 )
792 .ok()
793 .and_then(|raw| path_from_output(&raw))
794 .filter(|p| p.is_dir());
795 if answered.is_some() {
796 return answered;
797 }
798 }
799
800 fallbacks(probe.manager, probe.kind)
805 .into_iter()
806 .find(|p| p.is_dir())
807}
808
809fn path_from_output(raw: &str) -> Option<PathBuf> {
814 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
815 let line = line.trim_matches('"');
816 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
819 return None;
820 }
821 Some(PathBuf::from(line))
822}
823
824fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
826 let home = dirs::home_dir();
827 let local = dirs::data_local_dir();
828 let cache = dirs::cache_dir();
829 let under = |base: &Option<PathBuf>, rel: &str| {
833 base.as_ref()
834 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
835 };
836
837 let candidates = match (manager, kind) {
838 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
841 ("pnpm", _) => vec![
842 under(&local, "pnpm/store"),
843 under(&home, ".local/share/pnpm/store"),
844 under(&home, "Library/pnpm/store"),
845 under(&home, ".pnpm-store"),
846 ],
847 ("yarn", _) => vec![
848 under(&home, ".yarn/berry/cache"),
849 under(&local, "Yarn/Cache"),
850 under(&cache, "yarn"),
851 ],
852 ("bun", _) => vec![under(&home, ".bun/install/cache")],
853 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
854 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
855 ("conda", _) => vec![
863 std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
864 std::env::var_os("CONDA_EXE")
865 .map(PathBuf::from)
866 .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
867 .map(|root| root.join("pkgs")),
868 under(&home, "miniconda3/pkgs"),
869 under(&home, "anaconda3/pkgs"),
870 under(&home, "miniforge3/pkgs"),
871 under(&home, "mambaforge/pkgs"),
872 under(&home, ".conda/pkgs"),
873 ],
874 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
875 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
876 ("go", "module cache") => vec![
877 std::env::var_os("GOMODCACHE").map(PathBuf::from),
878 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
879 under(&home, "go/pkg/mod"),
880 ],
881 ("go", _) => vec![
882 std::env::var_os("GOCACHE").map(PathBuf::from),
883 under(&cache, "go-build"),
884 under(&local, "go-build"),
885 ],
886 ("maven", _) => vec![under(&home, ".m2/repository")],
887 ("gradle", "caches") => vec![
889 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
890 under(&home, ".gradle/caches"),
891 ],
892 ("gradle", _) => vec![
893 std::env::var_os("GRADLE_USER_HOME")
894 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
895 under(&home, ".gradle/wrapper/dists"),
896 ],
897 ("nuget", _) => vec![
898 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
899 under(&home, ".nuget/packages"),
900 ],
901 ("vcpkg", _) => vec![
902 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
903 under(&local, "vcpkg/archives"),
904 under(&cache, "vcpkg/archives"),
905 ],
906 ("conan", _) => vec![
909 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
910 under(&home, ".conan2/p"),
911 ],
912 ("composer", _) => vec![
915 std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
916 std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
917 under(&local, "Composer"),
918 under(&cache, "composer"),
919 under(&home, ".composer/cache"),
920 ],
921 ("cocoapods", _) => vec![
925 std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
926 under(&home, "Library/Caches/CocoaPods"),
927 ],
928 ("hex", _) => vec![
931 std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
932 under(&home, ".hex/packages"),
933 under(&cache, "hex/packages"),
934 ],
935 _ => vec![],
936 };
937
938 candidates.into_iter().flatten().collect()
939}
940
941fn cargo_home() -> PathBuf {
943 std::env::var_os("CARGO_HOME")
944 .map(PathBuf::from)
945 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
946 .unwrap_or_else(|| PathBuf::from(".cargo"))
947}
948
949fn print_report(reports: &[CacheReport], deps: Option<&Dependents>) {
950 output::print_header("Package manager caches");
951
952 if reports.is_empty() {
953 println!();
954 output::print_info("No package manager caches found on this machine.");
955 return;
956 }
957
958 println!();
959 let totals = manager_totals(reports);
960 let mut counted: HashSet<&'static str> = HashSet::new();
963 for r in reports {
964 let label = format!("{} {}", r.manager, r.kind);
965 println!(
966 " {:<30} {:>10} {}",
967 label,
968 output::format_bytes(r.bytes),
969 output::clean_path(&r.path)
970 );
971 println!(" {:<30} {:>10} clear: {}", "", "", r.clear_command);
972 if let Some(note) = r.note {
973 println!(" {:<30} {:>10} {}", "", "", note);
974 }
975 if r.over_cap
976 && let Some(gb) = r.cap_gb
977 {
978 println!(
979 " {:<30} {:>10} over the {gb} GiB cap you set for {}",
980 "", "", r.manager
981 );
982 }
983 if let Some(n) = r.dependents
984 && counted.insert(r.manager)
985 {
986 println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
987 }
988 println!();
989 }
990
991 let total: u64 = reports.iter().map(|r| r.bytes).sum();
992 println!(
993 " {:<30} {:>10} across {} {}",
994 "Total",
995 output::format_bytes(total),
996 reports.len(),
997 output::plural(reports.len(), "cache", "caches")
998 );
999
1000 if reports.iter().any(|r| r.over_cap) {
1001 println!();
1002 output::print_info(
1003 "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1004 --over-cap all` empties exactly those and leaves the rest alone.",
1005 );
1006 }
1007
1008 if reports.iter().any(|r| r.dependents == Some(0)) {
1009 println!();
1010 output::print_info(
1011 "The caches above that no registered repository uses were filled for projects that \
1012 are not here any more. `devp caches clear --unused all` empties exactly those. It \
1013 counts only repositories dev-prune knows about, so `devp link` anything you keep \
1014 outside the registry before trusting the number.",
1015 );
1016 }
1017
1018 println!();
1019 output::print_info(
1020 "Nothing above was deleted. A cache is shared by every project on the machine, so \
1021 no single repository's lockfile can prove it is recoverable — and it is what \
1022 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1023 will ever touch one. When you want the space more than the speed, run a clear \
1024 command yourself, or `devp caches clear <manager>`.",
1025 );
1026}
1027
1028fn used_by(
1035 r: &CacheReport,
1036 dependents: usize,
1037 deps: Option<&Dependents>,
1038 totals: &BTreeMap<&'static str, u64>,
1039) -> String {
1040 if dependents == 0 {
1041 return format!("no registered repository uses {}", r.manager);
1042 }
1043 let registered = deps.map_or(dependents, |d| d.repositories);
1044 let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1045 format!(
1050 "{} is used by {dependents} of {registered} registered {} · {} each",
1051 r.manager,
1052 output::plural(registered, "repository", "repositories"),
1053 output::format_bytes(total / dependents as u64)
1054 )
1055}
1056
1057pub struct ClearOutcome {
1059 pub manager: &'static str,
1061 pub kind: &'static str,
1063 pub path: PathBuf,
1065 pub before: u64,
1067 pub after: u64,
1071 pub problem: Option<String>,
1073}
1074
1075impl ClearOutcome {
1076 pub fn freed(&self) -> u64 {
1078 self.before.saturating_sub(self.after)
1079 }
1080}
1081
1082pub fn run_clear(
1089 target: &str,
1090 over_cap: bool,
1091 unused: bool,
1092 yes: bool,
1093 dry_run: bool,
1094 json_output: bool,
1095) -> Result<()> {
1096 let all = target.eq_ignore_ascii_case("all");
1097 if !all
1098 && !PROBES
1099 .iter()
1100 .any(|p| p.manager.eq_ignore_ascii_case(target))
1101 {
1102 return Err(anyhow::Error::new(crate::UsageError(format!(
1103 "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1104 known_managers().join(", ")
1105 ))));
1106 }
1107 if !all
1112 && let Some(probe) = manual_only(target)
1113 && let Clear::Manual { why } = probe.clear
1114 {
1115 return Err(anyhow::Error::new(crate::UsageError(format!(
1116 "{why} The command is: {}",
1117 probe.clear_command
1118 ))));
1119 }
1120
1121 if json_output && !yes && !dry_run {
1124 return Err(anyhow::Error::new(crate::UsageError(
1125 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1126 to see what would go."
1127 .to_string(),
1128 )));
1129 }
1130
1131 let reg = registered();
1135 let mut measured = collect(!json_output, reg.as_ref());
1136 apply_caps(&mut measured, &caps());
1137
1138 let deps = if unused {
1142 let Some(reg) = reg.as_ref() else {
1143 return Err(anyhow::Error::new(crate::UsageError(
1144 "`--unused` empties the caches no registered repository needs, and there are no \
1145 registered repositories on this disk to check against — every cache would look \
1146 unused. Register what you keep with `devp link` first."
1147 .to_string(),
1148 )));
1149 };
1150 Some(dependents(reg, !json_output))
1151 } else {
1152 None
1153 };
1154 apply_dependents(&mut measured, deps.as_ref());
1155
1156 let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1160 .into_iter()
1161 .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
1162 .filter(|r| !over_cap || r.over_cap)
1163 .filter(|r| !unused || r.dependents == Some(0))
1164 .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
1165
1166 if reports.is_empty() {
1167 if json_output {
1168 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1169 }
1170 if unused {
1171 output::print_info(
1172 "Every cache on this machine is used by at least one registered repository, or \
1173 is one dev-prune cannot attribute to any — nothing to clear.",
1174 );
1175 return Ok(());
1176 }
1177 if over_cap {
1178 output::print_info(if caps().is_empty() {
1182 "No cache size caps are set, so nothing is over one. Set them with `devp config \
1183 set cache_max_gb npm=10,uv=10`, or in `devp config wizard`."
1184 } else {
1185 "Every capped cache is under its cap — nothing to clear."
1186 });
1187 return Ok(());
1188 }
1189 output::print_info(&format!(
1190 "No {} cache on this machine — nothing to clear.",
1191 if all { "package manager" } else { target }
1192 ));
1193 return Ok(());
1194 }
1195
1196 if dry_run {
1197 if json_output {
1198 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1199 }
1200 print_kept(&kept);
1201 print_clear_plan(&reports, true);
1202 return Ok(());
1203 }
1204
1205 if !json_output {
1206 print_kept(&kept);
1207 print_clear_plan(&reports, false);
1208 if !confirm_clear(yes) {
1209 output::print_info("Nothing was cleared.");
1210 return Ok(());
1211 }
1212 }
1213
1214 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
1215
1216 if json_output {
1217 json::emit(&json::caches_clear_document(&outcomes, &kept))?;
1218 } else {
1219 print_clear_result(&outcomes);
1220 }
1221
1222 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
1225 if failed > 0 {
1226 anyhow::bail!(
1227 "{failed} {} could not be cleared.",
1228 output::plural(failed, "cache", "caches")
1229 );
1230 }
1231 Ok(())
1232}
1233
1234fn manual_only(target: &str) -> Option<&'static Probe> {
1239 let matching: Vec<&Probe> = PROBES
1240 .iter()
1241 .filter(|p| p.manager.eq_ignore_ascii_case(target))
1242 .collect();
1243 if matching.is_empty()
1244 || matching
1245 .iter()
1246 .any(|p| !matches!(p.clear, Clear::Manual { .. }))
1247 {
1248 return None;
1249 }
1250 matching.first().copied()
1251}
1252
1253pub fn is_cache_manager(name: &str) -> bool {
1255 PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
1256}
1257
1258pub fn known_managers() -> Vec<&'static str> {
1260 let mut names: Vec<&'static str> = Vec::new();
1261 for probe in PROBES {
1262 if !names.contains(&probe.manager) {
1263 names.push(probe.manager);
1264 }
1265 }
1266 names
1267}
1268
1269fn clear_one(report: &CacheReport) -> ClearOutcome {
1271 let problem = match report.clear {
1272 Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
1273 Clear::Directory => remove_cache_dir(&report.path),
1274 Clear::Manual { why } => Some(why.to_string()),
1278 };
1279 ClearOutcome {
1280 manager: report.manager,
1281 kind: report.kind,
1282 path: report.path.clone(),
1283 before: report.bytes,
1284 after: adapters::dir_size(&report.path),
1287 problem,
1288 }
1289}
1290
1291fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
1293 if !adapters::binary_available(program) {
1294 return Some(format!(
1295 "`{program}` is not on PATH — only it knows what in this cache is still \
1296 referenced, so dev-prune will not delete the directory in its place."
1297 ));
1298 }
1299 let mut all: Vec<&str> = args.to_vec();
1302 all.extend(extra.iter().map(String::as_str));
1303 adapters::run_command_with_timeout(
1304 program,
1305 &all,
1306 &query_dir(),
1307 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
1308 )
1309 .err()
1310 .map(|e| format!("{e:#}"))
1311}
1312
1313fn remove_cache_dir(path: &Path) -> Option<String> {
1315 std::fs::remove_dir_all(path)
1319 .or_else(|_| {
1320 std::thread::sleep(std::time::Duration::from_millis(250));
1321 std::fs::remove_dir_all(path)
1322 })
1323 .err()
1324 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
1326 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
1327}
1328
1329fn print_kept(kept: &[CacheReport]) {
1331 for r in kept {
1332 let Clear::Manual { why } = r.clear else {
1333 continue;
1334 };
1335 println!();
1336 output::print_info(&format!(
1337 "Keeping {} {} ({} at {}). {why}",
1338 r.manager,
1339 r.kind,
1340 output::format_bytes(r.bytes),
1341 output::clean_path(&r.path)
1342 ));
1343 }
1344}
1345
1346fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
1348 output::print_header(if dry_run {
1349 "Would clear"
1350 } else {
1351 "About to clear"
1352 });
1353
1354 println!();
1355 for r in reports {
1356 println!(
1357 " {:<30} {:>10} {}",
1358 format!("{} {}", r.manager, r.kind),
1359 output::format_bytes(r.bytes),
1360 output::clean_path(&r.path)
1361 );
1362 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
1363 }
1364
1365 println!();
1366 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1367 println!(
1368 " {:<30} {:>10} across {} {}",
1369 "Total",
1370 output::format_bytes(total),
1371 reports.len(),
1372 output::plural(reports.len(), "cache", "caches")
1373 );
1374
1375 println!();
1376 output::print_info(
1377 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
1378 The cost is time: the next install, and the next `devp restore`, in every \
1379 project on this machine.",
1380 );
1381}
1382
1383fn print_clear_result(outcomes: &[ClearOutcome]) {
1385 println!();
1386 for o in outcomes {
1387 let label = format!("{} {}", o.manager, o.kind);
1388 println!(
1389 " {:<30} {:>10} {}",
1390 label,
1391 output::format_bytes(o.freed()),
1392 if o.problem.is_some() {
1393 "not cleared"
1394 } else {
1395 "cleared"
1396 }
1397 );
1398 if let Some(why) = &o.problem {
1399 println!(" {:<30} {:>10} {why}", "", "");
1400 }
1401 }
1402
1403 println!();
1404 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
1405 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
1406}
1407
1408fn confirm_clear(yes: bool) -> bool {
1411 use std::io::{IsTerminal, Write};
1412 if yes {
1413 return true;
1414 }
1415 if !std::io::stdin().is_terminal() {
1416 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
1417 return false;
1418 }
1419 eprint!("Clear them? [y/N]: ");
1423 if std::io::stderr().flush().is_err() {
1424 return false;
1425 }
1426 let mut input = String::new();
1427 if std::io::stdin().read_line(&mut input).is_err() {
1428 return false;
1429 }
1430 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436
1437 #[test]
1438 fn every_probe_can_be_found_without_its_manager_installed() {
1439 for probe in PROBES {
1442 assert!(
1443 !fallbacks(probe.manager, probe.kind).is_empty(),
1444 "{} {} has no conventional location",
1445 probe.manager,
1446 probe.kind
1447 );
1448 }
1449 }
1450
1451 #[test]
1452 fn every_probe_names_the_command_that_clears_it() {
1453 for probe in PROBES {
1454 assert!(
1455 !probe.clear_command.trim().is_empty(),
1456 "{} {} reports a size with no way to act on it",
1457 probe.manager,
1458 probe.kind
1459 );
1460 }
1461 }
1462
1463 #[test]
1464 fn only_five_probed_managers_have_no_adapter_of_the_same_name() {
1465 let orphans: Vec<&str> = PROBES
1470 .iter()
1471 .map(|p| p.manager)
1472 .filter(|m| !adapters::is_adapter_name(m))
1473 .collect::<std::collections::BTreeSet<_>>()
1474 .into_iter()
1475 .collect();
1476 assert_eq!(orphans, ["conan", "conda", "hex", "nuget", "pip"]);
1477 }
1478
1479 #[test]
1480 fn no_two_probes_describe_the_same_cache() {
1481 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
1482 let count = keys.len();
1483 keys.sort_unstable();
1484 keys.dedup();
1485 assert_eq!(keys.len(), count, "two probes share a manager and kind");
1486 }
1487
1488 #[test]
1489 fn a_managers_answer_is_read_off_the_last_line() {
1490 let raw = if cfg!(windows) {
1492 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
1493 } else {
1494 "npm warn config global deprecated\n/home/dev/.npm\n"
1495 };
1496 assert!(path_from_output(raw).is_some());
1497 }
1498
1499 #[test]
1500 fn quoted_paths_lose_their_quotes() {
1501 let raw = if cfg!(windows) {
1502 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
1503 } else {
1504 "\"/opt/go path/pkg/mod\"\n"
1505 };
1506 let path = path_from_output(raw).expect("a quoted path is still a path");
1507 assert!(!path.to_string_lossy().contains('"'));
1508 }
1509
1510 #[test]
1511 fn a_non_answer_is_not_mistaken_for_a_path() {
1512 for raw in [
1515 "",
1516 "\n \n",
1517 "undefined\n",
1518 "not a command\n",
1519 "./relative\n",
1520 ] {
1521 assert!(
1522 path_from_output(raw).is_none(),
1523 "{raw:?} was accepted as a cache path"
1524 );
1525 }
1526 }
1527
1528 #[test]
1529 fn the_cargo_rows_point_inside_the_registry() {
1530 for kind in ["registry cache", "registry sources"] {
1533 let path = fallbacks("cargo", kind).remove(0);
1534 assert!(
1535 path.starts_with(cargo_home().join("registry")),
1536 "{kind} resolved outside the cargo registry: {}",
1537 path.display()
1538 );
1539 }
1540 }
1541
1542 #[test]
1543 fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
1544 let home = dirs::home_dir().expect("a home directory");
1550 let found = fallbacks("conda", "package cache");
1551
1552 for install in [
1553 "miniconda3",
1554 "anaconda3",
1555 "miniforge3",
1556 "mambaforge",
1557 ".conda",
1558 ] {
1559 let want = home.join(install).join("pkgs");
1560 assert!(
1561 found.contains(&want),
1562 "{} is not among conda's conventional locations",
1563 want.display()
1564 );
1565 assert!(
1566 !found.contains(&home.join(install)),
1567 "{} is the installation, not its package cache",
1568 home.join(install).display()
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn the_report_is_ordered_by_what_is_worth_clearing() {
1575 let mut reports = [
1576 CacheReport {
1577 manager: "npm",
1578 kind: "cache",
1579 path: PathBuf::from("/a"),
1580 bytes: 10,
1581 clear_command: "x".to_string(),
1582 clear: Clear::Command("npm", &["cache"]),
1583 note: None,
1584 cap_gb: None,
1585 over_cap: false,
1586 dependents: None,
1587 extra_args: Vec::new(),
1588 },
1589 CacheReport {
1590 manager: "go",
1591 kind: "module cache",
1592 path: PathBuf::from("/b"),
1593 bytes: 4_000,
1594 clear_command: "y".to_string(),
1595 clear: Clear::Directory,
1596 note: None,
1597 cap_gb: None,
1598 over_cap: false,
1599 dependents: None,
1600 extra_args: Vec::new(),
1601 },
1602 ];
1603 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1604 assert_eq!(reports[0].manager, "go");
1605 }
1606
1607 #[test]
1608 fn every_probe_clears_with_the_command_it_prints() {
1609 for probe in PROBES {
1612 let printed = probe.clear_command;
1613 match probe.clear {
1614 Clear::Command(program, args) => {
1615 assert!(
1616 printed.starts_with(program),
1617 "{} {} prints `{printed}` but runs `{program}`",
1618 probe.manager,
1619 probe.kind
1620 );
1621 for arg in args {
1622 assert!(
1625 printed.contains(arg.trim_matches('"')),
1626 "{} {} prints `{printed}` but passes `{arg}`",
1627 probe.manager,
1628 probe.kind
1629 );
1630 }
1631 }
1632 Clear::Directory | Clear::Manual { .. } => assert!(
1636 printed.contains("rm -rf") || printed.contains("Remove-Item"),
1637 "{} {} deletes a directory but prints `{printed}`",
1638 probe.manager,
1639 probe.kind
1640 ),
1641 }
1642 }
1643 }
1644
1645 #[test]
1646 fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
1647 let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
1651 assert!(!maven.is_empty(), "maven is no longer reported at all");
1652 for probe in maven {
1653 assert!(
1654 matches!(probe.clear, Clear::Manual { .. }),
1655 "maven {} would be emptied by dev-prune",
1656 probe.kind
1657 );
1658 }
1659 }
1660
1661 #[test]
1662 fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
1663 let dir = tempfile::tempdir().unwrap();
1667 let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
1668 std::fs::write(&artifact, b"nowhere else").unwrap();
1669
1670 let outcome = clear_one(&CacheReport {
1671 manager: "maven",
1672 kind: "local repository",
1673 path: dir.path().to_path_buf(),
1674 bytes: 12,
1675 clear_command: MAVEN_REPO_CLEAR.to_string(),
1676 clear: Clear::Manual { why: MAVEN_MANUAL },
1677 note: None,
1678 cap_gb: None,
1679 over_cap: false,
1680 dependents: None,
1681 extra_args: Vec::new(),
1682 });
1683
1684 assert!(artifact.exists(), "the store was emptied after all");
1685 assert!(
1686 outcome.problem.is_some(),
1687 "it reported success without doing anything"
1688 );
1689 }
1690
1691 #[test]
1692 fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
1693 let err = run_clear("maven", false, false, true, true, false).unwrap_err();
1696 assert!(
1697 err.downcast_ref::<crate::UsageError>().is_some(),
1698 "expected a usage error, got: {err:#}"
1699 );
1700 let text = format!("{err}");
1701 assert!(
1702 text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
1703 "the refusal names neither the reason nor the command: {text}"
1704 );
1705 }
1706
1707 #[test]
1708 fn every_manager_in_the_report_can_be_named_to_clear() {
1709 let names = known_managers();
1710 for probe in PROBES {
1711 assert!(
1712 names.contains(&probe.manager),
1713 "{} is reported but `devp caches clear {}` would not find it",
1714 probe.manager,
1715 probe.manager
1716 );
1717 }
1718 let mut sorted = names.clone();
1721 sorted.sort_unstable();
1722 sorted.dedup();
1723 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
1724 }
1725
1726 #[test]
1727 fn an_unknown_manager_is_a_usage_error() {
1728 let err = run_clear("nonesuch", false, false, true, true, false).unwrap_err();
1730 assert!(err.downcast_ref::<crate::UsageError>().is_some());
1731 }
1732
1733 #[test]
1734 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
1735 let err = run_clear("npm", false, false, false, false, true).unwrap_err();
1736 assert!(err.downcast_ref::<crate::UsageError>().is_some());
1737 }
1738
1739 #[test]
1740 fn removing_a_directory_reports_nothing_when_it_worked() {
1741 let dir = tempfile::tempdir().unwrap();
1742 let cache = dir.path().join("cache");
1743 std::fs::create_dir(&cache).unwrap();
1744 std::fs::write(cache.join("blob"), b"x").unwrap();
1745
1746 assert!(remove_cache_dir(&cache).is_none());
1747 assert!(!cache.exists());
1748 assert!(remove_cache_dir(&cache).is_none());
1751 }
1752
1753 #[test]
1754 fn clearing_a_directory_reports_what_actually_went() {
1755 let dir = tempfile::tempdir().unwrap();
1756 let cache = dir.path().join("store");
1757 std::fs::create_dir(&cache).unwrap();
1758 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
1759 let before = adapters::dir_size(&cache);
1760
1761 let outcome = clear_one(&CacheReport {
1762 manager: "cargo",
1763 kind: "registry cache",
1764 path: cache.clone(),
1765 bytes: before,
1766 clear_command: "rm -rf".to_string(),
1767 clear: Clear::Directory,
1768 note: None,
1769 cap_gb: None,
1770 over_cap: false,
1771 dependents: None,
1772 extra_args: Vec::new(),
1773 });
1774
1775 assert!(outcome.problem.is_none());
1776 assert_eq!(outcome.after, 0);
1777 assert_eq!(outcome.freed(), before);
1780 assert!(!cache.exists());
1781 }
1782
1783 #[test]
1784 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
1785 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
1788 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
1789 }
1790
1791 fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
1793 CacheReport {
1794 manager,
1795 kind,
1796 path: PathBuf::from("/cache").join(manager).join(kind),
1797 bytes: gib * crate::constants::BYTES_PER_GIB,
1798 clear_command: "x".to_string(),
1799 clear: Clear::Directory,
1800 note: None,
1801 cap_gb: None,
1802 over_cap: false,
1803 dependents: None,
1804 extra_args: Vec::new(),
1805 }
1806 }
1807
1808 fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
1810 Dependents {
1811 repositories,
1812 by_manager: counts.iter().copied().collect(),
1813 }
1814 }
1815
1816 #[test]
1817 fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
1818 let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
1823 apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
1824
1825 assert_eq!(reports[0].dependents, Some(2));
1826 assert_eq!(
1827 reports[1].dependents, None,
1828 "pip has no adapter of its name, so there is nothing to count"
1829 );
1830 }
1831
1832 #[test]
1833 fn no_registry_leaves_every_count_unanswered() {
1834 let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
1837 apply_dependents(&mut reports, None);
1838 assert!(reports.iter().all(|r| r.dependents.is_none()));
1839 }
1840
1841 #[test]
1842 fn a_manager_nothing_uses_is_a_counted_zero() {
1843 let mut reports = vec![row("go", "module cache", 3)];
1846 apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
1847 assert_eq!(reports[0].dependents, Some(0));
1848 assert!(
1849 used_by(&reports[0], 0, None, &manager_totals(&reports))
1850 .contains("no registered repository uses go")
1851 );
1852 }
1853
1854 #[test]
1855 fn the_per_repository_share_is_the_managers_whole_footprint() {
1856 let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
1860 let line = used_by(
1861 &reports[0],
1862 2,
1863 Some(&counted(2, &[("cargo", 2)])),
1864 &manager_totals(&reports),
1865 );
1866 assert!(
1867 line.contains("cargo is used by 2 of 2 registered repositories")
1868 && line.contains("6 GiB"),
1869 "{line}"
1870 );
1871 }
1872
1873 #[test]
1874 fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
1875 let dir = tempfile::tempdir().unwrap();
1879 let nested = dir.path().join("a").join("b");
1880 std::fs::create_dir_all(&nested).unwrap();
1881
1882 let root = volume_root(&nested).expect("a real directory sits on some filesystem");
1883 assert!(
1884 nested.starts_with(&root),
1885 "{} is not under {}",
1886 nested.display(),
1887 root.display()
1888 );
1889 assert!(root.is_dir(), "{} is not a directory", root.display());
1890 }
1891
1892 #[cfg(windows)]
1893 #[test]
1894 fn a_windows_volume_root_is_the_drive_and_nothing_more() {
1895 let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
1899 assert_eq!(root, PathBuf::from("V:\\"));
1900 assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
1901 }
1902
1903 #[test]
1904 fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
1905 let dir = tempfile::tempdir().unwrap();
1908 let a = dir.path().join("one");
1909 let b = dir.path().join("two");
1910 std::fs::create_dir_all(&a).unwrap();
1911 std::fs::create_dir_all(&b).unwrap();
1912
1913 assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
1914 assert!(volume_roots(&[]).is_empty());
1915 }
1916
1917 #[test]
1918 fn a_volume_stores_printed_command_is_the_one_that_runs() {
1919 let dir = tempfile::tempdir().unwrap();
1924 let store = dir.path().join(".pnpm-store");
1925 std::fs::create_dir_all(&store).unwrap();
1926
1927 let report = volume_store_report(store.clone());
1928 let named = output::clean_path(&store);
1929 assert_eq!(
1930 report.extra_args,
1931 vec!["--store-dir".to_string(), named.clone()]
1932 );
1933 assert!(
1934 report.clear_command.contains(&named),
1935 "the printed command does not name the store: {}",
1936 report.clear_command
1937 );
1938 assert!(matches!(
1939 report.clear,
1940 Clear::Command("pnpm", ["store", "prune"])
1941 ));
1942 }
1943
1944 #[test]
1945 fn only_a_path_with_a_space_in_it_is_quoted() {
1946 assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
1950 assert_eq!(
1951 shell_arg("/mnt/my data/.pnpm-store"),
1952 "\"/mnt/my data/.pnpm-store\""
1953 );
1954 }
1955
1956 #[test]
1957 fn a_cap_is_measured_against_the_managers_whole_footprint() {
1958 let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
1962 apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
1963 assert!(
1964 reports.iter().all(|r| r.over_cap),
1965 "both rows belong to the manager that went over"
1966 );
1967 assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
1968 }
1969
1970 #[test]
1971 fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
1972 let mut reports = vec![row("npm", "cache", 3)];
1973 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
1974 assert_eq!(reports[0].cap_gb, Some(10));
1977 assert!(!reports[0].over_cap);
1978 }
1979
1980 #[test]
1981 fn a_manager_with_no_cap_is_never_called_too_big() {
1982 let mut reports = vec![row("uv", "cache", 40)];
1985 apply_caps(&mut reports, &BTreeMap::new());
1986 assert_eq!(reports[0].cap_gb, None);
1987 assert!(!reports[0].over_cap);
1988 }
1989
1990 #[test]
1991 fn one_managers_cap_says_nothing_about_another() {
1992 let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
1993 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
1994 assert!(reports[0].over_cap);
1995 assert!(
1996 !reports[1].over_cap,
1997 "go has no cap and did not acquire npm's"
1998 );
1999 }
2000
2001 #[test]
2002 fn exactly_at_the_cap_is_not_over_it() {
2003 let mut reports = vec![row("pnpm", "store", 10)];
2006 apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
2007 assert!(!reports[0].over_cap);
2008 }
2009
2010 #[test]
2011 fn every_cache_manager_answers_to_its_own_name() {
2012 for probe in PROBES {
2015 assert!(
2016 is_cache_manager(probe.manager),
2017 "{} is reported but cannot be capped",
2018 probe.manager
2019 );
2020 }
2021 assert!(!is_cache_manager("dev-prune-no-such-manager"));
2022 }
2023}