1use std::collections::{BTreeMap, HashSet};
42use std::path::{Path, PathBuf};
43
44use anyhow::Result;
45use colored::Colorize as _;
46
47use crate::adapters;
48use crate::constants;
49use crate::i18n;
50use crate::json;
51use crate::output;
52
53pub struct CacheReport {
55 pub manager: &'static str,
57 pub kind: &'static str,
59 pub path: PathBuf,
61 pub bytes: u64,
63 pub clear_command: String,
69 pub clear: Clear,
71 pub note: Option<&'static str>,
73 pub cap_gb: Option<u64>,
78 pub over_cap: bool,
85 pub dependents: Option<usize>,
95 pub extra_args: Vec<String>,
103}
104
105#[derive(Clone, Copy)]
107pub enum Clear {
108 Command(&'static str, &'static [&'static str]),
112 Directory,
115 Manual { why: &'static str },
119}
120
121struct Probe {
123 manager: &'static str,
124 kind: &'static str,
125 query: Option<(&'static str, &'static [&'static str])>,
131 clear_command: &'static str,
132 clear: Clear,
133 note: Option<&'static str>,
134}
135
136#[cfg(windows)]
139const CARGO_CACHE_CLEAR: &str =
140 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
141#[cfg(not(windows))]
142const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
143
144#[cfg(windows)]
145const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
146#[cfg(not(windows))]
147const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
148
149#[cfg(windows)]
154const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
155#[cfg(not(windows))]
156const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
157
158const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
178 download cache: `mvn install` and `install:install-file` write artifacts there \
179 that exist nowhere else, and nothing in the tree tells them apart from the \
180 downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
181 the command; running it is yours to decide.";
182
183#[cfg(windows)]
184const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
185#[cfg(not(windows))]
186const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
187
188#[cfg(windows)]
189const GRADLE_DISTS_CLEAR: &str =
190 r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
191#[cfg(not(windows))]
192const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
193
194#[cfg(windows)]
195const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
196#[cfg(not(windows))]
197const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
198
199#[cfg(windows)]
202const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
203#[cfg(not(windows))]
204const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
205
206const PROBES: &[Probe] = &[
207 Probe {
208 manager: "npm",
209 kind: "cache",
210 query: Some(("npm", &["config", "get", "cache"])),
211 clear_command: "npm cache clean --force",
212 clear: Clear::Command("npm", &["cache", "clean", "--force"]),
213 note: None,
214 },
215 Probe {
216 manager: "pnpm",
217 kind: "store",
218 query: Some(("pnpm", &["store", "path"])),
219 clear_command: "pnpm store prune",
220 clear: Clear::Command("pnpm", &["store", "prune"]),
221 note: Some(
222 "hardlinked into every node_modules it filled; emptying it is what makes the \
223 next pnpm install a download",
224 ),
225 },
226 Probe {
227 manager: "yarn",
228 kind: "cache",
229 query: Some(("yarn", &["cache", "dir"])),
230 clear_command: "yarn cache clean",
231 clear: Clear::Command("yarn", &["cache", "clean"]),
232 note: None,
233 },
234 Probe {
235 manager: "bun",
236 kind: "cache",
237 query: Some(("bun", &["pm", "cache"])),
238 clear_command: "bun pm cache rm",
239 clear: Clear::Command("bun", &["pm", "cache", "rm"]),
240 note: None,
241 },
242 Probe {
243 manager: "uv",
244 kind: "cache",
245 query: Some(("uv", &["cache", "dir"])),
246 clear_command: "uv cache prune",
249 clear: Clear::Command("uv", &["cache", "prune"]),
250 note: None,
251 },
252 Probe {
253 manager: "pip",
254 kind: "cache",
255 query: Some(("pip", &["cache", "dir"])),
256 clear_command: "pip cache purge",
257 clear: Clear::Command("pip", &["cache", "purge"]),
258 note: None,
259 },
260 Probe {
266 manager: "conda",
267 kind: "package cache",
268 query: None,
269 clear_command: "conda clean --packages --tarballs --yes",
270 clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
271 note: Some(
272 "unpacked packages and downloaded archives; conda keeps what its \
273 environments use, except any it linked by symlink rather than hardlink",
274 ),
275 },
276 Probe {
277 manager: "cargo",
278 kind: "registry cache",
279 query: None,
280 clear_command: CARGO_CACHE_CLEAR,
281 clear: Clear::Directory,
282 note: Some("the downloaded .crate archives; clearing them means downloading again"),
283 },
284 Probe {
285 manager: "cargo",
286 kind: "registry sources",
287 query: None,
288 clear_command: CARGO_SRC_CLEAR,
289 clear: Clear::Directory,
290 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
291 },
292 Probe {
293 manager: "go",
294 kind: "module cache",
295 query: Some(("go", &["env", "GOMODCACHE"])),
296 clear_command: "go clean -modcache",
297 clear: Clear::Command("go", &["clean", "-modcache"]),
298 note: None,
299 },
300 Probe {
301 manager: "go",
302 kind: "build cache",
303 query: Some(("go", &["env", "GOCACHE"])),
304 clear_command: "go clean -cache",
305 clear: Clear::Command("go", &["clean", "-cache"]),
306 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
307 },
308 Probe {
313 manager: "maven",
314 kind: "local repository",
315 query: None,
316 clear_command: MAVEN_REPO_CLEAR,
317 clear: Clear::Manual { why: MAVEN_MANUAL },
318 note: Some(
319 "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
320 ),
321 },
322 Probe {
323 manager: "gradle",
324 kind: "caches",
325 query: None,
326 clear_command: GRADLE_CACHE_CLEAR,
327 clear: Clear::Directory,
328 note: Some(
329 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
330 ),
331 },
332 Probe {
333 manager: "gradle",
334 kind: "wrapper distributions",
335 query: None,
336 clear_command: GRADLE_DISTS_CLEAR,
337 clear: Clear::Directory,
338 note: Some(
339 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
340 ),
341 },
342 Probe {
346 manager: "nuget",
347 kind: "global packages",
348 query: None,
349 clear_command: "dotnet nuget locals global-packages --clear",
350 clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
351 note: Some(
352 "every .NET project on the machine restores from here; re-downloaded on the next restore",
353 ),
354 },
355 Probe {
356 manager: "vcpkg",
357 kind: "binary cache",
358 query: None,
359 clear_command: VCPKG_ARCHIVES_CLEAR,
360 clear: Clear::Directory,
361 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
362 },
363 Probe {
364 manager: "conan",
365 kind: "package cache",
366 query: None,
367 clear_command: "conan remove \"*\" --confirm",
368 clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
369 note: Some(
370 "recipes and binaries shared by every Conan project; re-fetched on the next install",
371 ),
372 },
373 Probe {
378 manager: "composer",
379 kind: "cache",
380 query: Some(("composer", &["config", "--global", "cache-dir"])),
381 clear_command: "composer clear-cache",
382 clear: Clear::Command("composer", &["clear-cache"]),
383 note: Some(
384 "downloaded package archives and repository metadata; re-fetched by the next composer install",
385 ),
386 },
387 Probe {
392 manager: "cocoapods",
393 kind: "cache",
394 query: None,
395 clear_command: "pod cache clean --all",
396 clear: Clear::Command("pod", &["cache", "clean", "--all"]),
397 note: Some("downloaded pod sources, re-fetched by the next pod install"),
398 },
399 Probe {
400 manager: "hex",
401 kind: "package cache",
402 query: None,
403 clear_command: HEX_CACHE_CLEAR,
404 clear: Clear::Directory,
405 note: Some(
406 "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
407 ),
408 },
409];
410
411pub fn run(json_output: bool, volume: Option<&str>) -> Result<()> {
413 let volume = volume.map(resolve_volume).transpose()?;
415
416 let reg = registered();
417 let mut reports = collect(!json_output, reg.as_ref());
418 apply_caps(&mut reports, &caps());
419 let deps = reg.as_ref().map(|r| dependents(r, !json_output));
420 apply_dependents(&mut reports, deps.as_ref());
421
422 if let Some(root) = &volume {
427 reports = on_volume(reports, root);
428 }
429
430 let engines = if volume.is_some() {
439 Vec::new()
440 } else {
441 container_summary(!json_output)
442 };
443
444 if json_output {
445 return json::emit(&json::caches_document(
446 &reports,
447 deps.as_ref().map(|d| d.repositories),
448 &engines,
449 volume.as_deref(),
450 ));
451 }
452
453 print_report(&reports, deps.as_ref(), volume.as_deref());
454 crate::commands::containers::print_summary(&engines);
455 Ok(())
456}
457
458fn resolve_volume(arg: &str) -> Result<PathBuf> {
463 let path = absolutize(normalize_volume_arg(arg));
464 volume_root(&path).ok_or_else(|| {
465 anyhow::Error::new(crate::UsageError(format!(
466 "`{arg}` is not a drive or a path on one. Name a drive (`--volume V:`), a \
467 mount point (`--volume /mnt/data`), or any path that sits on the one you \
468 mean."
469 )))
470 })
471}
472
473fn absolutize(path: PathBuf) -> PathBuf {
483 if path.is_absolute() || !path.exists() {
484 return path;
485 }
486 std::path::absolute(&path).unwrap_or(path)
487}
488
489#[cfg(windows)]
496fn normalize_volume_arg(arg: &str) -> PathBuf {
497 let trimmed = arg.trim();
498 let letter = match trimmed.as_bytes() {
499 [c] if c.is_ascii_alphabetic() => Some(*c),
500 [c, b':'] if c.is_ascii_alphabetic() => Some(*c),
501 _ => None,
502 };
503 match letter {
504 Some(c) => PathBuf::from(format!("{}:\\", c.to_ascii_uppercase() as char)),
505 None => PathBuf::from(trimmed),
506 }
507}
508
509#[cfg(unix)]
511fn normalize_volume_arg(arg: &str) -> PathBuf {
512 PathBuf::from(arg.trim())
513}
514
515#[cfg(windows)]
522fn same_volume(a: &Path, b: &Path) -> bool {
523 match (volume_key(a), volume_key(b)) {
524 (Some(a), Some(b)) => a == b,
525 _ => false,
526 }
527}
528
529#[cfg(windows)]
531fn volume_key(path: &Path) -> Option<String> {
532 use std::path::{Component, Prefix};
533
534 let Some(Component::Prefix(prefix)) = path.components().next() else {
535 return None;
536 };
537 Some(match prefix.kind() {
538 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
539 (letter.to_ascii_uppercase() as char).to_string()
540 }
541 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => format!(
542 "{}\\{}",
543 server.to_string_lossy().to_uppercase(),
544 share.to_string_lossy().to_uppercase()
545 ),
546 other => format!("{other:?}").to_uppercase(),
547 })
548}
549
550#[cfg(unix)]
552fn same_volume(a: &Path, b: &Path) -> bool {
553 a == b
554}
555
556fn on_volume(reports: Vec<CacheReport>, root: &Path) -> Vec<CacheReport> {
561 reports
562 .into_iter()
563 .filter(|r| volume_root(&r.path).is_some_and(|v| same_volume(&v, root)))
564 .collect()
565}
566
567fn volume_totals(reports: &[CacheReport]) -> Vec<(String, u64)> {
573 let mut totals: Vec<(String, u64)> = Vec::new();
574 for r in reports {
575 let Some(root) = volume_root(&r.path) else {
576 continue;
577 };
578 let label = output::clean_path(&root);
579 match totals.iter_mut().find(|(l, _)| *l == label) {
580 Some((_, bytes)) => *bytes += r.bytes,
581 None => totals.push((label, r.bytes)),
582 }
583 }
584 totals.sort_by_key(|(_, bytes)| std::cmp::Reverse(*bytes));
585 totals
586}
587
588fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
590 let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
591 let engines = crate::commands::containers::collect(None);
592 if let Some(pb) = pb {
593 pb.finish_and_clear();
594 }
595 engines
596}
597
598fn caps() -> BTreeMap<String, u64> {
603 crate::config::Registry::load()
604 .map(|r| r.settings.cache_max_gb)
605 .unwrap_or_default()
606}
607
608fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
613 let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
614 for r in reports.iter() {
615 *totals.entry(r.manager).or_default() += r.bytes;
616 }
617 for r in reports.iter_mut() {
618 let Some(&gb) = caps.get(r.manager) else {
619 continue;
620 };
621 r.cap_gb = Some(gb);
622 r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
623 > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
624 }
625}
626
627struct Registered {
633 paths: Vec<PathBuf>,
635 scan_depth: usize,
637}
638
639fn registered() -> Option<Registered> {
647 let registry = crate::config::Registry::load().ok()?;
648 let paths: Vec<PathBuf> = registry
649 .repositories
650 .keys()
651 .filter(|p| p.exists())
652 .cloned()
653 .collect();
654 if paths.is_empty() {
655 return None;
656 }
657 Some(Registered {
658 paths,
659 scan_depth: registry.settings.scan_depth,
660 })
661}
662
663struct Dependents {
671 repositories: usize,
674 by_manager: BTreeMap<&'static str, usize>,
680}
681
682fn dependents(reg: &Registered, spinner: bool) -> Dependents {
688 let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
689
690 let mut by_manager: BTreeMap<&'static str, usize> = PROBES
696 .iter()
697 .map(|p| p.manager)
698 .filter(|m| adapters::is_adapter_name(m))
699 .map(|m| (m, 0))
700 .collect();
701
702 for path in ®.paths {
703 let depth = crate::workspace::clamp_depth(
708 crate::config::PerRepoConfig::load_with_diagnostics(path)
709 .ok()
710 .flatten()
711 .and_then(|c| c.scan_depth)
712 .unwrap_or(reg.scan_depth),
713 );
714 let mut here: HashSet<&'static str> = HashSet::new();
715 for project in crate::workspace::discover_all_to_depth(path, depth) {
716 for adapter in &project.adapters {
717 here.insert(adapter.name());
718 }
719 }
720 for (manager, count) in by_manager.iter_mut() {
721 if here.contains(manager) {
722 *count += 1;
723 }
724 }
725 }
726
727 if let Some(pb) = pb {
728 pb.finish_and_clear();
729 }
730
731 Dependents {
732 repositories: reg.paths.len(),
733 by_manager,
734 }
735}
736
737fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
739 let Some(deps) = deps else {
740 return;
741 };
742 for r in reports.iter_mut() {
743 r.dependents = deps.by_manager.get(r.manager).copied();
744 }
745}
746
747fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, u64> {
752 let mut totals: BTreeMap<&'static str, u64> = BTreeMap::new();
753 for r in reports {
754 *totals.entry(r.manager).or_default() += r.bytes;
755 }
756 totals
757}
758
759fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
761 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
762 let from = query_dir();
763
764 let mut seen: HashSet<PathBuf> = HashSet::new();
765 let mut reports = Vec::new();
766
767 for probe in PROBES {
768 let Some(path) = locate(probe, &from) else {
769 continue;
770 };
771 let path = path.canonicalize().unwrap_or(path);
778 if !seen.insert(path.clone()) {
779 continue;
780 }
781 reports.push(CacheReport {
782 manager: probe.manager,
783 kind: probe.kind,
784 bytes: adapters::dir_size(&path),
785 path,
786 clear_command: probe.clear_command.to_string(),
787 clear: probe.clear,
788 note: probe.note,
789 cap_gb: None,
790 over_cap: false,
791 dependents: None,
792 extra_args: Vec::new(),
793 });
794 }
795
796 for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
799 if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
800 continue;
801 }
802 reports.push(volume_store_report(store));
803 }
804
805 if let Some(pb) = pb {
806 pb.finish_and_clear();
807 }
808
809 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
810 reports
811}
812
813const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
815 this is the store for the projects on this volume";
816
817fn volume_store_report(store: PathBuf) -> CacheReport {
823 let named = output::clean_path(&store);
824 CacheReport {
825 manager: "pnpm",
826 kind: "store",
827 bytes: adapters::dir_size(&store),
828 clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
829 extra_args: vec!["--store-dir".to_string(), named],
830 path: store,
831 clear: Clear::Command("pnpm", &["store", "prune"]),
832 note: Some(PNPM_VOLUME_NOTE),
833 cap_gb: None,
834 over_cap: false,
835 dependents: None,
836 }
837}
838
839fn shell_arg(named: &str) -> String {
844 if named.contains(' ') {
845 format!("\"{named}\"")
846 } else {
847 named.to_string()
848 }
849}
850
851fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
866 let mut roots = volume_roots(repos);
870 if let Ok(here) = std::env::current_dir()
871 && let Some(root) = volume_root(&here)
872 && !roots.contains(&root)
873 {
874 roots.push(root);
875 }
876 roots
877 .into_iter()
878 .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
879 .filter(|store| store.is_dir())
880 .collect()
881}
882
883fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
885 let mut roots: Vec<PathBuf> = Vec::new();
886 for repo in repos {
887 if let Some(root) = volume_root(repo)
888 && !roots.contains(&root)
889 {
890 roots.push(root);
891 }
892 }
893 roots
894}
895
896#[cfg(unix)]
903pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
904 use std::os::unix::fs::MetadataExt;
905
906 let dev = std::fs::metadata(path).ok()?.dev();
907 let mut root = path.to_path_buf();
908 for ancestor in path.ancestors().skip(1) {
909 match std::fs::metadata(ancestor) {
910 Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
911 _ => break,
912 }
913 }
914 Some(root)
915}
916
917#[cfg(windows)]
924pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
925 use std::path::Component;
926
927 let mut components = path.components();
928 let Some(Component::Prefix(prefix)) = components.next() else {
929 return None;
930 };
931 if components.next() != Some(Component::RootDir) {
932 return None;
933 }
934 let mut root = PathBuf::from(prefix.as_os_str());
935 root.push(Component::RootDir.as_os_str());
936 Some(root)
937}
938
939fn query_dir() -> PathBuf {
946 dirs::home_dir()
947 .or_else(|| std::env::current_dir().ok())
948 .unwrap_or_else(|| PathBuf::from("."))
949}
950
951fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
953 if let Some((program, args)) = probe.query
954 && adapters::binary_available(program)
955 {
956 let answered = adapters::capture_command_with_timeout(
957 program,
958 args,
959 from,
960 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
961 )
962 .ok()
963 .and_then(|raw| path_from_output(&raw))
964 .filter(|p| p.is_dir());
965 if answered.is_some() {
966 return answered;
967 }
968 }
969
970 fallbacks(probe.manager, probe.kind)
975 .into_iter()
976 .find(|p| p.is_dir())
977}
978
979fn path_from_output(raw: &str) -> Option<PathBuf> {
984 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
985 let line = line.trim_matches('"');
986 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
989 return None;
990 }
991 Some(PathBuf::from(line))
992}
993
994fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
996 let home = dirs::home_dir();
997 let local = dirs::data_local_dir();
998 let cache = dirs::cache_dir();
999 let under = |base: &Option<PathBuf>, rel: &str| {
1003 base.as_ref()
1004 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
1005 };
1006
1007 let candidates = match (manager, kind) {
1008 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
1011 ("pnpm", _) => vec![
1012 under(&local, "pnpm/store"),
1013 under(&home, ".local/share/pnpm/store"),
1014 under(&home, "Library/pnpm/store"),
1015 under(&home, ".pnpm-store"),
1016 ],
1017 ("yarn", _) => vec![
1018 under(&home, ".yarn/berry/cache"),
1019 under(&local, "Yarn/Cache"),
1020 under(&cache, "yarn"),
1021 ],
1022 ("bun", _) => vec![under(&home, ".bun/install/cache")],
1023 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
1024 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
1025 ("conda", _) => vec![
1033 std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
1034 std::env::var_os("CONDA_EXE")
1035 .map(PathBuf::from)
1036 .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
1037 .map(|root| root.join("pkgs")),
1038 under(&home, "miniconda3/pkgs"),
1039 under(&home, "anaconda3/pkgs"),
1040 under(&home, "miniforge3/pkgs"),
1041 under(&home, "mambaforge/pkgs"),
1042 under(&home, ".conda/pkgs"),
1043 ],
1044 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
1045 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
1046 ("go", "module cache") => vec![
1047 std::env::var_os("GOMODCACHE").map(PathBuf::from),
1048 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
1049 under(&home, "go/pkg/mod"),
1050 ],
1051 ("go", _) => vec![
1052 std::env::var_os("GOCACHE").map(PathBuf::from),
1053 under(&cache, "go-build"),
1054 under(&local, "go-build"),
1055 ],
1056 ("maven", _) => vec![under(&home, ".m2/repository")],
1057 ("gradle", "caches") => vec![
1059 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
1060 under(&home, ".gradle/caches"),
1061 ],
1062 ("gradle", _) => vec![
1063 std::env::var_os("GRADLE_USER_HOME")
1064 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
1065 under(&home, ".gradle/wrapper/dists"),
1066 ],
1067 ("nuget", _) => vec![
1068 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
1069 under(&home, ".nuget/packages"),
1070 ],
1071 ("vcpkg", _) => vec![
1072 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
1073 under(&local, "vcpkg/archives"),
1074 under(&cache, "vcpkg/archives"),
1075 ],
1076 ("conan", _) => vec![
1079 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
1080 under(&home, ".conan2/p"),
1081 ],
1082 ("composer", _) => vec![
1085 std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
1086 std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
1087 under(&local, "Composer"),
1088 under(&cache, "composer"),
1089 under(&home, ".composer/cache"),
1090 ],
1091 ("cocoapods", _) => vec![
1095 std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
1096 under(&home, "Library/Caches/CocoaPods"),
1097 ],
1098 ("hex", _) => vec![
1101 std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
1102 under(&home, ".hex/packages"),
1103 under(&cache, "hex/packages"),
1104 ],
1105 _ => vec![],
1106 };
1107
1108 candidates.into_iter().flatten().collect()
1109}
1110
1111fn cargo_home() -> PathBuf {
1113 std::env::var_os("CARGO_HOME")
1114 .map(PathBuf::from)
1115 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
1116 .unwrap_or_else(|| PathBuf::from(".cargo"))
1117}
1118
1119fn print_report(reports: &[CacheReport], deps: Option<&Dependents>, volume: Option<&Path>) {
1120 output::print_header(i18n::t("caches.header"));
1121
1122 if reports.is_empty() {
1123 println!();
1124 match volume {
1125 Some(root) => output::print_info(&format!(
1126 "No package manager cache on this machine sits on {}. They are somewhere \
1127 else — run `devp caches` without `--volume` to see where.",
1128 output::clean_path(root)
1129 )),
1130 None => output::print_info(i18n::t("caches.nothing")),
1131 }
1132 return;
1133 }
1134
1135 println!();
1136 let totals = manager_totals(reports);
1137 let mut counted: HashSet<&'static str> = HashSet::new();
1140 for r in reports {
1141 let label = format!("{} {}", r.manager, r.kind);
1142 println!(
1143 " {:<30} {:>10} {}",
1144 label,
1145 output::format_bytes(r.bytes),
1146 output::clean_path(&r.path)
1147 );
1148 println!(" {:<30} {:>10} clear: {}", "", "", r.clear_command);
1149 if let Some(note) = r.note {
1150 println!(" {:<30} {:>10} {}", "", "", note);
1151 }
1152 if r.over_cap
1153 && let Some(gb) = r.cap_gb
1154 {
1155 println!(
1156 " {:<30} {:>10} over the {gb} GiB cap you set for {}",
1157 "", "", r.manager
1158 );
1159 }
1160 if let Some(n) = r.dependents
1161 && counted.insert(r.manager)
1162 {
1163 println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
1164 }
1165 println!();
1166 }
1167
1168 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1169 println!(
1170 " {:<30} {:>10} across {} {}",
1171 "Total",
1172 output::format_bytes(total),
1173 reports.len(),
1174 output::plural(reports.len(), "cache", "caches")
1175 );
1176
1177 let by_volume = volume_totals(reports);
1183 if by_volume.len() > 1 {
1184 let named = by_volume
1185 .iter()
1186 .map(|(label, bytes)| format!("{label} {}", output::format_bytes(*bytes)))
1187 .collect::<Vec<_>>()
1188 .join(" · ");
1189 println!(" {:<30} {:>10} {named}", "By drive", "");
1190 }
1191
1192 let ranked = costliest_per_repository(reports);
1196 if ranked.len() > 1 {
1197 let named = ranked
1198 .iter()
1199 .take(3)
1200 .map(|(m, b)| format!("{m} {}", output::format_bytes_weighted(*b)))
1201 .collect::<Vec<_>>()
1202 .join(" · ");
1203 println!(" {:<30} {:>10} {named}", "Costliest per repository", "");
1204 }
1205
1206 if reports.iter().any(|r| r.over_cap) {
1207 println!();
1208 output::print_info(
1209 "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1210 --over-cap all` empties exactly those and leaves the rest alone.",
1211 );
1212 }
1213
1214 if reports.iter().any(|r| r.dependents == Some(0)) {
1215 println!();
1216 output::print_info(
1217 "The caches above that no registered repository uses were filled for projects that \
1218 are not here any more. `devp caches clear --unused all` empties exactly those. It \
1219 counts only repositories dev-prune knows about, so `devp link` anything you keep \
1220 outside the registry before trusting the number.",
1221 );
1222 }
1223
1224 if let Some(root) = volume {
1225 println!();
1226 output::print_info(&format!(
1227 "Only the caches on {} are above, and the container engines are not among \
1228 them: an engine reports its own disk from inside a VM image with no path on \
1229 this filesystem, so there is no drive to file it under — `devp caches \
1230 docker` has that number. The clear commands above are not drive-specific \
1231 either. They empty that manager's cache wherever it is, which for every \
1232 manager but pnpm is one place.",
1233 output::clean_path(root)
1234 ));
1235 }
1236
1237 println!();
1238 output::print_info(
1239 "Nothing above was deleted. A cache is shared by every project on the machine, so \
1240 no single repository's lockfile can prove it is recoverable — and it is what \
1241 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1242 will ever touch one. When you want the space more than the speed, run a clear \
1243 command yourself, or `devp caches clear <manager>`.",
1244 );
1245}
1246
1247fn used_by(
1261 r: &CacheReport,
1262 dependents: usize,
1263 deps: Option<&Dependents>,
1264 totals: &BTreeMap<&'static str, u64>,
1265) -> String {
1266 if dependents == 0 {
1267 return format!("no registered repository uses {}", r.manager)
1268 .bold()
1269 .to_string();
1270 }
1271 let registered = deps.map_or(dependents, |d| d.repositories);
1272 let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1273 format!(
1278 "{} is used by {dependents} of {registered} registered {} · {} each",
1279 r.manager,
1280 output::plural(registered, "repository", "repositories"),
1281 output::format_bytes(total / dependents as u64)
1282 )
1283 .bold()
1284 .to_string()
1285}
1286
1287fn costliest_per_repository(reports: &[CacheReport]) -> Vec<(&'static str, u64)> {
1294 let totals = manager_totals(reports);
1295 let mut per: BTreeMap<&'static str, u64> = BTreeMap::new();
1298 for r in reports {
1299 if let Some(n) = r.dependents.filter(|n| *n > 0) {
1300 let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1301 per.insert(r.manager, total / n as u64);
1302 }
1303 }
1304 let mut ranked: Vec<(&'static str, u64)> = per.into_iter().collect();
1305 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
1307 ranked
1308}
1309
1310pub struct ClearOutcome {
1312 pub manager: &'static str,
1314 pub kind: &'static str,
1316 pub path: PathBuf,
1318 pub before: u64,
1320 pub after: u64,
1324 pub problem: Option<String>,
1326}
1327
1328impl ClearOutcome {
1329 pub fn freed(&self) -> u64 {
1331 self.before.saturating_sub(self.after)
1332 }
1333}
1334
1335pub fn run_clear(
1342 target: &str,
1343 over_cap: bool,
1344 unused: bool,
1345 yes: bool,
1346 dry_run: bool,
1347 json_output: bool,
1348) -> Result<()> {
1349 let all = target.eq_ignore_ascii_case("all");
1350 if !all && crate::commands::containers::is_engine(target) {
1355 return Err(anyhow::Error::new(crate::UsageError(format!(
1356 "dev-prune reports {target}'s disk use and never deletes it — an image has no \
1357 lockfile to prove it can be rebuilt, and a volume cannot be rebuilt at all. \
1358 `devp caches {target}` shows what it is holding and prints the prune commands \
1359 for you to run."
1360 ))));
1361 }
1362 if !all
1363 && !PROBES
1364 .iter()
1365 .any(|p| p.manager.eq_ignore_ascii_case(target))
1366 {
1367 return Err(anyhow::Error::new(crate::UsageError(format!(
1368 "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1369 known_managers().join(", ")
1370 ))));
1371 }
1372 if !all
1377 && let Some(probe) = manual_only(target)
1378 && let Clear::Manual { why } = probe.clear
1379 {
1380 return Err(anyhow::Error::new(crate::UsageError(format!(
1381 "{why} The command is: {}",
1382 probe.clear_command
1383 ))));
1384 }
1385
1386 if json_output && !yes && !dry_run {
1389 return Err(anyhow::Error::new(crate::UsageError(
1390 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1391 to see what would go."
1392 .to_string(),
1393 )));
1394 }
1395
1396 let reg = registered();
1400 let mut measured = collect(!json_output, reg.as_ref());
1401 apply_caps(&mut measured, &caps());
1402
1403 let deps = if unused {
1407 let Some(reg) = reg.as_ref() else {
1408 return Err(anyhow::Error::new(crate::UsageError(
1409 "`--unused` empties the caches no registered repository needs, and there are no \
1410 registered repositories on this disk to check against — every cache would look \
1411 unused. Register what you keep with `devp link` first."
1412 .to_string(),
1413 )));
1414 };
1415 Some(dependents(reg, !json_output))
1416 } else {
1417 None
1418 };
1419 apply_dependents(&mut measured, deps.as_ref());
1420
1421 let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1425 .into_iter()
1426 .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
1427 .filter(|r| !over_cap || r.over_cap)
1428 .filter(|r| !unused || r.dependents == Some(0))
1429 .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
1430
1431 if reports.is_empty() {
1432 if json_output {
1433 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1434 }
1435 if unused {
1436 output::print_info(
1437 "Every cache on this machine is used by at least one registered repository, or \
1438 is one dev-prune cannot attribute to any — nothing to clear.",
1439 );
1440 return Ok(());
1441 }
1442 if over_cap {
1443 output::print_info(if caps().is_empty() {
1447 "No cache size caps are set, so nothing is over one. Set them with `devp config \
1448 set cache_max_gb npm=10,uv=10`, or in `devp config wizard`."
1449 } else {
1450 "Every capped cache is under its cap — nothing to clear."
1451 });
1452 return Ok(());
1453 }
1454 output::print_info(&format!(
1455 "No {} cache on this machine — nothing to clear.",
1456 if all { "package manager" } else { target }
1457 ));
1458 return Ok(());
1459 }
1460
1461 if dry_run {
1462 if json_output {
1463 return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1464 }
1465 print_kept(&kept);
1466 print_clear_plan(&reports, true);
1467 return Ok(());
1468 }
1469
1470 if !json_output {
1471 print_kept(&kept);
1472 print_clear_plan(&reports, false);
1473 if !confirm_clear(yes) {
1474 output::print_info("Nothing was cleared.");
1475 return Ok(());
1476 }
1477 }
1478
1479 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
1480 record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
1484
1485 if json_output {
1486 json::emit(&json::caches_clear_document(&outcomes, &kept))?;
1487 } else {
1488 print_clear_result(&outcomes);
1489 }
1490
1491 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
1494 if failed > 0 {
1495 anyhow::bail!(
1496 "{failed} {} could not be cleared.",
1497 output::plural(failed, "cache", "caches")
1498 );
1499 }
1500 Ok(())
1501}
1502
1503fn manual_only(target: &str) -> Option<&'static Probe> {
1508 let matching: Vec<&Probe> = PROBES
1509 .iter()
1510 .filter(|p| p.manager.eq_ignore_ascii_case(target))
1511 .collect();
1512 if matching.is_empty()
1513 || matching
1514 .iter()
1515 .any(|p| !matches!(p.clear, Clear::Manual { .. }))
1516 {
1517 return None;
1518 }
1519 matching.first().copied()
1520}
1521
1522pub fn is_cache_manager(name: &str) -> bool {
1524 PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
1525}
1526
1527pub fn known_managers() -> Vec<&'static str> {
1529 let mut names: Vec<&'static str> = Vec::new();
1530 for probe in PROBES {
1531 if !names.contains(&probe.manager) {
1532 names.push(probe.manager);
1533 }
1534 }
1535 names
1536}
1537
1538fn clear_one(report: &CacheReport) -> ClearOutcome {
1540 let problem = match report.clear {
1541 Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
1542 Clear::Directory => remove_cache_dir(&report.path),
1543 Clear::Manual { why } => Some(why.to_string()),
1547 };
1548 ClearOutcome {
1549 manager: report.manager,
1550 kind: report.kind,
1551 path: report.path.clone(),
1552 before: report.bytes,
1553 after: adapters::dir_size(&report.path),
1556 problem,
1557 }
1558}
1559
1560fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
1562 if !adapters::binary_available(program) {
1563 return Some(format!(
1564 "`{program}` is not on PATH — only it knows what in this cache is still \
1565 referenced, so dev-prune will not delete the directory in its place."
1566 ));
1567 }
1568 let mut all: Vec<&str> = args.to_vec();
1571 all.extend(extra.iter().map(String::as_str));
1572 adapters::run_command_with_timeout(
1573 program,
1574 &all,
1575 &query_dir(),
1576 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
1577 )
1578 .err()
1579 .map(|e| format!("{e:#}"))
1580}
1581
1582fn remove_cache_dir(path: &Path) -> Option<String> {
1584 std::fs::remove_dir_all(path)
1588 .or_else(|_| {
1589 std::thread::sleep(std::time::Duration::from_millis(250));
1590 std::fs::remove_dir_all(path)
1591 })
1592 .err()
1593 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
1595 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
1596}
1597
1598fn print_kept(kept: &[CacheReport]) {
1600 for r in kept {
1601 let Clear::Manual { why } = r.clear else {
1602 continue;
1603 };
1604 println!();
1605 output::print_info(&format!(
1606 "Keeping {} {} ({} at {}). {why}",
1607 r.manager,
1608 r.kind,
1609 output::format_bytes(r.bytes),
1610 output::clean_path(&r.path)
1611 ));
1612 }
1613}
1614
1615fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
1617 output::print_header(if dry_run {
1618 i18n::t("caches.header.would_clear")
1619 } else {
1620 i18n::t("caches.header.about_to_clear")
1621 });
1622
1623 println!();
1624 for r in reports {
1625 println!(
1626 " {:<30} {:>10} {}",
1627 format!("{} {}", r.manager, r.kind),
1628 output::format_bytes(r.bytes),
1629 output::clean_path(&r.path)
1630 );
1631 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
1632 }
1633
1634 println!();
1635 let total: u64 = reports.iter().map(|r| r.bytes).sum();
1636 println!(
1637 " {:<30} {:>10} across {} {}",
1638 "Total",
1639 output::format_bytes(total),
1640 reports.len(),
1641 output::plural(reports.len(), "cache", "caches")
1642 );
1643
1644 println!();
1645 output::print_info(
1646 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
1647 The cost is time: the next install, and the next `devp restore`, in every \
1648 project on this machine.",
1649 );
1650}
1651
1652fn record_cache_clear(bytes: u64) {
1659 if bytes == 0 {
1660 return;
1661 }
1662 if let Ok(mut registry) = crate::config::Registry::load() {
1663 registry.record_cache_clear(bytes);
1664 let _ = registry.save();
1665 }
1666}
1667
1668fn print_clear_result(outcomes: &[ClearOutcome]) {
1670 println!();
1671 for o in outcomes {
1672 let label = format!("{} {}", o.manager, o.kind);
1673 println!(
1674 " {:<30} {:>10} {}",
1675 label,
1676 output::format_bytes(o.freed()),
1677 if o.problem.is_some() {
1678 "not cleared"
1679 } else {
1680 "cleared"
1681 }
1682 );
1683 if let Some(why) = &o.problem {
1684 println!(" {:<30} {:>10} {why}", "", "");
1685 }
1686 }
1687
1688 println!();
1689 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
1690 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
1691}
1692
1693fn confirm_clear(yes: bool) -> bool {
1696 use std::io::{IsTerminal, Write};
1697 if yes {
1698 return true;
1699 }
1700 if !std::io::stdin().is_terminal() {
1701 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
1702 return false;
1703 }
1704 eprint!("Clear them? [y/N]: ");
1708 if std::io::stderr().flush().is_err() {
1709 return false;
1710 }
1711 let mut input = String::new();
1712 if std::io::stdin().read_line(&mut input).is_err() {
1713 return false;
1714 }
1715 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use super::*;
1721
1722 #[test]
1723 fn every_probe_can_be_found_without_its_manager_installed() {
1724 for probe in PROBES {
1727 assert!(
1728 !fallbacks(probe.manager, probe.kind).is_empty(),
1729 "{} {} has no conventional location",
1730 probe.manager,
1731 probe.kind
1732 );
1733 }
1734 }
1735
1736 #[test]
1737 fn every_probe_names_the_command_that_clears_it() {
1738 for probe in PROBES {
1739 assert!(
1740 !probe.clear_command.trim().is_empty(),
1741 "{} {} reports a size with no way to act on it",
1742 probe.manager,
1743 probe.kind
1744 );
1745 }
1746 }
1747
1748 #[test]
1749 fn only_five_probed_managers_have_no_adapter_of_the_same_name() {
1750 let orphans: Vec<&str> = PROBES
1755 .iter()
1756 .map(|p| p.manager)
1757 .filter(|m| !adapters::is_adapter_name(m))
1758 .collect::<std::collections::BTreeSet<_>>()
1759 .into_iter()
1760 .collect();
1761 assert_eq!(orphans, ["conan", "conda", "hex", "nuget", "pip"]);
1762 }
1763
1764 #[test]
1765 fn no_two_probes_describe_the_same_cache() {
1766 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
1767 let count = keys.len();
1768 keys.sort_unstable();
1769 keys.dedup();
1770 assert_eq!(keys.len(), count, "two probes share a manager and kind");
1771 }
1772
1773 #[test]
1774 fn a_managers_answer_is_read_off_the_last_line() {
1775 let raw = if cfg!(windows) {
1777 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
1778 } else {
1779 "npm warn config global deprecated\n/home/dev/.npm\n"
1780 };
1781 assert!(path_from_output(raw).is_some());
1782 }
1783
1784 #[test]
1785 fn quoted_paths_lose_their_quotes() {
1786 let raw = if cfg!(windows) {
1787 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
1788 } else {
1789 "\"/opt/go path/pkg/mod\"\n"
1790 };
1791 let path = path_from_output(raw).expect("a quoted path is still a path");
1792 assert!(!path.to_string_lossy().contains('"'));
1793 }
1794
1795 #[test]
1796 fn a_non_answer_is_not_mistaken_for_a_path() {
1797 for raw in [
1800 "",
1801 "\n \n",
1802 "undefined\n",
1803 "not a command\n",
1804 "./relative\n",
1805 ] {
1806 assert!(
1807 path_from_output(raw).is_none(),
1808 "{raw:?} was accepted as a cache path"
1809 );
1810 }
1811 }
1812
1813 #[test]
1814 fn the_cargo_rows_point_inside_the_registry() {
1815 for kind in ["registry cache", "registry sources"] {
1818 let path = fallbacks("cargo", kind).remove(0);
1819 assert!(
1820 path.starts_with(cargo_home().join("registry")),
1821 "{kind} resolved outside the cargo registry: {}",
1822 path.display()
1823 );
1824 }
1825 }
1826
1827 #[test]
1828 fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
1829 let home = dirs::home_dir().expect("a home directory");
1835 let found = fallbacks("conda", "package cache");
1836
1837 for install in [
1838 "miniconda3",
1839 "anaconda3",
1840 "miniforge3",
1841 "mambaforge",
1842 ".conda",
1843 ] {
1844 let want = home.join(install).join("pkgs");
1845 assert!(
1846 found.contains(&want),
1847 "{} is not among conda's conventional locations",
1848 want.display()
1849 );
1850 assert!(
1851 !found.contains(&home.join(install)),
1852 "{} is the installation, not its package cache",
1853 home.join(install).display()
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn the_report_is_ordered_by_what_is_worth_clearing() {
1860 let mut reports = [
1861 CacheReport {
1862 manager: "npm",
1863 kind: "cache",
1864 path: PathBuf::from("/a"),
1865 bytes: 10,
1866 clear_command: "x".to_string(),
1867 clear: Clear::Command("npm", &["cache"]),
1868 note: None,
1869 cap_gb: None,
1870 over_cap: false,
1871 dependents: None,
1872 extra_args: Vec::new(),
1873 },
1874 CacheReport {
1875 manager: "go",
1876 kind: "module cache",
1877 path: PathBuf::from("/b"),
1878 bytes: 4_000,
1879 clear_command: "y".to_string(),
1880 clear: Clear::Directory,
1881 note: None,
1882 cap_gb: None,
1883 over_cap: false,
1884 dependents: None,
1885 extra_args: Vec::new(),
1886 },
1887 ];
1888 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1889 assert_eq!(reports[0].manager, "go");
1890 }
1891
1892 fn sized(manager: &'static str, bytes: u64, dependents: Option<usize>) -> CacheReport {
1894 CacheReport {
1895 manager,
1896 kind: "cache",
1897 path: PathBuf::from("/x"),
1898 bytes,
1899 clear_command: "x".to_string(),
1900 clear: Clear::Directory,
1901 note: None,
1902 cap_gb: None,
1903 over_cap: false,
1904 dependents,
1905 extra_args: Vec::new(),
1906 }
1907 }
1908
1909 #[test]
1910 fn the_costliest_cache_per_repository_is_not_the_biggest_one() {
1911 let reports = [
1914 sized("npm", 10_240, Some(18)),
1915 sized("pnpm", 2_048, Some(1)),
1916 sized("cargo", 300, Some(2)),
1917 sized("cargo", 100, Some(2)),
1918 sized("bun", 512, Some(0)),
1919 sized("nuget", 900, None),
1920 ];
1921 assert_eq!(
1922 costliest_per_repository(&reports),
1923 vec![("pnpm", 2_048), ("npm", 568), ("cargo", 200)],
1924 );
1925 }
1926
1927 #[test]
1928 fn every_probe_clears_with_the_command_it_prints() {
1929 for probe in PROBES {
1932 let printed = probe.clear_command;
1933 match probe.clear {
1934 Clear::Command(program, args) => {
1935 assert!(
1936 printed.starts_with(program),
1937 "{} {} prints `{printed}` but runs `{program}`",
1938 probe.manager,
1939 probe.kind
1940 );
1941 for arg in args {
1942 assert!(
1945 printed.contains(arg.trim_matches('"')),
1946 "{} {} prints `{printed}` but passes `{arg}`",
1947 probe.manager,
1948 probe.kind
1949 );
1950 }
1951 }
1952 Clear::Directory | Clear::Manual { .. } => assert!(
1956 printed.contains("rm -rf") || printed.contains("Remove-Item"),
1957 "{} {} deletes a directory but prints `{printed}`",
1958 probe.manager,
1959 probe.kind
1960 ),
1961 }
1962 }
1963 }
1964
1965 #[test]
1966 fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
1967 let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
1971 assert!(!maven.is_empty(), "maven is no longer reported at all");
1972 for probe in maven {
1973 assert!(
1974 matches!(probe.clear, Clear::Manual { .. }),
1975 "maven {} would be emptied by dev-prune",
1976 probe.kind
1977 );
1978 }
1979 }
1980
1981 #[test]
1982 fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
1983 let dir = tempfile::tempdir().unwrap();
1987 let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
1988 std::fs::write(&artifact, b"nowhere else").unwrap();
1989
1990 let outcome = clear_one(&CacheReport {
1991 manager: "maven",
1992 kind: "local repository",
1993 path: dir.path().to_path_buf(),
1994 bytes: 12,
1995 clear_command: MAVEN_REPO_CLEAR.to_string(),
1996 clear: Clear::Manual { why: MAVEN_MANUAL },
1997 note: None,
1998 cap_gb: None,
1999 over_cap: false,
2000 dependents: None,
2001 extra_args: Vec::new(),
2002 });
2003
2004 assert!(artifact.exists(), "the store was emptied after all");
2005 assert!(
2006 outcome.problem.is_some(),
2007 "it reported success without doing anything"
2008 );
2009 }
2010
2011 #[test]
2012 fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
2013 let err = run_clear("maven", false, false, true, true, false).unwrap_err();
2016 assert!(
2017 err.downcast_ref::<crate::UsageError>().is_some(),
2018 "expected a usage error, got: {err:#}"
2019 );
2020 let text = format!("{err}");
2021 assert!(
2022 text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
2023 "the refusal names neither the reason nor the command: {text}"
2024 );
2025 }
2026
2027 #[test]
2028 fn every_manager_in_the_report_can_be_named_to_clear() {
2029 let names = known_managers();
2030 for probe in PROBES {
2031 assert!(
2032 names.contains(&probe.manager),
2033 "{} is reported but `devp caches clear {}` would not find it",
2034 probe.manager,
2035 probe.manager
2036 );
2037 }
2038 let mut sorted = names.clone();
2041 sorted.sort_unstable();
2042 sorted.dedup();
2043 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
2044 }
2045
2046 #[test]
2047 fn an_unknown_manager_is_a_usage_error() {
2048 let err = run_clear("nonesuch", false, false, true, true, false).unwrap_err();
2050 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2051 }
2052
2053 #[test]
2054 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
2055 let err = run_clear("npm", false, false, false, false, true).unwrap_err();
2056 assert!(err.downcast_ref::<crate::UsageError>().is_some());
2057 }
2058
2059 #[test]
2060 fn removing_a_directory_reports_nothing_when_it_worked() {
2061 let dir = tempfile::tempdir().unwrap();
2062 let cache = dir.path().join("cache");
2063 std::fs::create_dir(&cache).unwrap();
2064 std::fs::write(cache.join("blob"), b"x").unwrap();
2065
2066 assert!(remove_cache_dir(&cache).is_none());
2067 assert!(!cache.exists());
2068 assert!(remove_cache_dir(&cache).is_none());
2071 }
2072
2073 #[test]
2074 fn clearing_a_directory_reports_what_actually_went() {
2075 let dir = tempfile::tempdir().unwrap();
2076 let cache = dir.path().join("store");
2077 std::fs::create_dir(&cache).unwrap();
2078 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
2079 let before = adapters::dir_size(&cache);
2080
2081 let outcome = clear_one(&CacheReport {
2082 manager: "cargo",
2083 kind: "registry cache",
2084 path: cache.clone(),
2085 bytes: before,
2086 clear_command: "rm -rf".to_string(),
2087 clear: Clear::Directory,
2088 note: None,
2089 cap_gb: None,
2090 over_cap: false,
2091 dependents: None,
2092 extra_args: Vec::new(),
2093 });
2094
2095 assert!(outcome.problem.is_none());
2096 assert_eq!(outcome.after, 0);
2097 assert_eq!(outcome.freed(), before);
2100 assert!(!cache.exists());
2101 }
2102
2103 #[test]
2104 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
2105 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
2108 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
2109 }
2110
2111 fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
2113 CacheReport {
2114 manager,
2115 kind,
2116 path: PathBuf::from("/cache").join(manager).join(kind),
2117 bytes: gib * crate::constants::BYTES_PER_GIB,
2118 clear_command: "x".to_string(),
2119 clear: Clear::Directory,
2120 note: None,
2121 cap_gb: None,
2122 over_cap: false,
2123 dependents: None,
2124 extra_args: Vec::new(),
2125 }
2126 }
2127
2128 fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
2130 Dependents {
2131 repositories,
2132 by_manager: counts.iter().copied().collect(),
2133 }
2134 }
2135
2136 #[test]
2137 fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
2138 let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
2143 apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
2144
2145 assert_eq!(reports[0].dependents, Some(2));
2146 assert_eq!(
2147 reports[1].dependents, None,
2148 "pip has no adapter of its name, so there is nothing to count"
2149 );
2150 }
2151
2152 #[test]
2153 fn no_registry_leaves_every_count_unanswered() {
2154 let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
2157 apply_dependents(&mut reports, None);
2158 assert!(reports.iter().all(|r| r.dependents.is_none()));
2159 }
2160
2161 #[test]
2162 fn a_manager_nothing_uses_is_a_counted_zero() {
2163 let mut reports = vec![row("go", "module cache", 3)];
2166 apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
2167 assert_eq!(reports[0].dependents, Some(0));
2168 assert!(
2169 used_by(&reports[0], 0, None, &manager_totals(&reports))
2170 .contains("no registered repository uses go")
2171 );
2172 }
2173
2174 #[test]
2175 fn the_per_repository_share_is_the_managers_whole_footprint() {
2176 let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2180 let line = used_by(
2181 &reports[0],
2182 2,
2183 Some(&counted(2, &[("cargo", 2)])),
2184 &manager_totals(&reports),
2185 );
2186 assert!(
2187 line.contains("cargo is used by 2 of 2 registered repositories")
2188 && line.contains("6 GiB"),
2189 "{line}"
2190 );
2191 }
2192
2193 #[test]
2194 fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
2195 let dir = tempfile::tempdir().unwrap();
2199 let nested = dir.path().join("a").join("b");
2200 std::fs::create_dir_all(&nested).unwrap();
2201
2202 let root = volume_root(&nested).expect("a real directory sits on some filesystem");
2203 assert!(
2204 nested.starts_with(&root),
2205 "{} is not under {}",
2206 nested.display(),
2207 root.display()
2208 );
2209 assert!(root.is_dir(), "{} is not a directory", root.display());
2210 }
2211
2212 #[cfg(windows)]
2213 #[test]
2214 fn a_windows_volume_root_is_the_drive_and_nothing_more() {
2215 let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
2219 assert_eq!(root, PathBuf::from("V:\\"));
2220 assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
2221 }
2222
2223 #[cfg(windows)]
2224 #[test]
2225 fn the_drive_someone_types_and_the_drive_on_disk_are_the_same_drive() {
2226 assert!(same_volume(Path::new(r"\\?\C:\"), Path::new(r"C:\")));
2231 assert!(same_volume(Path::new(r"c:\"), Path::new(r"C:\")));
2232 assert!(!same_volume(Path::new(r"C:\"), Path::new(r"V:\")));
2233 }
2234
2235 #[cfg(windows)]
2236 #[test]
2237 fn a_bare_drive_letter_is_what_people_type_and_has_to_resolve() {
2238 for typed in ["V", "v", "V:", "v:", r"V:\", r"V:\Code\ProjectCode"] {
2242 assert_eq!(
2243 resolve_volume(typed).unwrap(),
2244 PathBuf::from(r"V:\"),
2245 "{typed}"
2246 );
2247 }
2248 assert!(resolve_volume("not-a-path-at-all").is_err());
2249 }
2250
2251 #[test]
2252 fn the_drive_you_are_standing_on_is_a_dot() {
2253 let here = std::env::current_dir().unwrap();
2258 assert_eq!(resolve_volume(".").unwrap(), volume_root(&here).unwrap());
2259 assert!(resolve_volume("definitely-not-a-drive").is_err());
2262 }
2263
2264 #[test]
2265 fn narrowing_to_one_drive_keeps_only_what_is_on_it() {
2266 let dir = tempfile::tempdir().unwrap();
2269 let here = dir.path().join("cache");
2270 std::fs::create_dir_all(&here).unwrap();
2271 let root = volume_root(&here).unwrap();
2272
2273 let mut on_this_one = sized("pnpm", 2_048, Some(1));
2274 on_this_one.path = here;
2275 let mut nowhere = sized("npm", 10_240, Some(18));
2276 nowhere.path = PathBuf::from("relative/and/unresolvable");
2277
2278 let kept = on_volume(vec![on_this_one, nowhere], &root);
2279 assert_eq!(
2280 kept.len(),
2281 1,
2282 "a row whose drive is unknown is not on this one"
2283 );
2284 assert_eq!(kept[0].manager, "pnpm");
2285 }
2286
2287 #[test]
2288 fn the_per_drive_line_adds_up_and_leads_with_the_biggest() {
2289 let dir = tempfile::tempdir().unwrap();
2293 let here = dir.path().join("cache");
2294 std::fs::create_dir_all(&here).unwrap();
2295
2296 let mut small = sized("pnpm", 100, None);
2297 small.path = here.clone();
2298 let mut large = sized("npm", 900, None);
2299 large.path = here;
2300 let mut unknown = sized("uv", 500, None);
2301 unknown.path = PathBuf::from("relative/and/unresolvable");
2302
2303 let totals = volume_totals(&[small, large, unknown]);
2304 assert_eq!(totals.len(), 1);
2305 assert_eq!(totals[0].1, 1_000);
2306 }
2307
2308 #[test]
2309 fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
2310 let dir = tempfile::tempdir().unwrap();
2313 let a = dir.path().join("one");
2314 let b = dir.path().join("two");
2315 std::fs::create_dir_all(&a).unwrap();
2316 std::fs::create_dir_all(&b).unwrap();
2317
2318 assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
2319 assert!(volume_roots(&[]).is_empty());
2320 }
2321
2322 #[test]
2323 fn a_volume_stores_printed_command_is_the_one_that_runs() {
2324 let dir = tempfile::tempdir().unwrap();
2329 let store = dir.path().join(".pnpm-store");
2330 std::fs::create_dir_all(&store).unwrap();
2331
2332 let report = volume_store_report(store.clone());
2333 let named = output::clean_path(&store);
2334 assert_eq!(
2335 report.extra_args,
2336 vec!["--store-dir".to_string(), named.clone()]
2337 );
2338 assert!(
2339 report.clear_command.contains(&named),
2340 "the printed command does not name the store: {}",
2341 report.clear_command
2342 );
2343 assert!(matches!(
2344 report.clear,
2345 Clear::Command("pnpm", ["store", "prune"])
2346 ));
2347 }
2348
2349 #[test]
2350 fn only_a_path_with_a_space_in_it_is_quoted() {
2351 assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
2355 assert_eq!(
2356 shell_arg("/mnt/my data/.pnpm-store"),
2357 "\"/mnt/my data/.pnpm-store\""
2358 );
2359 }
2360
2361 #[test]
2362 fn a_cap_is_measured_against_the_managers_whole_footprint() {
2363 let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2367 apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
2368 assert!(
2369 reports.iter().all(|r| r.over_cap),
2370 "both rows belong to the manager that went over"
2371 );
2372 assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
2373 }
2374
2375 #[test]
2376 fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
2377 let mut reports = vec![row("npm", "cache", 3)];
2378 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2379 assert_eq!(reports[0].cap_gb, Some(10));
2382 assert!(!reports[0].over_cap);
2383 }
2384
2385 #[test]
2386 fn a_manager_with_no_cap_is_never_called_too_big() {
2387 let mut reports = vec![row("uv", "cache", 40)];
2390 apply_caps(&mut reports, &BTreeMap::new());
2391 assert_eq!(reports[0].cap_gb, None);
2392 assert!(!reports[0].over_cap);
2393 }
2394
2395 #[test]
2396 fn one_managers_cap_says_nothing_about_another() {
2397 let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
2398 apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2399 assert!(reports[0].over_cap);
2400 assert!(
2401 !reports[1].over_cap,
2402 "go has no cap and did not acquire npm's"
2403 );
2404 }
2405
2406 #[test]
2407 fn exactly_at_the_cap_is_not_over_it() {
2408 let mut reports = vec![row("pnpm", "store", 10)];
2411 apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
2412 assert!(!reports[0].over_cap);
2413 }
2414
2415 #[test]
2416 fn every_cache_manager_answers_to_its_own_name() {
2417 for probe in PROBES {
2420 assert!(
2421 is_cache_manager(probe.manager),
2422 "{} is reported but cannot be capped",
2423 probe.manager
2424 );
2425 }
2426 assert!(!is_cache_manager("dev-prune-no-such-manager"));
2427 }
2428}