Skip to main content

dev_prune/commands/
caches.rs

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