1use std::collections::HashSet;
38use std::path::{Path, PathBuf};
39
40use anyhow::Result;
41
42use crate::adapters;
43use crate::constants;
44use crate::json;
45use crate::output;
46
47pub struct CacheReport {
49 pub manager: &'static str,
51 pub kind: &'static str,
53 pub path: PathBuf,
55 pub bytes: u64,
57 pub clear_command: &'static str,
59 pub clear: Clear,
61 pub note: Option<&'static str>,
63}
64
65#[derive(Clone, Copy)]
67pub enum Clear {
68 Command(&'static str, &'static [&'static str]),
72 Directory,
75}
76
77struct Probe {
79 manager: &'static str,
80 kind: &'static str,
81 query: Option<(&'static str, &'static [&'static str])>,
87 clear_command: &'static str,
88 clear: Clear,
89 note: Option<&'static str>,
90}
91
92#[cfg(windows)]
95const CARGO_CACHE_CLEAR: &str =
96 r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
97#[cfg(not(windows))]
98const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
99
100#[cfg(windows)]
101const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
102#[cfg(not(windows))]
103const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
104
105#[cfg(windows)]
109const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
110#[cfg(not(windows))]
111const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
112
113#[cfg(windows)]
114const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
115#[cfg(not(windows))]
116const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
117
118#[cfg(windows)]
119const GRADLE_DISTS_CLEAR: &str =
120 r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
121#[cfg(not(windows))]
122const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
123
124#[cfg(windows)]
125const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
126#[cfg(not(windows))]
127const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
128
129const PROBES: &[Probe] = &[
130 Probe {
131 manager: "npm",
132 kind: "cache",
133 query: Some(("npm", &["config", "get", "cache"])),
134 clear_command: "npm cache clean --force",
135 clear: Clear::Command("npm", &["cache", "clean", "--force"]),
136 note: None,
137 },
138 Probe {
139 manager: "pnpm",
140 kind: "store",
141 query: Some(("pnpm", &["store", "path"])),
142 clear_command: "pnpm store prune",
143 clear: Clear::Command("pnpm", &["store", "prune"]),
144 note: Some(
145 "hardlinked into every node_modules on the machine; emptying it is what makes \
146 the next pnpm install a download",
147 ),
148 },
149 Probe {
150 manager: "yarn",
151 kind: "cache",
152 query: Some(("yarn", &["cache", "dir"])),
153 clear_command: "yarn cache clean",
154 clear: Clear::Command("yarn", &["cache", "clean"]),
155 note: None,
156 },
157 Probe {
158 manager: "bun",
159 kind: "cache",
160 query: Some(("bun", &["pm", "cache"])),
161 clear_command: "bun pm cache rm",
162 clear: Clear::Command("bun", &["pm", "cache", "rm"]),
163 note: None,
164 },
165 Probe {
166 manager: "uv",
167 kind: "cache",
168 query: Some(("uv", &["cache", "dir"])),
169 clear_command: "uv cache prune",
172 clear: Clear::Command("uv", &["cache", "prune"]),
173 note: None,
174 },
175 Probe {
176 manager: "pip",
177 kind: "cache",
178 query: Some(("pip", &["cache", "dir"])),
179 clear_command: "pip cache purge",
180 clear: Clear::Command("pip", &["cache", "purge"]),
181 note: None,
182 },
183 Probe {
184 manager: "cargo",
185 kind: "registry cache",
186 query: None,
187 clear_command: CARGO_CACHE_CLEAR,
188 clear: Clear::Directory,
189 note: Some("the downloaded .crate archives; clearing them means downloading again"),
190 },
191 Probe {
192 manager: "cargo",
193 kind: "registry sources",
194 query: None,
195 clear_command: CARGO_SRC_CLEAR,
196 clear: Clear::Directory,
197 note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
198 },
199 Probe {
200 manager: "go",
201 kind: "module cache",
202 query: Some(("go", &["env", "GOMODCACHE"])),
203 clear_command: "go clean -modcache",
204 clear: Clear::Command("go", &["clean", "-modcache"]),
205 note: None,
206 },
207 Probe {
208 manager: "go",
209 kind: "build cache",
210 query: Some(("go", &["env", "GOCACHE"])),
211 clear_command: "go clean -cache",
212 clear: Clear::Command("go", &["clean", "-cache"]),
213 note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
214 },
215 Probe {
220 manager: "maven",
221 kind: "local repository",
222 query: None,
223 clear_command: MAVEN_REPO_CLEAR,
224 clear: Clear::Directory,
225 note: Some(
226 "every Maven build on the machine resolves from here; the next build re-downloads what it needs",
227 ),
228 },
229 Probe {
230 manager: "gradle",
231 kind: "caches",
232 query: None,
233 clear_command: GRADLE_CACHE_CLEAR,
234 clear: Clear::Directory,
235 note: Some(
236 "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
237 ),
238 },
239 Probe {
240 manager: "gradle",
241 kind: "wrapper distributions",
242 query: None,
243 clear_command: GRADLE_DISTS_CLEAR,
244 clear: Clear::Directory,
245 note: Some(
246 "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
247 ),
248 },
249 Probe {
253 manager: "nuget",
254 kind: "global packages",
255 query: None,
256 clear_command: "dotnet nuget locals global-packages --clear",
257 clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
258 note: Some(
259 "every .NET project on the machine restores from here; re-downloaded on the next restore",
260 ),
261 },
262 Probe {
263 manager: "vcpkg",
264 kind: "binary cache",
265 query: None,
266 clear_command: VCPKG_ARCHIVES_CLEAR,
267 clear: Clear::Directory,
268 note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
269 },
270 Probe {
271 manager: "conan",
272 kind: "package cache",
273 query: None,
274 clear_command: "conan remove \"*\" --confirm",
275 clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
276 note: Some(
277 "recipes and binaries shared by every Conan project; re-fetched on the next install",
278 ),
279 },
280];
281
282pub fn run(json_output: bool) -> Result<()> {
284 let reports = collect(!json_output);
285
286 if json_output {
287 return json::emit(&json::caches_document(&reports));
288 }
289
290 print_report(&reports);
291 Ok(())
292}
293
294fn collect(spinner: bool) -> Vec<CacheReport> {
296 let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
297 let from = query_dir();
298
299 let mut seen: HashSet<PathBuf> = HashSet::new();
300 let mut reports = Vec::new();
301
302 for probe in PROBES {
303 let Some(path) = locate(probe, &from) else {
304 continue;
305 };
306 let path = path.canonicalize().unwrap_or(path);
313 if !seen.insert(path.clone()) {
314 continue;
315 }
316 reports.push(CacheReport {
317 manager: probe.manager,
318 kind: probe.kind,
319 bytes: adapters::dir_size(&path),
320 path,
321 clear_command: probe.clear_command,
322 clear: probe.clear,
323 note: probe.note,
324 });
325 }
326
327 if let Some(pb) = pb {
328 pb.finish_and_clear();
329 }
330
331 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
332 reports
333}
334
335fn query_dir() -> PathBuf {
342 dirs::home_dir()
343 .or_else(|| std::env::current_dir().ok())
344 .unwrap_or_else(|| PathBuf::from("."))
345}
346
347fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
349 if let Some((program, args)) = probe.query
350 && adapters::binary_available(program)
351 {
352 let answered = adapters::capture_command_with_timeout(
353 program,
354 args,
355 from,
356 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
357 )
358 .ok()
359 .and_then(|raw| path_from_output(&raw))
360 .filter(|p| p.is_dir());
361 if answered.is_some() {
362 return answered;
363 }
364 }
365
366 fallbacks(probe.manager, probe.kind)
371 .into_iter()
372 .find(|p| p.is_dir())
373}
374
375fn path_from_output(raw: &str) -> Option<PathBuf> {
380 let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
381 let line = line.trim_matches('"');
382 if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
385 return None;
386 }
387 Some(PathBuf::from(line))
388}
389
390fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
392 let home = dirs::home_dir();
393 let local = dirs::data_local_dir();
394 let cache = dirs::cache_dir();
395 let under = |base: &Option<PathBuf>, rel: &str| {
399 base.as_ref()
400 .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
401 };
402
403 let candidates = match (manager, kind) {
404 ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
407 ("pnpm", _) => vec![
408 under(&local, "pnpm/store"),
409 under(&home, ".local/share/pnpm/store"),
410 under(&home, "Library/pnpm/store"),
411 under(&home, ".pnpm-store"),
412 ],
413 ("yarn", _) => vec![
414 under(&home, ".yarn/berry/cache"),
415 under(&local, "Yarn/Cache"),
416 under(&cache, "yarn"),
417 ],
418 ("bun", _) => vec![under(&home, ".bun/install/cache")],
419 ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
420 ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
421 ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
422 ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
423 ("go", "module cache") => vec![
424 std::env::var_os("GOMODCACHE").map(PathBuf::from),
425 std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
426 under(&home, "go/pkg/mod"),
427 ],
428 ("go", _) => vec![
429 std::env::var_os("GOCACHE").map(PathBuf::from),
430 under(&cache, "go-build"),
431 under(&local, "go-build"),
432 ],
433 ("maven", _) => vec![under(&home, ".m2/repository")],
434 ("gradle", "caches") => vec![
436 std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
437 under(&home, ".gradle/caches"),
438 ],
439 ("gradle", _) => vec![
440 std::env::var_os("GRADLE_USER_HOME")
441 .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
442 under(&home, ".gradle/wrapper/dists"),
443 ],
444 ("nuget", _) => vec![
445 std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
446 under(&home, ".nuget/packages"),
447 ],
448 ("vcpkg", _) => vec![
449 std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
450 under(&local, "vcpkg/archives"),
451 under(&cache, "vcpkg/archives"),
452 ],
453 ("conan", _) => vec![
456 std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
457 under(&home, ".conan2/p"),
458 ],
459 _ => vec![],
460 };
461
462 candidates.into_iter().flatten().collect()
463}
464
465fn cargo_home() -> PathBuf {
467 std::env::var_os("CARGO_HOME")
468 .map(PathBuf::from)
469 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
470 .unwrap_or_else(|| PathBuf::from(".cargo"))
471}
472
473fn print_report(reports: &[CacheReport]) {
474 output::print_header("Package manager caches");
475
476 if reports.is_empty() {
477 println!();
478 output::print_info("No package manager caches found on this machine.");
479 return;
480 }
481
482 println!();
483 for r in reports {
484 let label = format!("{} {}", r.manager, r.kind);
485 println!(
486 " {:<30} {:>10} {}",
487 label,
488 output::format_bytes(r.bytes),
489 output::clean_path(&r.path)
490 );
491 println!(" {:<30} {:>10} clear: {}", "", "", r.clear_command);
492 if let Some(note) = r.note {
493 println!(" {:<30} {:>10} {}", "", "", note);
494 }
495 println!();
496 }
497
498 let total: u64 = reports.iter().map(|r| r.bytes).sum();
499 println!(
500 " {:<30} {:>10} across {} {}",
501 "Total",
502 output::format_bytes(total),
503 reports.len(),
504 output::plural(reports.len(), "cache", "caches")
505 );
506
507 println!();
508 output::print_info(
509 "Nothing above was deleted. A cache is shared by every project on the machine, so \
510 no single repository's lockfile can prove it is recoverable — and it is what \
511 makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
512 will ever touch one. When you want the space more than the speed, run a clear \
513 command yourself, or `devp caches clear <manager>`.",
514 );
515}
516
517pub struct ClearOutcome {
519 pub manager: &'static str,
521 pub kind: &'static str,
523 pub path: PathBuf,
525 pub before: u64,
527 pub after: u64,
531 pub problem: Option<String>,
533}
534
535impl ClearOutcome {
536 pub fn freed(&self) -> u64 {
538 self.before.saturating_sub(self.after)
539 }
540}
541
542pub fn run_clear(target: &str, yes: bool, dry_run: bool, json_output: bool) -> Result<()> {
547 let all = target.eq_ignore_ascii_case("all");
548 if !all
549 && !PROBES
550 .iter()
551 .any(|p| p.manager.eq_ignore_ascii_case(target))
552 {
553 return Err(anyhow::Error::new(crate::UsageError(format!(
554 "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
555 known_managers().join(", ")
556 ))));
557 }
558 if json_output && !yes && !dry_run {
561 return Err(anyhow::Error::new(crate::UsageError(
562 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
563 to see what would go."
564 .to_string(),
565 )));
566 }
567
568 let reports: Vec<CacheReport> = collect(!json_output)
569 .into_iter()
570 .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
571 .collect();
572
573 if reports.is_empty() {
574 if json_output {
575 return json::emit(&json::caches_clear_plan_document(&reports));
576 }
577 output::print_info(&format!(
578 "No {} cache on this machine — nothing to clear.",
579 if all { "package manager" } else { target }
580 ));
581 return Ok(());
582 }
583
584 if dry_run {
585 if json_output {
586 return json::emit(&json::caches_clear_plan_document(&reports));
587 }
588 print_clear_plan(&reports, true);
589 return Ok(());
590 }
591
592 if !json_output {
593 print_clear_plan(&reports, false);
594 if !confirm_clear(yes) {
595 output::print_info("Nothing was cleared.");
596 return Ok(());
597 }
598 }
599
600 let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
601
602 if json_output {
603 json::emit(&json::caches_clear_document(&outcomes))?;
604 } else {
605 print_clear_result(&outcomes);
606 }
607
608 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
611 if failed > 0 {
612 anyhow::bail!(
613 "{failed} {} could not be cleared.",
614 output::plural(failed, "cache", "caches")
615 );
616 }
617 Ok(())
618}
619
620fn known_managers() -> Vec<&'static str> {
622 let mut names: Vec<&'static str> = Vec::new();
623 for probe in PROBES {
624 if !names.contains(&probe.manager) {
625 names.push(probe.manager);
626 }
627 }
628 names
629}
630
631fn clear_one(report: &CacheReport) -> ClearOutcome {
633 let problem = match report.clear {
634 Clear::Command(program, args) => run_clear_command(program, args),
635 Clear::Directory => remove_cache_dir(&report.path),
636 };
637 ClearOutcome {
638 manager: report.manager,
639 kind: report.kind,
640 path: report.path.clone(),
641 before: report.bytes,
642 after: adapters::dir_size(&report.path),
645 problem,
646 }
647}
648
649fn run_clear_command(program: &str, args: &[&str]) -> Option<String> {
651 if !adapters::binary_available(program) {
652 return Some(format!(
653 "`{program}` is not on PATH — only it knows what in this cache is still \
654 referenced, so dev-prune will not delete the directory in its place."
655 ));
656 }
657 adapters::run_command_with_timeout(
658 program,
659 args,
660 &query_dir(),
661 std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
662 )
663 .err()
664 .map(|e| format!("{e:#}"))
665}
666
667fn remove_cache_dir(path: &Path) -> Option<String> {
669 std::fs::remove_dir_all(path)
673 .or_else(|_| {
674 std::thread::sleep(std::time::Duration::from_millis(250));
675 std::fs::remove_dir_all(path)
676 })
677 .err()
678 .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
680 .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
681}
682
683fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
685 output::print_header(if dry_run {
686 "Would clear"
687 } else {
688 "About to clear"
689 });
690
691 println!();
692 for r in reports {
693 println!(
694 " {:<30} {:>10} {}",
695 format!("{} {}", r.manager, r.kind),
696 output::format_bytes(r.bytes),
697 output::clean_path(&r.path)
698 );
699 println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
700 }
701
702 println!();
703 let total: u64 = reports.iter().map(|r| r.bytes).sum();
704 println!(
705 " {:<30} {:>10} across {} {}",
706 "Total",
707 output::format_bytes(total),
708 reports.len(),
709 output::plural(reports.len(), "cache", "caches")
710 );
711
712 println!();
713 output::print_info(
714 "Nothing in a cache is lost — every manager above re-downloads what it needs. \
715 The cost is time: the next install, and the next `devp restore`, in every \
716 project on this machine.",
717 );
718}
719
720fn print_clear_result(outcomes: &[ClearOutcome]) {
722 println!();
723 for o in outcomes {
724 let label = format!("{} {}", o.manager, o.kind);
725 println!(
726 " {:<30} {:>10} {}",
727 label,
728 output::format_bytes(o.freed()),
729 if o.problem.is_some() {
730 "not cleared"
731 } else {
732 "cleared"
733 }
734 );
735 if let Some(why) = &o.problem {
736 println!(" {:<30} {:>10} {why}", "", "");
737 }
738 }
739
740 println!();
741 let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
742 output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
743}
744
745fn confirm_clear(yes: bool) -> bool {
748 use std::io::{IsTerminal, Write};
749 if yes {
750 return true;
751 }
752 if !std::io::stdin().is_terminal() {
753 output::print_info("Not running in a terminal — pass `--yes` to clear these.");
754 return false;
755 }
756 eprint!("Clear them? [y/N]: ");
760 if std::io::stderr().flush().is_err() {
761 return false;
762 }
763 let mut input = String::new();
764 if std::io::stdin().read_line(&mut input).is_err() {
765 return false;
766 }
767 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn every_probe_can_be_found_without_its_manager_installed() {
776 for probe in PROBES {
779 assert!(
780 !fallbacks(probe.manager, probe.kind).is_empty(),
781 "{} {} has no conventional location",
782 probe.manager,
783 probe.kind
784 );
785 }
786 }
787
788 #[test]
789 fn every_probe_names_the_command_that_clears_it() {
790 for probe in PROBES {
791 assert!(
792 !probe.clear_command.trim().is_empty(),
793 "{} {} reports a size with no way to act on it",
794 probe.manager,
795 probe.kind
796 );
797 }
798 }
799
800 #[test]
801 fn no_two_probes_describe_the_same_cache() {
802 let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
803 let count = keys.len();
804 keys.sort_unstable();
805 keys.dedup();
806 assert_eq!(keys.len(), count, "two probes share a manager and kind");
807 }
808
809 #[test]
810 fn a_managers_answer_is_read_off_the_last_line() {
811 let raw = if cfg!(windows) {
813 "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
814 } else {
815 "npm warn config global deprecated\n/home/dev/.npm\n"
816 };
817 assert!(path_from_output(raw).is_some());
818 }
819
820 #[test]
821 fn quoted_paths_lose_their_quotes() {
822 let raw = if cfg!(windows) {
823 "\"C:\\Program Files\\go\\pkg\\mod\"\n"
824 } else {
825 "\"/opt/go path/pkg/mod\"\n"
826 };
827 let path = path_from_output(raw).expect("a quoted path is still a path");
828 assert!(!path.to_string_lossy().contains('"'));
829 }
830
831 #[test]
832 fn a_non_answer_is_not_mistaken_for_a_path() {
833 for raw in [
836 "",
837 "\n \n",
838 "undefined\n",
839 "not a command\n",
840 "./relative\n",
841 ] {
842 assert!(
843 path_from_output(raw).is_none(),
844 "{raw:?} was accepted as a cache path"
845 );
846 }
847 }
848
849 #[test]
850 fn the_cargo_rows_point_inside_the_registry() {
851 for kind in ["registry cache", "registry sources"] {
854 let path = fallbacks("cargo", kind).remove(0);
855 assert!(
856 path.starts_with(cargo_home().join("registry")),
857 "{kind} resolved outside the cargo registry: {}",
858 path.display()
859 );
860 }
861 }
862
863 #[test]
864 fn the_report_is_ordered_by_what_is_worth_clearing() {
865 let mut reports = [
866 CacheReport {
867 manager: "npm",
868 kind: "cache",
869 path: PathBuf::from("/a"),
870 bytes: 10,
871 clear_command: "x",
872 clear: Clear::Command("npm", &["cache"]),
873 note: None,
874 },
875 CacheReport {
876 manager: "go",
877 kind: "module cache",
878 path: PathBuf::from("/b"),
879 bytes: 4_000,
880 clear_command: "y",
881 clear: Clear::Directory,
882 note: None,
883 },
884 ];
885 reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
886 assert_eq!(reports[0].manager, "go");
887 }
888
889 #[test]
890 fn every_probe_clears_with_the_command_it_prints() {
891 for probe in PROBES {
894 let printed = probe.clear_command;
895 match probe.clear {
896 Clear::Command(program, args) => {
897 assert!(
898 printed.starts_with(program),
899 "{} {} prints `{printed}` but runs `{program}`",
900 probe.manager,
901 probe.kind
902 );
903 for arg in args {
904 assert!(
907 printed.contains(arg.trim_matches('"')),
908 "{} {} prints `{printed}` but passes `{arg}`",
909 probe.manager,
910 probe.kind
911 );
912 }
913 }
914 Clear::Directory => assert!(
915 printed.contains("rm -rf") || printed.contains("Remove-Item"),
916 "{} {} deletes a directory but prints `{printed}`",
917 probe.manager,
918 probe.kind
919 ),
920 }
921 }
922 }
923
924 #[test]
925 fn every_manager_in_the_report_can_be_named_to_clear() {
926 let names = known_managers();
927 for probe in PROBES {
928 assert!(
929 names.contains(&probe.manager),
930 "{} is reported but `devp caches clear {}` would not find it",
931 probe.manager,
932 probe.manager
933 );
934 }
935 let mut sorted = names.clone();
938 sorted.sort_unstable();
939 sorted.dedup();
940 assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
941 }
942
943 #[test]
944 fn an_unknown_manager_is_a_usage_error() {
945 let err = run_clear("nonesuch", true, true, false).unwrap_err();
947 assert!(err.downcast_ref::<crate::UsageError>().is_some());
948 }
949
950 #[test]
951 fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
952 let err = run_clear("npm", false, false, true).unwrap_err();
953 assert!(err.downcast_ref::<crate::UsageError>().is_some());
954 }
955
956 #[test]
957 fn removing_a_directory_reports_nothing_when_it_worked() {
958 let dir = tempfile::tempdir().unwrap();
959 let cache = dir.path().join("cache");
960 std::fs::create_dir(&cache).unwrap();
961 std::fs::write(cache.join("blob"), b"x").unwrap();
962
963 assert!(remove_cache_dir(&cache).is_none());
964 assert!(!cache.exists());
965 assert!(remove_cache_dir(&cache).is_none());
968 }
969
970 #[test]
971 fn clearing_a_directory_reports_what_actually_went() {
972 let dir = tempfile::tempdir().unwrap();
973 let cache = dir.path().join("store");
974 std::fs::create_dir(&cache).unwrap();
975 std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
976 let before = adapters::dir_size(&cache);
977
978 let outcome = clear_one(&CacheReport {
979 manager: "cargo",
980 kind: "registry cache",
981 path: cache.clone(),
982 bytes: before,
983 clear_command: "rm -rf",
984 clear: Clear::Directory,
985 note: None,
986 });
987
988 assert!(outcome.problem.is_none());
989 assert_eq!(outcome.after, 0);
990 assert_eq!(outcome.freed(), before);
993 assert!(!cache.exists());
994 }
995
996 #[test]
997 fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
998 let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"]);
1001 assert!(problem.is_some_and(|p| p.contains("not on PATH")));
1002 }
1003}