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 let engines = container_summary(!json_output);
422
423 if json_output {
424 return json::emit(&json::caches_document(
425 &reports,
426 deps.as_ref().map(|d| d.repositories),
427 &engines,
428 ));
429 }
430
431 print_report(&reports, deps.as_ref());
432 crate::commands::containers::print_summary(&engines);
433 Ok(())
434}
435
436fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
438 let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
439 let engines = crate::commands::containers::collect(None);
440 if let Some(pb) = pb {
441 pb.finish_and_clear();
442 }
443 engines
444}
445
446fn caps() -> BTreeMap<String, u64> {
451 crate::config::Registry::load()
452 .map(|r| r.settings.cache_max_gb)
453 .unwrap_or_default()
454}
455
456fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
461 let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
462 for r in reports.iter() {
463 *totals.entry(r.manager).or_default() += r.bytes;
464 }
465 for r in reports.iter_mut() {
466 let Some(&gb) = caps.get(r.manager) else {
467 continue;
468 };
469 r.cap_gb = Some(gb);
470 r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
471 > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
472 }
473}
474
475struct Registered {
481 paths: Vec<PathBuf>,
483 scan_depth: usize,
485}
486
487fn registered() -> Option<Registered> {
495 let registry = crate::config::Registry::load().ok()?;
496 let paths: Vec<PathBuf> = registry
497 .repositories
498 .keys()
499 .filter(|p| p.exists())
500 .cloned()
501 .collect();
502 if paths.is_empty() {
503 return None;
504 }
505 Some(Registered {
506 paths,
507 scan_depth: registry.settings.scan_depth,
508 })
509}
510
511struct Dependents {
519 repositories: usize,
522 by_manager: BTreeMap<&'static str, usize>,
528}
529
530fn dependents(reg: &Registered, spinner: bool) -> Dependents {
536 let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
537
538 let mut by_manager: BTreeMap<&'static str, usize> = PROBES
544 .iter()
545 .map(|p| p.manager)
546 .filter(|m| adapters::is_adapter_name(m))
547 .map(|m| (m, 0))
548 .collect();
549
550 for path in ®.paths {
551 let depth = crate::workspace::clamp_depth(
556 crate::config::PerRepoConfig::load_with_diagnostics(path)
557 .ok()
558 .flatten()
559 .and_then(|c| c.scan_depth)
560 .unwrap_or(reg.scan_depth),
561 );
562 let mut here: HashSet<&'static str> = HashSet::new();
563 for project in crate::workspace::discover_all_to_depth(path, depth) {
564 for adapter in &project.adapters {
565 here.insert(adapter.name());
566 }
567 }
568 for (manager, count) in by_manager.iter_mut() {
569 if here.contains(manager) {
570 *count += 1;
571 }
572 }
573 }
574
575 if let Some(pb) = pb {
576 pb.finish_and_clear();
577 }
578
579 Dependents {
580 repositories: reg.paths.len(),
581 by_manager,
582 }
583}
584
585fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
587 let Some(deps) = deps else {
588 return;
589 };
590 for r in reports.iter_mut() {
591 r.dependents = deps.by_manager.get(r.manager).copied();
592 }
593}
594
595fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, u64> {
600 let mut totals: BTreeMap<&'static str, u64> = BTreeMap::new();
601 for r in reports {
602 *totals.entry(r.manager).or_default() += r.bytes;
603 }
604 totals
605}
606
607fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
609 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
610 let from = query_dir();
611
612 let mut seen: HashSet<PathBuf> = HashSet::new();
613 let mut reports = Vec::new();
614
615 for probe in PROBES {
616 let Some(path) = locate(probe, &from) else {
617 continue;
618 };
619 let path = path.canonicalize().unwrap_or(path);
626 if !seen.insert(path.clone()) {
627 continue;
628 }
629 reports.push(CacheReport {
630 manager: probe.manager,
631 kind: probe.kind,
632 bytes: adapters::dir_size(&path),
633 path,
634 clear_command: probe.clear_command.to_string(),
635 clear: probe.clear,
636 note: probe.note,
637 cap_gb: None,
638 over_cap: false,
639 dependents: None,
640 extra_args: Vec::new(),
641 });
642 }
643
644 for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
647 if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
648 continue;
649 }
650 reports.push(volume_store_report(store));
651 }
652
653 if let Some(pb) = pb {
654 pb.finish_and_clear();
655 }
656
657 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
658 reports
659}
660
661const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
663 this is the store for the projects on this volume";
664
665fn volume_store_report(store: PathBuf) -> CacheReport {
671 let named = output::clean_path(&store);
672 CacheReport {
673 manager: "pnpm",
674 kind: "store",
675 bytes: adapters::dir_size(&store),
676 clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
677 extra_args: vec!["--store-dir".to_string(), named],
678 path: store,
679 clear: Clear::Command("pnpm", &["store", "prune"]),
680 note: Some(PNPM_VOLUME_NOTE),
681 cap_gb: None,
682 over_cap: false,
683 dependents: None,
684 }
685}
686
687fn shell_arg(named: &str) -> String {
692 if named.contains(' ') {
693 format!("\"{named}\"")
694 } else {
695 named.to_string()
696 }
697}
698
699fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
714 let mut roots = volume_roots(repos);
718 if let Ok(here) = std::env::current_dir()
719 && let Some(root) = volume_root(&here)
720 && !roots.contains(&root)
721 {
722 roots.push(root);
723 }
724 roots
725 .into_iter()
726 .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
727 .filter(|store| store.is_dir())
728 .collect()
729}
730
731fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
733 let mut roots: Vec<PathBuf> = Vec::new();
734 for repo in repos {
735 if let Some(root) = volume_root(repo)
736 && !roots.contains(&root)
737 {
738 roots.push(root);
739 }
740 }
741 roots
742}
743
744#[cfg(unix)]
751fn volume_root(path: &Path) -> Option<PathBuf> {
752 use std::os::unix::fs::MetadataExt;
753
754 let dev = std::fs::metadata(path).ok()?.dev();
755 let mut root = path.to_path_buf();
756 for ancestor in path.ancestors().skip(1) {
757 match std::fs::metadata(ancestor) {
758 Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
759 _ => break,
760 }
761 }
762 Some(root)
763}
764
765#[cfg(windows)]
772fn volume_root(path: &Path) -> Option<PathBuf> {
773 use std::path::Component;
774
775 let mut components = path.components();
776 let Some(Component::Prefix(prefix)) = components.next() else {
777 return None;
778 };
779 if components.next() != Some(Component::RootDir) {
780 return None;
781 }
782 let mut root = PathBuf::from(prefix.as_os_str());
783 root.push(Component::RootDir.as_os_str());
784 Some(root)
785}
786
787fn query_dir() -> PathBuf {
794 dirs::home_dir()
795 .or_else(|| std::env::current_dir().ok())
796 .unwrap_or_else(|| PathBuf::from("."))
797}
798
799fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
801 if let Some((program, args)) = probe.query
802 && adapters::binary_available(program)
803 {
804 let answered = adapters::capture_command_with_timeout(
805 program,
806 args,
807 from,
808 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
809 )
810 .ok()
811 .and_then(|raw| path_from_output(&raw))
812 .filter(|p| p.is_dir());
813 if answered.is_some() {
814 return answered;
815 }
816 }
817
818 fallbacks(probe.manager, probe.kind)
823 .into_iter()
824 .find(|p| p.is_dir())
825}
826
827fn path_from_output(raw: &str) -> Option<PathBuf> {
832 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
833 let line = line.trim_matches('"');
834 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
837 return None;
838 }
839 Some(PathBuf::from(line))
840}
841
842fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
844 let home = dirs::home_dir();
845 let local = dirs::data_local_dir();
846 let cache = dirs::cache_dir();
847 let under = |base: &Option<PathBuf>, rel: &str| {
851 base.as_ref()
852 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
853 };
854
855 let candidates = match (manager, kind) {
856 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
859 ("pnpm", _) => vec![
860 under(&local, "pnpm/store"),
861 under(&home, ".local/share/pnpm/store"),
862 under(&home, "Library/pnpm/store"),
863 under(&home, ".pnpm-store"),
864 ],
865 ("yarn", _) => vec![
866 under(&home, ".yarn/berry/cache"),
867 under(&local, "Yarn/Cache"),
868 under(&cache, "yarn"),
869 ],
870 ("bun", _) => vec![under(&home, ".bun/install/cache")],
871 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
872 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
873 ("conda", _) => vec![
881 std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
882 std::env::var_os("CONDA_EXE")
883 .map(PathBuf::from)
884 .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
885 .map(|root| root.join("pkgs")),
886 under(&home, "miniconda3/pkgs"),
887 under(&home, "anaconda3/pkgs"),
888 under(&home, "miniforge3/pkgs"),
889 under(&home, "mambaforge/pkgs"),
890 under(&home, ".conda/pkgs"),
891 ],
892 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
893 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
894 ("go", "module cache") => vec![
895 std::env::var_os("GOMODCACHE").map(PathBuf::from),
896 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
897 under(&home, "go/pkg/mod"),
898 ],
899 ("go", _) => vec![
900 std::env::var_os("GOCACHE").map(PathBuf::from),
901 under(&cache, "go-build"),
902 under(&local, "go-build"),
903 ],
904 ("maven", _) => vec![under(&home, ".m2/repository")],
905 ("gradle", "caches") => vec![
907 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
908 under(&home, ".gradle/caches"),
909 ],
910 ("gradle", _) => vec![
911 std::env::var_os("GRADLE_USER_HOME")
912 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
913 under(&home, ".gradle/wrapper/dists"),
914 ],
915 ("nuget", _) => vec![
916 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
917 under(&home, ".nuget/packages"),
918 ],
919 ("vcpkg", _) => vec![
920 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
921 under(&local, "vcpkg/archives"),
922 under(&cache, "vcpkg/archives"),
923 ],
924 ("conan", _) => vec![
927 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
928 under(&home, ".conan2/p"),
929 ],
930 ("composer", _) => vec![
933 std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
934 std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
935 under(&local, "Composer"),
936 under(&cache, "composer"),
937 under(&home, ".composer/cache"),
938 ],
939 ("cocoapods", _) => vec![
943 std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
944 under(&home, "Library/Caches/CocoaPods"),
945 ],
946 ("hex", _) => vec![
949 std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
950 under(&home, ".hex/packages"),
951 under(&cache, "hex/packages"),
952 ],
953 _ => vec![],
954 };
955
956 candidates.into_iter().flatten().collect()
957}
958
959fn cargo_home() -> PathBuf {
961 std::env::var_os("CARGO_HOME")
962 .map(PathBuf::from)
963 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
964 .unwrap_or_else(|| PathBuf::from(".cargo"))
965}
966
967fn print_report(reports: &[CacheReport], deps: Option<&Dependents>) {
968 output::print_header("Package manager caches");
969
970 if reports.is_empty() {
971 println!();
972 output::print_info("No package manager caches found on this machine.");
973 return;
974 }
975
976 println!();
977 let totals = manager_totals(reports);
978 let mut counted: HashSet<&'static str> = HashSet::new();
981 for r in reports {
982 let label = format!("{} {}", r.manager, r.kind);
983 println!(
984 " {:<30} {:>10} {}",
985 label,
986 output::format_bytes(r.bytes),
987 output::clean_path(&r.path)
988 );
989 println!(" {:<30} {:>10} clear: {}", "", "", r.clear_command);
990 if let Some(note) = r.note {
991 println!(" {:<30} {:>10} {}", "", "", note);
992 }
993 if r.over_cap
994 && let Some(gb) = r.cap_gb
995 {
996 println!(
997 " {:<30} {:>10} over the {gb} GiB cap you set for {}",
998 "", "", r.manager
999 );
1000 }
1001 if let Some(n) = r.dependents
1002 && counted.insert(r.manager)
1003 {
1004 println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
1005 }
1006 println!();
1007 }
1008
1009 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1010 println!(
1011 " {:<30} {:>10} across {} {}",
1012 "Total",
1013 output::format_bytes(total),
1014 reports.len(),
1015 output::plural(reports.len(), "cache", "caches")
1016 );
1017
1018 if reports.iter().any(|r| r.over_cap) {
1019 println!();
1020 output::print_info(
1021 "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1022 --over-cap all` empties exactly those and leaves the rest alone.",
1023 );
1024 }
1025
1026 if reports.iter().any(|r| r.dependents == Some(0)) {
1027 println!();
1028 output::print_info(
1029 "The caches above that no registered repository uses were filled for projects that \
1030 are not here any more. `devp caches clear --unused all` empties exactly those. It \
1031 counts only repositories dev-prune knows about, so `devp link` anything you keep \
1032 outside the registry before trusting the number.",
1033 );
1034 }
1035
1036 println!();
1037 output::print_info(
1038 "Nothing above was deleted. A cache is shared by every project on the machine, so \
1039 no single repository's lockfile can prove it is recoverable — and it is what \
1040 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1041 will ever touch one. When you want the space more than the speed, run a clear \
1042 command yourself, or `devp caches clear <manager>`.",
1043 );
1044}
1045
1046fn used_by(
1053 r: &CacheReport,
1054 dependents: usize,
1055 deps: Option<&Dependents>,
1056 totals: &BTreeMap<&'static str, u64>,
1057) -> String {
1058 if dependents == 0 {
1059 return format!("no registered repository uses {}", r.manager);
1060 }
1061 let registered = deps.map_or(dependents, |d| d.repositories);
1062 let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1063 format!(
1068 "{} is used by {dependents} of {registered} registered {} · {} each",
1069 r.manager,
1070 output::plural(registered, "repository", "repositories"),
1071 output::format_bytes(total / dependents as u64)
1072 )
1073}
1074
1075pub struct ClearOutcome {
1077 pub manager: &'static str,
1079 pub kind: &'static str,
1081 pub path: PathBuf,
1083 pub before: u64,
1085 pub after: u64,
1089 pub problem: Option<String>,
1091}
1092
1093impl ClearOutcome {
1094 pub fn freed(&self) -> u64 {
1096 self.before.saturating_sub(self.after)
1097 }
1098}
1099
1100pub fn run_clear(
1107 target: &str,
1108 over_cap: bool,
1109 unused: bool,
1110 yes: bool,
1111 dry_run: bool,
1112 json_output: bool,
1113) -> Result<()> {
1114 let all = target.eq_ignore_ascii_case("all");
1115 if !all && crate::commands::containers::is_engine(target) {
1120 return Err(anyhow::Error::new(crate::UsageError(format!(
1121 "dev-prune reports {target}'s disk use and never deletes it — an image has no \
1122 lockfile to prove it can be rebuilt, and a volume cannot be rebuilt at all. \
1123 `devp caches {target}` shows what it is holding and prints the prune commands \
1124 for you to run."
1125 ))));
1126 }
1127 if !all
1128 && !PROBES
1129 .iter()
1130 .any(|p| p.manager.eq_ignore_ascii_case(target))
1131 {
1132 return Err(anyhow::Error::new(crate::UsageError(format!(
1133 "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1134 known_managers().join(", ")
1135 ))));
1136 }
1137 if !all
1142 && let Some(probe) = manual_only(target)
1143 && let Clear::Manual { why } = probe.clear
1144 {
1145 return Err(anyhow::Error::new(crate::UsageError(format!(
1146 "{why} The command is: {}",
1147 probe.clear_command
1148 ))));
1149 }
1150
1151 if json_output && !yes && !dry_run {
1154 return Err(anyhow::Error::new(crate::UsageError(
1155 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1156 to see what would go."
1157 .to_string(),
1158 )));
1159 }
1160
1161 let reg = registered();
1165 let mut measured = collect(!json_output, reg.as_ref());
1166 apply_caps(&mut measured, &caps());
1167
1168 let deps = if unused {
1172 let Some(reg) = reg.as_ref() else {
1173 return Err(anyhow::Error::new(crate::UsageError(
1174 "`--unused` empties the caches no registered repository needs, and there are no \
1175 registered repositories on this disk to check against — every cache would look \
1176 unused. Register what you keep with `devp link` first."
1177 .to_string(),
1178 )));
1179 };
1180 Some(dependents(reg, !json_output))
1181 } else {
1182 None
1183 };
1184 apply_dependents(&mut measured, deps.as_ref());
1185
1186 let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1190 .into_iter()
1191 .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
1192 .filter(|r| !over_cap || r.over_cap)
1193 .filter(|r| !unused || r.dependents == Some(0))
1194 .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
1195
1196 if reports.is_empty() {
1197 if json_output {
1198 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1199 }
1200 if unused {
1201 output::print_info(
1202 "Every cache on this machine is used by at least one registered repository, or \
1203 is one dev-prune cannot attribute to any — nothing to clear.",
1204 );
1205 return Ok(());
1206 }
1207 if over_cap {
1208 output::print_info(if caps().is_empty() {
1212 "No cache size caps are set, so nothing is over one. Set them with `devp config \
1213 set cache_max_gb npm=10,uv=10`, or in `devp config wizard`."
1214 } else {
1215 "Every capped cache is under its cap — nothing to clear."
1216 });
1217 return Ok(());
1218 }
1219 output::print_info(&format!(
1220 "No {} cache on this machine — nothing to clear.",
1221 if all { "package manager" } else { target }
1222 ));
1223 return Ok(());
1224 }
1225
1226 if dry_run {
1227 if json_output {
1228 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1229 }
1230 print_kept(&kept);
1231 print_clear_plan(&reports, true);
1232 return Ok(());
1233 }
1234
1235 if !json_output {
1236 print_kept(&kept);
1237 print_clear_plan(&reports, false);
1238 if !confirm_clear(yes) {
1239 output::print_info("Nothing was cleared.");
1240 return Ok(());
1241 }
1242 }
1243
1244 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
1245 record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
1249
1250 if json_output {
1251 json::emit(&json::caches_clear_document(&outcomes, &kept))?;
1252 } else {
1253 print_clear_result(&outcomes);
1254 }
1255
1256 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
1259 if failed > 0 {
1260 anyhow::bail!(
1261 "{failed} {} could not be cleared.",
1262 output::plural(failed, "cache", "caches")
1263 );
1264 }
1265 Ok(())
1266}
1267
1268fn manual_only(target: &str) -> Option<&'static Probe> {
1273 let matching: Vec<&Probe> = PROBES
1274 .iter()
1275 .filter(|p| p.manager.eq_ignore_ascii_case(target))
1276 .collect();
1277 if matching.is_empty()
1278 || matching
1279 .iter()
1280 .any(|p| !matches!(p.clear, Clear::Manual { .. }))
1281 {
1282 return None;
1283 }
1284 matching.first().copied()
1285}
1286
1287pub fn is_cache_manager(name: &str) -> bool {
1289 PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
1290}
1291
1292pub fn known_managers() -> Vec<&'static str> {
1294 let mut names: Vec<&'static str> = Vec::new();
1295 for probe in PROBES {
1296 if !names.contains(&probe.manager) {
1297 names.push(probe.manager);
1298 }
1299 }
1300 names
1301}
1302
1303fn clear_one(report: &CacheReport) -> ClearOutcome {
1305 let problem = match report.clear {
1306 Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
1307 Clear::Directory => remove_cache_dir(&report.path),
1308 Clear::Manual { why } => Some(why.to_string()),
1312 };
1313 ClearOutcome {
1314 manager: report.manager,
1315 kind: report.kind,
1316 path: report.path.clone(),
1317 before: report.bytes,
1318 after: adapters::dir_size(&report.path),
1321 problem,
1322 }
1323}
1324
1325fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
1327 if !adapters::binary_available(program) {
1328 return Some(format!(
1329 "`{program}` is not on PATH — only it knows what in this cache is still \
1330 referenced, so dev-prune will not delete the directory in its place."
1331 ));
1332 }
1333 let mut all: Vec<&str> = args.to_vec();
1336 all.extend(extra.iter().map(String::as_str));
1337 adapters::run_command_with_timeout(
1338 program,
1339 &all,
1340 &query_dir(),
1341 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
1342 )
1343 .err()
1344 .map(|e| format!("{e:#}"))
1345}
1346
1347fn remove_cache_dir(path: &Path) -> Option<String> {
1349 std::fs::remove_dir_all(path)
1353 .or_else(|_| {
1354 std::thread::sleep(std::time::Duration::from_millis(250));
1355 std::fs::remove_dir_all(path)
1356 })
1357 .err()
1358 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
1360 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
1361}
1362
1363fn print_kept(kept: &[CacheReport]) {
1365 for r in kept {
1366 let Clear::Manual { why } = r.clear else {
1367 continue;
1368 };
1369 println!();
1370 output::print_info(&format!(
1371 "Keeping {} {} ({} at {}). {why}",
1372 r.manager,
1373 r.kind,
1374 output::format_bytes(r.bytes),
1375 output::clean_path(&r.path)
1376 ));
1377 }
1378}
1379
1380fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
1382 output::print_header(if dry_run {
1383 "Would clear"
1384 } else {
1385 "About to clear"
1386 });
1387
1388 println!();
1389 for r in reports {
1390 println!(
1391 " {:<30} {:>10} {}",
1392 format!("{} {}", r.manager, r.kind),
1393 output::format_bytes(r.bytes),
1394 output::clean_path(&r.path)
1395 );
1396 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
1397 }
1398
1399 println!();
1400 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1401 println!(
1402 " {:<30} {:>10} across {} {}",
1403 "Total",
1404 output::format_bytes(total),
1405 reports.len(),
1406 output::plural(reports.len(), "cache", "caches")
1407 );
1408
1409 println!();
1410 output::print_info(
1411 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
1412 The cost is time: the next install, and the next `devp restore`, in every \
1413 project on this machine.",
1414 );
1415}
1416
1417fn record_cache_clear(bytes: u64) {
1424 if bytes == 0 {
1425 return;
1426 }
1427 if let Ok(mut registry) = crate::config::Registry::load() {
1428 registry.record_cache_clear(bytes);
1429 let _ = registry.save();
1430 }
1431}
1432
1433fn print_clear_result(outcomes: &[ClearOutcome]) {
1435 println!();
1436 for o in outcomes {
1437 let label = format!("{} {}", o.manager, o.kind);
1438 println!(
1439 " {:<30} {:>10} {}",
1440 label,
1441 output::format_bytes(o.freed()),
1442 if o.problem.is_some() {
1443 "not cleared"
1444 } else {
1445 "cleared"
1446 }
1447 );
1448 if let Some(why) = &o.problem {
1449 println!(" {:<30} {:>10} {why}", "", "");
1450 }
1451 }
1452
1453 println!();
1454 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
1455 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
1456}
1457
1458fn confirm_clear(yes: bool) -> bool {
1461 use std::io::{IsTerminal, Write};
1462 if yes {
1463 return true;
1464 }
1465 if !std::io::stdin().is_terminal() {
1466 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
1467 return false;
1468 }
1469 eprint!("Clear them? [y/N]: ");
1473 if std::io::stderr().flush().is_err() {
1474 return false;
1475 }
1476 let mut input = String::new();
1477 if std::io::stdin().read_line(&mut input).is_err() {
1478 return false;
1479 }
1480 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485 use super::*;
1486
1487 #[test]
1488 fn every_probe_can_be_found_without_its_manager_installed() {
1489 for probe in PROBES {
1492 assert!(
1493 !fallbacks(probe.manager, probe.kind).is_empty(),
1494 "{} {} has no conventional location",
1495 probe.manager,
1496 probe.kind
1497 );
1498 }
1499 }
1500
1501 #[test]
1502 fn every_probe_names_the_command_that_clears_it() {
1503 for probe in PROBES {
1504 assert!(
1505 !probe.clear_command.trim().is_empty(),
1506 "{} {} reports a size with no way to act on it",
1507 probe.manager,
1508 probe.kind
1509 );
1510 }
1511 }
1512
1513 #[test]
1514 fn only_five_probed_managers_have_no_adapter_of_the_same_name() {
1515 let orphans: Vec<&str> = PROBES
1520 .iter()
1521 .map(|p| p.manager)
1522 .filter(|m| !adapters::is_adapter_name(m))
1523 .collect::<std::collections::BTreeSet<_>>()
1524 .into_iter()
1525 .collect();
1526 assert_eq!(orphans, ["conan", "conda", "hex", "nuget", "pip"]);
1527 }
1528
1529 #[test]
1530 fn no_two_probes_describe_the_same_cache() {
1531 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
1532 let count = keys.len();
1533 keys.sort_unstable();
1534 keys.dedup();
1535 assert_eq!(keys.len(), count, "two probes share a manager and kind");
1536 }
1537
1538 #[test]
1539 fn a_managers_answer_is_read_off_the_last_line() {
1540 let raw = if cfg!(windows) {
1542 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
1543 } else {
1544 "npm warn config global deprecated\n/home/dev/.npm\n"
1545 };
1546 assert!(path_from_output(raw).is_some());
1547 }
1548
1549 #[test]
1550 fn quoted_paths_lose_their_quotes() {
1551 let raw = if cfg!(windows) {
1552 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
1553 } else {
1554 "\"/opt/go path/pkg/mod\"\n"
1555 };
1556 let path = path_from_output(raw).expect("a quoted path is still a path");
1557 assert!(!path.to_string_lossy().contains('"'));
1558 }
1559
1560 #[test]
1561 fn a_non_answer_is_not_mistaken_for_a_path() {
1562 for raw in [
1565 "",
1566 "\n \n",
1567 "undefined\n",
1568 "not a command\n",
1569 "./relative\n",
1570 ] {
1571 assert!(
1572 path_from_output(raw).is_none(),
1573 "{raw:?} was accepted as a cache path"
1574 );
1575 }
1576 }
1577
1578 #[test]
1579 fn the_cargo_rows_point_inside_the_registry() {
1580 for kind in ["registry cache", "registry sources"] {
1583 let path = fallbacks("cargo", kind).remove(0);
1584 assert!(
1585 path.starts_with(cargo_home().join("registry")),
1586 "{kind} resolved outside the cargo registry: {}",
1587 path.display()
1588 );
1589 }
1590 }
1591
1592 #[test]
1593 fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
1594 let home = dirs::home_dir().expect("a home directory");
1600 let found = fallbacks("conda", "package cache");
1601
1602 for install in [
1603 "miniconda3",
1604 "anaconda3",
1605 "miniforge3",
1606 "mambaforge",
1607 ".conda",
1608 ] {
1609 let want = home.join(install).join("pkgs");
1610 assert!(
1611 found.contains(&want),
1612 "{} is not among conda's conventional locations",
1613 want.display()
1614 );
1615 assert!(
1616 !found.contains(&home.join(install)),
1617 "{} is the installation, not its package cache",
1618 home.join(install).display()
1619 );
1620 }
1621 }
1622
1623 #[test]
1624 fn the_report_is_ordered_by_what_is_worth_clearing() {
1625 let mut reports = [
1626 CacheReport {
1627 manager: "npm",
1628 kind: "cache",
1629 path: PathBuf::from("/a"),
1630 bytes: 10,
1631 clear_command: "x".to_string(),
1632 clear: Clear::Command("npm", &["cache"]),
1633 note: None,
1634 cap_gb: None,
1635 over_cap: false,
1636 dependents: None,
1637 extra_args: Vec::new(),
1638 },
1639 CacheReport {
1640 manager: "go",
1641 kind: "module cache",
1642 path: PathBuf::from("/b"),
1643 bytes: 4_000,
1644 clear_command: "y".to_string(),
1645 clear: Clear::Directory,
1646 note: None,
1647 cap_gb: None,
1648 over_cap: false,
1649 dependents: None,
1650 extra_args: Vec::new(),
1651 },
1652 ];
1653 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1654 assert_eq!(reports[0].manager, "go");
1655 }
1656
1657 #[test]
1658 fn every_probe_clears_with_the_command_it_prints() {
1659 for probe in PROBES {
1662 let printed = probe.clear_command;
1663 match probe.clear {
1664 Clear::Command(program, args) => {
1665 assert!(
1666 printed.starts_with(program),
1667 "{} {} prints `{printed}` but runs `{program}`",
1668 probe.manager,
1669 probe.kind
1670 );
1671 for arg in args {
1672 assert!(
1675 printed.contains(arg.trim_matches('"')),
1676 "{} {} prints `{printed}` but passes `{arg}`",
1677 probe.manager,
1678 probe.kind
1679 );
1680 }
1681 }
1682 Clear::Directory | Clear::Manual { .. } => assert!(
1686 printed.contains("rm -rf") || printed.contains("Remove-Item"),
1687 "{} {} deletes a directory but prints `{printed}`",
1688 probe.manager,
1689 probe.kind
1690 ),
1691 }
1692 }
1693 }
1694
1695 #[test]
1696 fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
1697 let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
1701 assert!(!maven.is_empty(), "maven is no longer reported at all");
1702 for probe in maven {
1703 assert!(
1704 matches!(probe.clear, Clear::Manual { .. }),
1705 "maven {} would be emptied by dev-prune",
1706 probe.kind
1707 );
1708 }
1709 }
1710
1711 #[test]
1712 fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
1713 let dir = tempfile::tempdir().unwrap();
1717 let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
1718 std::fs::write(&artifact, b"nowhere else").unwrap();
1719
1720 let outcome = clear_one(&CacheReport {
1721 manager: "maven",
1722 kind: "local repository",
1723 path: dir.path().to_path_buf(),
1724 bytes: 12,
1725 clear_command: MAVEN_REPO_CLEAR.to_string(),
1726 clear: Clear::Manual { why: MAVEN_MANUAL },
1727 note: None,
1728 cap_gb: None,
1729 over_cap: false,
1730 dependents: None,
1731 extra_args: Vec::new(),
1732 });
1733
1734 assert!(artifact.exists(), "the store was emptied after all");
1735 assert!(
1736 outcome.problem.is_some(),
1737 "it reported success without doing anything"
1738 );
1739 }
1740
1741 #[test]
1742 fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
1743 let err = run_clear("maven", false, false, true, true, false).unwrap_err();
1746 assert!(
1747 err.downcast_ref::<crate::UsageError>().is_some(),
1748 "expected a usage error, got: {err:#}"
1749 );
1750 let text = format!("{err}");
1751 assert!(
1752 text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
1753 "the refusal names neither the reason nor the command: {text}"
1754 );
1755 }
1756
1757 #[test]
1758 fn every_manager_in_the_report_can_be_named_to_clear() {
1759 let names = known_managers();
1760 for probe in PROBES {
1761 assert!(
1762 names.contains(&probe.manager),
1763 "{} is reported but `devp caches clear {}` would not find it",
1764 probe.manager,
1765 probe.manager
1766 );
1767 }
1768 let mut sorted = names.clone();
1771 sorted.sort_unstable();
1772 sorted.dedup();
1773 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
1774 }
1775
1776 #[test]
1777 fn an_unknown_manager_is_a_usage_error() {
1778 let err = run_clear("nonesuch", false, false, true, true, false).unwrap_err();
1780 assert!(err.downcast_ref::<crate::UsageError>().is_some());
1781 }
1782
1783 #[test]
1784 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
1785 let err = run_clear("npm", false, false, false, false, true).unwrap_err();
1786 assert!(err.downcast_ref::<crate::UsageError>().is_some());
1787 }
1788
1789 #[test]
1790 fn removing_a_directory_reports_nothing_when_it_worked() {
1791 let dir = tempfile::tempdir().unwrap();
1792 let cache = dir.path().join("cache");
1793 std::fs::create_dir(&cache).unwrap();
1794 std::fs::write(cache.join("blob"), b"x").unwrap();
1795
1796 assert!(remove_cache_dir(&cache).is_none());
1797 assert!(!cache.exists());
1798 assert!(remove_cache_dir(&cache).is_none());
1801 }
1802
1803 #[test]
1804 fn clearing_a_directory_reports_what_actually_went() {
1805 let dir = tempfile::tempdir().unwrap();
1806 let cache = dir.path().join("store");
1807 std::fs::create_dir(&cache).unwrap();
1808 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
1809 let before = adapters::dir_size(&cache);
1810
1811 let outcome = clear_one(&CacheReport {
1812 manager: "cargo",
1813 kind: "registry cache",
1814 path: cache.clone(),
1815 bytes: before,
1816 clear_command: "rm -rf".to_string(),
1817 clear: Clear::Directory,
1818 note: None,
1819 cap_gb: None,
1820 over_cap: false,
1821 dependents: None,
1822 extra_args: Vec::new(),
1823 });
1824
1825 assert!(outcome.problem.is_none());
1826 assert_eq!(outcome.after, 0);
1827 assert_eq!(outcome.freed(), before);
1830 assert!(!cache.exists());
1831 }
1832
1833 #[test]
1834 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
1835 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
1838 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
1839 }
1840
1841 fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
1843 CacheReport {
1844 manager,
1845 kind,
1846 path: PathBuf::from("/cache").join(manager).join(kind),
1847 bytes: gib * crate::constants::BYTES_PER_GIB,
1848 clear_command: "x".to_string(),
1849 clear: Clear::Directory,
1850 note: None,
1851 cap_gb: None,
1852 over_cap: false,
1853 dependents: None,
1854 extra_args: Vec::new(),
1855 }
1856 }
1857
1858 fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
1860 Dependents {
1861 repositories,
1862 by_manager: counts.iter().copied().collect(),
1863 }
1864 }
1865
1866 #[test]
1867 fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
1868 let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
1873 apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
1874
1875 assert_eq!(reports[0].dependents, Some(2));
1876 assert_eq!(
1877 reports[1].dependents, None,
1878 "pip has no adapter of its name, so there is nothing to count"
1879 );
1880 }
1881
1882 #[test]
1883 fn no_registry_leaves_every_count_unanswered() {
1884 let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
1887 apply_dependents(&mut reports, None);
1888 assert!(reports.iter().all(|r| r.dependents.is_none()));
1889 }
1890
1891 #[test]
1892 fn a_manager_nothing_uses_is_a_counted_zero() {
1893 let mut reports = vec![row("go", "module cache", 3)];
1896 apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
1897 assert_eq!(reports[0].dependents, Some(0));
1898 assert!(
1899 used_by(&reports[0], 0, None, &manager_totals(&reports))
1900 .contains("no registered repository uses go")
1901 );
1902 }
1903
1904 #[test]
1905 fn the_per_repository_share_is_the_managers_whole_footprint() {
1906 let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
1910 let line = used_by(
1911 &reports[0],
1912 2,
1913 Some(&counted(2, &[("cargo", 2)])),
1914 &manager_totals(&reports),
1915 );
1916 assert!(
1917 line.contains("cargo is used by 2 of 2 registered repositories")
1918 && line.contains("6 GiB"),
1919 "{line}"
1920 );
1921 }
1922
1923 #[test]
1924 fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
1925 let dir = tempfile::tempdir().unwrap();
1929 let nested = dir.path().join("a").join("b");
1930 std::fs::create_dir_all(&nested).unwrap();
1931
1932 let root = volume_root(&nested).expect("a real directory sits on some filesystem");
1933 assert!(
1934 nested.starts_with(&root),
1935 "{} is not under {}",
1936 nested.display(),
1937 root.display()
1938 );
1939 assert!(root.is_dir(), "{} is not a directory", root.display());
1940 }
1941
1942 #[cfg(windows)]
1943 #[test]
1944 fn a_windows_volume_root_is_the_drive_and_nothing_more() {
1945 let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
1949 assert_eq!(root, PathBuf::from("V:\\"));
1950 assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
1951 }
1952
1953 #[test]
1954 fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
1955 let dir = tempfile::tempdir().unwrap();
1958 let a = dir.path().join("one");
1959 let b = dir.path().join("two");
1960 std::fs::create_dir_all(&a).unwrap();
1961 std::fs::create_dir_all(&b).unwrap();
1962
1963 assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
1964 assert!(volume_roots(&[]).is_empty());
1965 }
1966
1967 #[test]
1968 fn a_volume_stores_printed_command_is_the_one_that_runs() {
1969 let dir = tempfile::tempdir().unwrap();
1974 let store = dir.path().join(".pnpm-store");
1975 std::fs::create_dir_all(&store).unwrap();
1976
1977 let report = volume_store_report(store.clone());
1978 let named = output::clean_path(&store);
1979 assert_eq!(
1980 report.extra_args,
1981 vec!["--store-dir".to_string(), named.clone()]
1982 );
1983 assert!(
1984 report.clear_command.contains(&named),
1985 "the printed command does not name the store: {}",
1986 report.clear_command
1987 );
1988 assert!(matches!(
1989 report.clear,
1990 Clear::Command("pnpm", ["store", "prune"])
1991 ));
1992 }
1993
1994 #[test]
1995 fn only_a_path_with_a_space_in_it_is_quoted() {
1996 assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
2000 assert_eq!(
2001 shell_arg("/mnt/my data/.pnpm-store"),
2002 "\"/mnt/my data/.pnpm-store\""
2003 );
2004 }
2005
2006 #[test]
2007 fn a_cap_is_measured_against_the_managers_whole_footprint() {
2008 let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2012 apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
2013 assert!(
2014 reports.iter().all(|r| r.over_cap),
2015 "both rows belong to the manager that went over"
2016 );
2017 assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
2018 }
2019
2020 #[test]
2021 fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
2022 let mut reports = vec![row("npm", "cache", 3)];
2023 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2024 assert_eq!(reports[0].cap_gb, Some(10));
2027 assert!(!reports[0].over_cap);
2028 }
2029
2030 #[test]
2031 fn a_manager_with_no_cap_is_never_called_too_big() {
2032 let mut reports = vec![row("uv", "cache", 40)];
2035 apply_caps(&mut reports, &BTreeMap::new());
2036 assert_eq!(reports[0].cap_gb, None);
2037 assert!(!reports[0].over_cap);
2038 }
2039
2040 #[test]
2041 fn one_managers_cap_says_nothing_about_another() {
2042 let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
2043 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2044 assert!(reports[0].over_cap);
2045 assert!(
2046 !reports[1].over_cap,
2047 "go has no cap and did not acquire npm's"
2048 );
2049 }
2050
2051 #[test]
2052 fn exactly_at_the_cap_is_not_over_it() {
2053 let mut reports = vec![row("pnpm", "store", 10)];
2056 apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
2057 assert!(!reports[0].over_cap);
2058 }
2059
2060 #[test]
2061 fn every_cache_manager_answers_to_its_own_name() {
2062 for probe in PROBES {
2065 assert!(
2066 is_cache_manager(probe.manager),
2067 "{} is reported but cannot be capped",
2068 probe.manager
2069 );
2070 }
2071 assert!(!is_cache_manager("dev-prune-no-such-manager"));
2072 }
2073}