Skip to main content

dev_prune/commands/
caches.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune caches`.
5//
6// Every package manager keeps a machine-wide download cache outside any repository:
7// npm's `_cacache`, pnpm's content-addressable store, the Go module cache, cargo's
8// registry, Maven's local repository, NuGet's global packages folder. They are
9// frequently the largest reclaimable thing on a developer's disk and
10// nobody notices, because nothing ever mentions them — a 4 GiB `GOMODCACHE` looks like
11// free space that simply went missing.
12//
13// This command finds them, sizes them, and prints the command that clears each one.
14//
15// **It deletes nothing, ever.** That is the entire design. A cache is shared by every
16// project on the machine, so its contents are not something dev-prune can prove is
17// recoverable for any one repository — which is the bar every deletion in this tool has
18// to clear. It is also the thing that makes `devp restore` fast: clearing a cache turns
19// the next reinstall into a download. Reporting is most of the value and none of the
20// risk, so the clear commands are printed for a human to run deliberately.
21//
22// Each manager is asked where its own cache lives rather than being assumed — a
23// `CARGO_HOME`, a `--cache-dir`, a corporate `.npmrc` all move it. Every one of those
24// queries is read-only, and a manager that is not installed falls back to the
25// conventional location, so a cache left behind by an uninstalled manager still shows up.
26
27use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29
30use anyhow::Result;
31
32use crate::adapters;
33use crate::constants;
34use crate::json;
35use crate::output;
36
37/// One cache directory that exists on this machine.
38pub struct CacheReport {
39    /// The package manager that owns it.
40    pub manager: &'static str,
41    /// Which of that manager's caches this is, when it keeps more than one.
42    pub kind: &'static str,
43    /// Where it actually is, as resolved on this machine.
44    pub path: PathBuf,
45    /// Total size on disk.
46    pub bytes: u64,
47    /// The command that empties it. Printed, never run.
48    pub clear_command: &'static str,
49    /// What the user gives up by running that command, when it is more than time.
50    pub note: Option<&'static str>,
51}
52
53/// How to find one cache.
54struct Probe {
55    manager: &'static str,
56    kind: &'static str,
57    /// The manager's own answer to "where is it?", as `(program, args)`.
58    ///
59    /// All of these print a path and exit; none of them writes anything or creates the
60    /// directory. `None` means the ecosystem has no such query and only the conventional
61    /// locations are available.
62    query: Option<(&'static str, &'static [&'static str])>,
63    clear_command: &'static str,
64    note: Option<&'static str>,
65}
66
67/// cargo ships no cache subcommand, so the only honest "how do I clear this" is the
68/// deletion itself. `cargo build` re-downloads and re-extracts what it needs.
69#[cfg(windows)]
70const CARGO_CACHE_CLEAR: &str =
71    r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
72#[cfg(not(windows))]
73const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
74
75#[cfg(windows)]
76const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
77#[cfg(not(windows))]
78const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
79
80/// Maven has no cache subcommand either — `mvn dependency:purge-local-repository`
81/// exists, but it needs a project to run in and re-resolves as it purges, which is not
82/// "clear the cache". The honest command is the deletion.
83#[cfg(windows)]
84const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
85#[cfg(not(windows))]
86const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
87
88#[cfg(windows)]
89const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
90#[cfg(not(windows))]
91const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
92
93#[cfg(windows)]
94const GRADLE_DISTS_CLEAR: &str =
95    r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
96#[cfg(not(windows))]
97const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
98
99#[cfg(windows)]
100const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
101#[cfg(not(windows))]
102const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
103
104const PROBES: &[Probe] = &[
105    Probe {
106        manager: "npm",
107        kind: "cache",
108        query: Some(("npm", &["config", "get", "cache"])),
109        clear_command: "npm cache clean --force",
110        note: None,
111    },
112    Probe {
113        manager: "pnpm",
114        kind: "store",
115        query: Some(("pnpm", &["store", "path"])),
116        clear_command: "pnpm store prune",
117        note: Some(
118            "hardlinked into every node_modules on the machine; emptying it is what makes \
119             the next pnpm install a download",
120        ),
121    },
122    Probe {
123        manager: "yarn",
124        kind: "cache",
125        query: Some(("yarn", &["cache", "dir"])),
126        clear_command: "yarn cache clean",
127        note: None,
128    },
129    Probe {
130        manager: "bun",
131        kind: "cache",
132        query: Some(("bun", &["pm", "cache"])),
133        clear_command: "bun pm cache rm",
134        note: None,
135    },
136    Probe {
137        manager: "uv",
138        kind: "cache",
139        query: Some(("uv", &["cache", "dir"])),
140        // `prune` drops what nothing can use again and keeps the rest; `uv cache clean`
141        // is the sledgehammer, and is not what most people mean by "clear the cache".
142        clear_command: "uv cache prune",
143        note: None,
144    },
145    Probe {
146        manager: "pip",
147        kind: "cache",
148        query: Some(("pip", &["cache", "dir"])),
149        clear_command: "pip cache purge",
150        note: None,
151    },
152    Probe {
153        manager: "cargo",
154        kind: "registry cache",
155        query: None,
156        clear_command: CARGO_CACHE_CLEAR,
157        note: Some("the downloaded .crate archives; clearing them means downloading again"),
158    },
159    Probe {
160        manager: "cargo",
161        kind: "registry sources",
162        query: None,
163        clear_command: CARGO_SRC_CLEAR,
164        note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
165    },
166    Probe {
167        manager: "go",
168        kind: "module cache",
169        query: Some(("go", &["env", "GOMODCACHE"])),
170        clear_command: "go clean -modcache",
171        note: None,
172    },
173    Probe {
174        manager: "go",
175        kind: "build cache",
176        query: Some(("go", &["env", "GOCACHE"])),
177        clear_command: "go clean -cache",
178        note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
179    },
180    // `mvn help:evaluate -Dexpression=settings.localRepository` would answer precisely,
181    // but it boots a JVM, resolves the help plugin over the network on first use, and
182    // takes several seconds — the wrong trade for a read-only size report. A relocated
183    // repository (settings.xml `<localRepository>`) is rare enough to miss.
184    Probe {
185        manager: "maven",
186        kind: "local repository",
187        query: None,
188        clear_command: MAVEN_REPO_CLEAR,
189        note: Some(
190            "every Maven build on the machine resolves from here; the next build re-downloads what it needs",
191        ),
192    },
193    Probe {
194        manager: "gradle",
195        kind: "caches",
196        query: None,
197        clear_command: GRADLE_CACHE_CLEAR,
198        note: Some(
199            "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
200        ),
201    },
202    Probe {
203        manager: "gradle",
204        kind: "wrapper distributions",
205        query: None,
206        clear_command: GRADLE_DISTS_CLEAR,
207        note: Some(
208            "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
209        ),
210    },
211    // `dotnet nuget locals global-packages --list` answers `global-packages: <path>` —
212    // a labelled line, not a bare path — so the conventional locations are simpler and
213    // just as reliable. The clear command, however, is nuget's own.
214    Probe {
215        manager: "nuget",
216        kind: "global packages",
217        query: None,
218        clear_command: "dotnet nuget locals global-packages --clear",
219        note: Some(
220            "every .NET project on the machine restores from here; re-downloaded on the next restore",
221        ),
222    },
223    Probe {
224        manager: "vcpkg",
225        kind: "binary cache",
226        query: None,
227        clear_command: VCPKG_ARCHIVES_CLEAR,
228        note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
229    },
230    Probe {
231        manager: "conan",
232        kind: "package cache",
233        query: None,
234        clear_command: "conan remove \"*\" --confirm",
235        note: Some(
236            "recipes and binaries shared by every Conan project; re-fetched on the next install",
237        ),
238    },
239];
240
241/// Run the `caches` command.
242pub fn run(json_output: bool) -> Result<()> {
243    let reports = collect(!json_output);
244
245    if json_output {
246        return json::emit(&json::caches_document(&reports));
247    }
248
249    print_report(&reports);
250    Ok(())
251}
252
253/// Find and size every cache on this machine, largest first.
254fn collect(spinner: bool) -> Vec<CacheReport> {
255    let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
256    let from = query_dir();
257
258    let mut seen: HashSet<PathBuf> = HashSet::new();
259    let mut reports = Vec::new();
260
261    for probe in PROBES {
262        let Some(path) = locate(probe, &from) else {
263            continue;
264        };
265        // Canonical, because two probes can land on the same directory — `GOCACHE` and
266        // `GOMODCACHE` are both under `~/.cache` on Linux, and a machine can be
267        // configured to share them. Counting one twice would inflate the total, which is
268        // the one number this command exists to get right. It also settles the spelling:
269        // a manager answers in whatever case and separators it likes, and two rows
270        // disagreeing about how to write `C:\Users` reads like a bug.
271        let path = path.canonicalize().unwrap_or(path);
272        if !seen.insert(path.clone()) {
273            continue;
274        }
275        reports.push(CacheReport {
276            manager: probe.manager,
277            kind: probe.kind,
278            bytes: adapters::dir_size(&path),
279            path,
280            clear_command: probe.clear_command,
281            note: probe.note,
282        });
283    }
284
285    if let Some(pb) = pb {
286        pb.finish_and_clear();
287    }
288
289    reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
290    reports
291}
292
293/// Where to run the "where is your cache?" queries from.
294///
295/// The home directory, not the current one. A project's `.npmrc` or `.cargo/config.toml`
296/// can move the cache for that project alone, and answering with it would report a
297/// directory that is not the machine's actual cache. Falling back to the current
298/// directory is only for the case where there is no home directory at all.
299fn query_dir() -> PathBuf {
300    dirs::home_dir()
301        .or_else(|| std::env::current_dir().ok())
302        .unwrap_or_else(|| PathBuf::from("."))
303}
304
305/// Resolve one probe to a directory that exists, or nothing.
306fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
307    if let Some((program, args)) = probe.query
308        && adapters::binary_available(program)
309    {
310        let answered = adapters::capture_command_with_timeout(
311            program,
312            args,
313            from,
314            std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
315        )
316        .ok()
317        .and_then(|raw| path_from_output(&raw))
318        .filter(|p| p.is_dir());
319        if answered.is_some() {
320            return answered;
321        }
322    }
323
324    // Either the manager is not installed, or it is and its cache has never been
325    // populated. The conventional location is still worth checking: an uninstalled
326    // manager leaves its cache behind, and that is exactly the multi-gigabyte directory
327    // nobody remembers.
328    fallbacks(probe.manager, probe.kind)
329        .into_iter()
330        .find(|p| p.is_dir())
331}
332
333/// Read a path out of a manager's answer.
334///
335/// The last non-empty line, because some managers print a notice first, and quotes are
336/// stripped because `go env` quotes paths containing spaces on Windows.
337fn path_from_output(raw: &str) -> Option<PathBuf> {
338    let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
339    let line = line.trim_matches('"');
340    // npm answers `undefined` for a config key it does not have, and a manager that
341    // errored can print anything at all. A relative path is never a machine-wide cache.
342    if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
343        return None;
344    }
345    Some(PathBuf::from(line))
346}
347
348/// Conventional locations for a cache, most likely first.
349fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
350    let home = dirs::home_dir();
351    let local = dirs::data_local_dir();
352    let cache = dirs::cache_dir();
353    // `rel` is split rather than joined whole so a Windows path never comes out as
354    // `C:\Users\dev\go\pkg/mod`. `Path::join` accepts the forward slashes, it just keeps
355    // them, and a report that spells the same drive two ways reads like a bug.
356    let under = |base: &Option<PathBuf>, rel: &str| {
357        base.as_ref()
358            .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
359    };
360
361    let candidates = match (manager, kind) {
362        // `npm config get cache` answers `~/.npm` on Unix and `%LocalAppData%\npm-cache`
363        // on Windows; the payload lives in `_cacache` underneath either one.
364        ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
365        ("pnpm", _) => vec![
366            under(&local, "pnpm/store"),
367            under(&home, ".local/share/pnpm/store"),
368            under(&home, "Library/pnpm/store"),
369            under(&home, ".pnpm-store"),
370        ],
371        ("yarn", _) => vec![
372            under(&home, ".yarn/berry/cache"),
373            under(&local, "Yarn/Cache"),
374            under(&cache, "yarn"),
375        ],
376        ("bun", _) => vec![under(&home, ".bun/install/cache")],
377        ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
378        ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
379        ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
380        ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
381        ("go", "module cache") => vec![
382            std::env::var_os("GOMODCACHE").map(PathBuf::from),
383            std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
384            under(&home, "go/pkg/mod"),
385        ],
386        ("go", _) => vec![
387            std::env::var_os("GOCACHE").map(PathBuf::from),
388            under(&cache, "go-build"),
389            under(&local, "go-build"),
390        ],
391        ("maven", _) => vec![under(&home, ".m2/repository")],
392        // GRADLE_USER_HOME relocates the whole ~/.gradle tree, caches and wrapper both.
393        ("gradle", "caches") => vec![
394            std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
395            under(&home, ".gradle/caches"),
396        ],
397        ("gradle", _) => vec![
398            std::env::var_os("GRADLE_USER_HOME")
399                .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
400            under(&home, ".gradle/wrapper/dists"),
401        ],
402        ("nuget", _) => vec![
403            std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
404            under(&home, ".nuget/packages"),
405        ],
406        ("vcpkg", _) => vec![
407            std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
408            under(&local, "vcpkg/archives"),
409            under(&cache, "vcpkg/archives"),
410        ],
411        // Conan 2 keeps packages under <CONAN_HOME>/p; pointing at `p` rather than the
412        // whole home keeps profiles and remotes out of the size (and out of harm's way).
413        ("conan", _) => vec![
414            std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
415            under(&home, ".conan2/p"),
416        ],
417        _ => vec![],
418    };
419
420    candidates.into_iter().flatten().collect()
421}
422
423/// `CARGO_HOME`, or the default cargo puts it in.
424fn cargo_home() -> PathBuf {
425    std::env::var_os("CARGO_HOME")
426        .map(PathBuf::from)
427        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
428        .unwrap_or_else(|| PathBuf::from(".cargo"))
429}
430
431fn print_report(reports: &[CacheReport]) {
432    output::print_header("Package manager caches");
433
434    if reports.is_empty() {
435        println!();
436        output::print_info("No package manager caches found on this machine.");
437        return;
438    }
439
440    println!();
441    for r in reports {
442        let label = format!("{} {}", r.manager, r.kind);
443        println!(
444            "  {:<22} {:>10}  {}",
445            label,
446            output::format_bytes(r.bytes),
447            output::clean_path(&r.path)
448        );
449        println!("  {:<22} {:>10}  clear: {}", "", "", r.clear_command);
450        if let Some(note) = r.note {
451            println!("  {:<22} {:>10}  {}", "", "", note);
452        }
453        println!();
454    }
455
456    let total: u64 = reports.iter().map(|r| r.bytes).sum();
457    println!(
458        "  {:<22} {:>10}  across {} {}",
459        "Total",
460        output::format_bytes(total),
461        reports.len(),
462        output::plural(reports.len(), "cache", "caches")
463    );
464
465    println!();
466    output::print_info(
467        "Nothing above was deleted, and dev-prune never deletes any of it. A cache is \
468         shared by every project on the machine, so no single repository's lockfile can \
469         prove it is recoverable — and it is what makes `devp restore` fast. Run a clear \
470         command yourself when you want the space more than the speed.",
471    );
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn every_probe_can_be_found_without_its_manager_installed() {
480        // A probe with no query and no fallbacks is a row that can never appear, which
481        // is a silent hole in the report rather than a test failure anywhere else.
482        for probe in PROBES {
483            assert!(
484                !fallbacks(probe.manager, probe.kind).is_empty(),
485                "{} {} has no conventional location",
486                probe.manager,
487                probe.kind
488            );
489        }
490    }
491
492    #[test]
493    fn every_probe_names_the_command_that_clears_it() {
494        for probe in PROBES {
495            assert!(
496                !probe.clear_command.trim().is_empty(),
497                "{} {} reports a size with no way to act on it",
498                probe.manager,
499                probe.kind
500            );
501        }
502    }
503
504    #[test]
505    fn no_two_probes_describe_the_same_cache() {
506        let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
507        let count = keys.len();
508        keys.sort_unstable();
509        keys.dedup();
510        assert_eq!(keys.len(), count, "two probes share a manager and kind");
511    }
512
513    #[test]
514    fn a_managers_answer_is_read_off_the_last_line() {
515        // npm prints notices before the value it was asked for.
516        let raw = if cfg!(windows) {
517            "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
518        } else {
519            "npm warn config global deprecated\n/home/dev/.npm\n"
520        };
521        assert!(path_from_output(raw).is_some());
522    }
523
524    #[test]
525    fn quoted_paths_lose_their_quotes() {
526        let raw = if cfg!(windows) {
527            "\"C:\\Program Files\\go\\pkg\\mod\"\n"
528        } else {
529            "\"/opt/go path/pkg/mod\"\n"
530        };
531        let path = path_from_output(raw).expect("a quoted path is still a path");
532        assert!(!path.to_string_lossy().contains('"'));
533    }
534
535    #[test]
536    fn a_non_answer_is_not_mistaken_for_a_path() {
537        // Each of these has been an actual answer from a package manager at some point,
538        // and treating any of them as a directory would size the wrong thing.
539        for raw in [
540            "",
541            "\n \n",
542            "undefined\n",
543            "not a command\n",
544            "./relative\n",
545        ] {
546            assert!(
547                path_from_output(raw).is_none(),
548                "{raw:?} was accepted as a cache path"
549            );
550        }
551    }
552
553    #[test]
554    fn the_cargo_rows_point_inside_the_registry() {
555        // Both cargo rows are fallback-only — cargo has no "where is your cache" query —
556        // so a wrong path here is a row that silently reports 0 B forever.
557        for kind in ["registry cache", "registry sources"] {
558            let path = fallbacks("cargo", kind).remove(0);
559            assert!(
560                path.starts_with(cargo_home().join("registry")),
561                "{kind} resolved outside the cargo registry: {}",
562                path.display()
563            );
564        }
565    }
566
567    #[test]
568    fn the_report_is_ordered_by_what_is_worth_clearing() {
569        let mut reports = [
570            CacheReport {
571                manager: "npm",
572                kind: "cache",
573                path: PathBuf::from("/a"),
574                bytes: 10,
575                clear_command: "x",
576                note: None,
577            },
578            CacheReport {
579                manager: "go",
580                kind: "module cache",
581                path: PathBuf::from("/b"),
582                bytes: 4_000,
583                clear_command: "y",
584                note: None,
585            },
586        ];
587        reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
588        assert_eq!(reports[0].manager, "go");
589    }
590}