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// **Nothing here ever runs on its own.** A cache is shared by every project on the
16// machine, so its contents are not something dev-prune can prove is recoverable for any
17// one repository — which is the bar every deletion in the prune path has to clear. So no
18// scheduler, no Git hook and no `devp run` will ever touch one, and `devp caches` on its
19// own still deletes nothing.
20//
21// `devp caches clear <manager>` exists because typing the command this report already
22// prints is the whole of what it does. It names what it is about to empty, says what
23// that costs — a cleared cache turns the next `devp restore` into a download — and asks
24// before it does it.
25//
26// Clearing prefers the manager's own subcommand (`npm cache clean --force`, `go clean
27// -modcache`) over deleting a directory: the manager knows what is safe to keep, and its
28// own bookkeeping stays consistent. The managers that ship no such subcommand — cargo,
29// gradle, vcpkg — are cleared by removing the directory, and the path removed is the one
30// this command resolved and sized, never a string handed to a shell.
31//
32// Maven is reported and never cleared. `~/.m2/repository` is an install target as well
33// as a download cache, and `MAVEN_MANUAL` below is the long version of why that puts it
34// out of reach of a tool that deletes only what it can prove is recoverable.
35//
36// Each manager is asked where its own cache lives rather than being assumed — a
37// `CARGO_HOME`, a `--cache-dir`, a corporate `.npmrc` all move it. Every one of those
38// queries is read-only, and a manager that is not installed falls back to the
39// conventional location, so a cache left behind by an uninstalled manager still shows up.
40
41use 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
53/// One cache directory that exists on this machine.
54pub struct CacheReport {
55    /// The package manager that owns it.
56    pub manager: &'static str,
57    /// Which of that manager's caches this is, when it keeps more than one.
58    pub kind: &'static str,
59    /// Where it actually is, as resolved on this machine.
60    pub path: PathBuf,
61    /// Total size on disk.
62    pub bytes: u64,
63    /// The command that empties it, as a human would type it.
64    ///
65    /// Owned rather than borrowed because one row's command names a path: a pnpm store
66    /// on a volume of its own is emptied by `pnpm store prune --store-dir <that store>`,
67    /// and no fixed string can say which one.
68    pub clear_command: String,
69    /// How `devp caches clear` empties it.
70    pub clear: Clear,
71    /// What the user gives up by running that command, when it is more than time.
72    pub note: Option<&'static str>,
73    /// The size cap set for this manager in `cache_max_gb`, in gibibytes.
74    ///
75    /// `None` when none is set, which is the default and means this cache is never
76    /// called too big.
77    pub cap_gb: Option<u64>,
78    /// Whether this manager's caches add up to more than [`Self::cap_gb`].
79    ///
80    /// Per *manager*, not per row: cargo keeps a registry cache and an unpacked source
81    /// tree, go keeps a build cache and a module cache, and "cargo is over ten
82    /// gigabytes" is a statement about the pair. Every row of an over-cap manager is
83    /// marked, because clearing only one of them is not what the cap asked for.
84    pub over_cap: bool,
85    /// How many registered repositories use this manager, or `None` where dev-prune
86    /// cannot say.
87    ///
88    /// `None` is not zero. It is the honest answer for the caches no adapter is named
89    /// after — `pip`, `conda`, `nuget`, `conan`, `ccache`, `sccache`, `hex`,
90    /// `playwright`, `puppeteer`, `huggingface`, `cypress` and `electron` — where
91    /// deciding which projects feed them
92    /// would mean inventing a mapping dev-prune has never verified, and it is the answer
93    /// again when there is no registry to compare against. Only
94    /// `Some(0)` means "nothing registered on this machine needs this", and that is the
95    /// one reading `devp caches clear --unused` is allowed to act on.
96    pub dependents: Option<usize>,
97    /// Arguments appended to [`Self::clear`]'s command for this row alone.
98    ///
99    /// Empty for every cache a manager finds on its own. It exists for the one that a
100    /// manager does *not*: `pnpm store prune` prunes the store for the filesystem it is
101    /// run on, so emptying a store on another volume means naming it. Appended to both
102    /// the command dev-prune runs and the [`Self::clear_command`] it prints, so the two
103    /// cannot say different things.
104    pub extra_args: Vec<String>,
105}
106
107/// How one cache is emptied.
108#[derive(Clone, Copy)]
109pub enum Clear {
110    /// The manager's own subcommand, as `(program, args)`. Preferred wherever one
111    /// exists — `pnpm store prune` and `uv cache prune` keep what is still referenced,
112    /// which no directory delete can work out.
113    Command(&'static str, &'static [&'static str]),
114    /// Delete the directory this command resolved and sized. Only for the managers that
115    /// ship nothing equivalent.
116    Directory,
117    /// Report it, print the command, and refuse to run it. For the one store that is not
118    /// a cache: see the maven entry for the reason a deletion here cannot be proven
119    /// recoverable. `why` is printed to the user in place of doing it.
120    Manual { why: &'static str },
121}
122
123/// How to find one cache.
124struct Probe {
125    manager: &'static str,
126    kind: &'static str,
127    /// The manager's own answer to "where is it?", as `(program, args)`.
128    ///
129    /// All of these print a path and exit; none of them writes anything or creates the
130    /// directory. `None` means the ecosystem has no such query and only the conventional
131    /// locations are available.
132    query: Option<(&'static str, &'static [&'static str])>,
133    /// Appended to whatever [`Self::query`] answered, for the managers that will only
134    /// name a directory one level above their cache.
135    ///
136    /// `gem env gemdir` prints the gem home, which holds installed gems, binstubs and
137    /// the `.gem` archives that are the cache; `poetry config cache-dir` prints a
138    /// directory holding both `artifacts/` and `virtualenvs/`. Sizing or deleting either
139    /// answer whole would take environments and installed packages with it, so the probe
140    /// names the one subdirectory that is genuinely a download cache. Ignored by the
141    /// fallback list, which already spells the full path.
142    query_suffix: Option<&'static str>,
143    clear_command: &'static str,
144    clear: Clear,
145    note: Option<&'static str>,
146}
147
148/// cargo ships no cache subcommand, so the only honest "how do I clear this" is the
149/// deletion itself. `cargo build` re-downloads and re-extracts what it needs.
150#[cfg(windows)]
151const CARGO_CACHE_CLEAR: &str =
152    r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
153#[cfg(not(windows))]
154const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
155
156#[cfg(windows)]
157const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
158#[cfg(not(windows))]
159const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
160
161/// Maven has no cache subcommand either — `mvn dependency:purge-local-repository`
162/// exists, but it needs a project to run in and re-resolves as it purges, which is not
163/// "clear the cache". The honest command is the deletion, so that is what gets printed —
164/// but dev-prune does not run it. See [`MAVEN_MANUAL`].
165#[cfg(windows)]
166const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
167#[cfg(not(windows))]
168const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
169
170/// Why `devp caches clear maven` refuses.
171///
172/// `~/.m2/repository` is the one entry in this table that is not a cache, and Maven does
173/// not call it one either — it is the *local repository*, and `mvn install` writes into
174/// it. Two things live there that no remote can hand back:
175///
176/// * artifacts put there by `mvn install:install-file`, which is the documented way to
177///   use a jar that is in no repository at all — a driver behind a click-through
178///   licence, a partner SDK, an internal artifact from before there was an internal
179///   Nexus. There is nothing to re-download them *from*.
180/// * `-SNAPSHOT` builds of the user's own modules, which are recoverable only for as
181///   long as the source that produced them is still on the machine and still builds.
182///
183/// Maven does record which remote each artifact came from, in a `_remote.repositories`
184/// file it documents as internal and free to change without notice — and one written
185/// only by Maven 3 and later, so an older or legacy-mode repository has none at all.
186/// Deleting on the strength of that would mean betting the unrecoverable half of the
187/// tree on a file format with no compatibility promise. Sizing it and printing the
188/// command is the whole of what can be done honestly.
189const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
190     download cache: `mvn install` and `install:install-file` write artifacts there \
191     that exist nowhere else, and nothing in the tree tells them apart from the \
192     downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
193     the command; running it is yours to decide.";
194
195#[cfg(windows)]
196const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
197#[cfg(not(windows))]
198const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
199
200#[cfg(windows)]
201const GRADLE_DISTS_CLEAR: &str =
202    r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
203#[cfg(not(windows))]
204const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
205
206#[cfg(windows)]
207const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
208#[cfg(not(windows))]
209const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
210
211/// Hex has no cache-clearing task. hexpm/hex#344 asked for one and there still is not
212/// one, so the honest command is the deletion; `mix deps.get` re-fetches the tarballs.
213#[cfg(windows)]
214const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
215#[cfg(not(windows))]
216const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
217
218/// RubyGems has no command for this. `gem cleanup` removes *older versions of
219/// installed gems*, which touches the installed tree and leaves the downloads alone —
220/// the opposite of what is wanted here. The `.gem` archives under the gem home are the
221/// cache, and deleting them is the only command there is.
222#[cfg(windows)]
223const BUNDLER_CACHE_CLEAR: &str = "Remove-Item -Recurse -Force \"$(gem env gemdir)\\cache\"";
224#[cfg(not(windows))]
225const BUNDLER_CACHE_CLEAR: &str = "rm -rf \"$(gem env gemdir)/cache\"";
226
227/// `dart pub cache clean` empties the whole pub cache, including the `git/` checkouts
228/// and the `bin/` shims `dart pub global activate` writes — none of which is a download
229/// this tool can prove recoverable. `hosted/` is the part that is, so that is the part
230/// reported and the part deleted.
231#[cfg(windows)]
232const DART_HOSTED_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Pub\Cache\hosted";
233#[cfg(not(windows))]
234const DART_HOSTED_CLEAR: &str = "rm -rf ~/.pub-cache/hosted";
235
236/// `swift package purge-cache` exists but has to be run from inside a package
237/// directory, which makes it a per-project command for a machine-wide store. The
238/// deletion is what a person can actually type.
239#[cfg(windows)]
240const SWIFTPM_CACHE_CLEAR: &str =
241    r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\org.swift.swiftpm";
242#[cfg(target_os = "macos")]
243const SWIFTPM_CACHE_CLEAR: &str = "rm -rf ~/Library/Caches/org.swift.swiftpm";
244#[cfg(not(any(windows, target_os = "macos")))]
245const SWIFTPM_CACHE_CLEAR: &str = "rm -rf ~/.cache/org.swift.swiftpm";
246
247#[cfg(windows)]
248const TERRAFORM_PLUGIN_CLEAR: &str =
249    r"Remove-Item -Recurse -Force $env:APPDATA\terraform.d\plugin-cache";
250#[cfg(not(windows))]
251const TERRAFORM_PLUGIN_CLEAR: &str = "rm -rf ~/.terraform.d/plugin-cache";
252
253/// Poetry's cache directory holds `artifacts/` beside `virtualenvs/`. Only the first is
254/// a cache; the second is where the environments themselves live, and nothing here will
255/// go near it.
256#[cfg(windows)]
257const POETRY_ARTIFACTS_CLEAR: &str =
258    "Remove-Item -Recurse -Force \"$(poetry config cache-dir)\\artifacts\"";
259#[cfg(not(windows))]
260const POETRY_ARTIFACTS_CLEAR: &str = "rm -rf \"$(poetry config cache-dir)/artifacts\"";
261
262/// sccache ships no cache-clearing subcommand — `--stop-server` stops the daemon and
263/// nothing empties the directory — so the directory delete is the only unattended way.
264#[cfg(windows)]
265const SCCACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Mozilla\sccache";
266#[cfg(target_os = "macos")]
267const SCCACHE_CLEAR: &str = "rm -rf ~/Library/Caches/Mozilla.sccache";
268#[cfg(not(any(windows, target_os = "macos")))]
269const SCCACHE_CLEAR: &str = "rm -rf ~/.cache/sccache";
270
271/// Playwright keeps one unpacked browser build per version and removes none of them on
272/// upgrade, so this grows by a few hundred megabytes every time the dependency moves.
273#[cfg(windows)]
274const PLAYWRIGHT_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\ms-playwright";
275#[cfg(target_os = "macos")]
276const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/Library/Caches/ms-playwright";
277#[cfg(not(any(windows, target_os = "macos")))]
278const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/.cache/ms-playwright";
279
280/// Puppeteer resolves its download directory from the home directory on every platform
281/// rather than through the OS cache location, so this path is the same everywhere.
282#[cfg(windows)]
283const PUPPETEER_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\puppeteer";
284#[cfg(not(windows))]
285const PUPPETEER_CLEAR: &str = "rm -rf ~/.cache/puppeteer";
286
287/// `hf cache delete` — `huggingface-cli delete-cache` before it — is an interactive
288/// picker: it draws a checklist and waits for a keypress. dev-prune runs its clear
289/// commands with no terminal attached and a timeout, so driving that would hang until
290/// the timeout and delete nothing. The directory delete is what can be done unattended.
291#[cfg(windows)]
292const HUGGINGFACE_CLEAR: &str =
293    r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\huggingface\hub";
294#[cfg(not(windows))]
295const HUGGINGFACE_CLEAR: &str = "rm -rf ~/.cache/huggingface/hub";
296
297#[cfg(windows)]
298const CYPRESS_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Cypress\Cache";
299#[cfg(target_os = "macos")]
300const CYPRESS_CLEAR: &str = "rm -rf ~/Library/Caches/Cypress";
301#[cfg(not(any(windows, target_os = "macos")))]
302const CYPRESS_CLEAR: &str = "rm -rf ~/.cache/Cypress";
303
304#[cfg(windows)]
305const ELECTRON_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron\Cache";
306#[cfg(target_os = "macos")]
307const ELECTRON_CLEAR: &str = "rm -rf ~/Library/Caches/electron";
308#[cfg(not(any(windows, target_os = "macos")))]
309const ELECTRON_CLEAR: &str = "rm -rf ~/.cache/electron";
310
311#[cfg(windows)]
312const ELECTRON_BUILDER_CLEAR: &str =
313    r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron-builder\Cache";
314#[cfg(target_os = "macos")]
315const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/Library/Caches/electron-builder";
316#[cfg(not(any(windows, target_os = "macos")))]
317const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/.cache/electron-builder";
318
319const PROBES: &[Probe] = &[
320    Probe {
321        manager: "npm",
322        kind: "cache",
323        query: Some(("npm", &["config", "get", "cache"])),
324        query_suffix: None,
325        clear_command: "npm cache clean --force",
326        clear: Clear::Command("npm", &["cache", "clean", "--force"]),
327        note: None,
328    },
329    Probe {
330        manager: "pnpm",
331        kind: "store",
332        query: Some(("pnpm", &["store", "path"])),
333        query_suffix: None,
334        clear_command: "pnpm store prune",
335        clear: Clear::Command("pnpm", &["store", "prune"]),
336        note: Some(
337            "hardlinked into every node_modules it filled; emptying it is what makes the \
338             next pnpm install a download",
339        ),
340    },
341    Probe {
342        manager: "yarn",
343        kind: "cache",
344        query: Some(("yarn", &["cache", "dir"])),
345        query_suffix: None,
346        clear_command: "yarn cache clean",
347        clear: Clear::Command("yarn", &["cache", "clean"]),
348        note: None,
349    },
350    Probe {
351        manager: "bun",
352        kind: "cache",
353        query: Some(("bun", &["pm", "cache"])),
354        query_suffix: None,
355        clear_command: "bun pm cache rm",
356        clear: Clear::Command("bun", &["pm", "cache", "rm"]),
357        note: None,
358    },
359    Probe {
360        manager: "uv",
361        kind: "cache",
362        query: Some(("uv", &["cache", "dir"])),
363        query_suffix: None,
364        // `prune` drops what nothing can use again and keeps the rest; `uv cache clean`
365        // is the sledgehammer, and is not what most people mean by "clear the cache".
366        clear_command: "uv cache prune",
367        clear: Clear::Command("uv", &["cache", "prune"]),
368        note: None,
369    },
370    Probe {
371        manager: "pip",
372        kind: "cache",
373        query: Some(("pip", &["cache", "dir"])),
374        query_suffix: None,
375        clear_command: "pip cache purge",
376        clear: Clear::Command("pip", &["cache", "purge"]),
377        note: None,
378    },
379    // conda ships a command that prints the package directories, but it is `conda config
380    // --show pkgs_dirs` and conda takes seconds to start on a cold shell — the same price
381    // Maven charges, for the same read-only size report. So this row is the conventional
382    // locations plus `CONDA_EXE`, which every conda shell exports and which names the
383    // installation root wherever someone put it.
384    Probe {
385        manager: "conda",
386        kind: "package cache",
387        query: None,
388        query_suffix: None,
389        clear_command: "conda clean --packages --tarballs --yes",
390        clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
391        note: Some(
392            "unpacked packages and downloaded archives; conda keeps what its \
393             environments use, except any it linked by symlink rather than hardlink",
394        ),
395    },
396    Probe {
397        manager: "cargo",
398        kind: "registry cache",
399        query: None,
400        query_suffix: None,
401        clear_command: CARGO_CACHE_CLEAR,
402        clear: Clear::Directory,
403        note: Some("the downloaded .crate archives; clearing them means downloading again"),
404    },
405    Probe {
406        manager: "cargo",
407        kind: "registry sources",
408        query: None,
409        query_suffix: None,
410        clear_command: CARGO_SRC_CLEAR,
411        clear: Clear::Directory,
412        note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
413    },
414    Probe {
415        manager: "go",
416        kind: "module cache",
417        query: Some(("go", &["env", "GOMODCACHE"])),
418        query_suffix: None,
419        clear_command: "go clean -modcache",
420        clear: Clear::Command("go", &["clean", "-modcache"]),
421        note: None,
422    },
423    Probe {
424        manager: "go",
425        kind: "build cache",
426        query: Some(("go", &["env", "GOCACHE"])),
427        query_suffix: None,
428        clear_command: "go clean -cache",
429        clear: Clear::Command("go", &["clean", "-cache"]),
430        note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
431    },
432    // `mvn help:evaluate -Dexpression=settings.localRepository` would answer precisely,
433    // but it boots a JVM, resolves the help plugin over the network on first use, and
434    // takes several seconds — the wrong trade for a read-only size report. A relocated
435    // repository (settings.xml `<localRepository>`) is rare enough to miss.
436    Probe {
437        manager: "maven",
438        kind: "local repository",
439        query: None,
440        query_suffix: None,
441        clear_command: MAVEN_REPO_CLEAR,
442        clear: Clear::Manual { why: MAVEN_MANUAL },
443        note: Some(
444            "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
445        ),
446    },
447    Probe {
448        manager: "gradle",
449        kind: "caches",
450        query: None,
451        query_suffix: None,
452        clear_command: GRADLE_CACHE_CLEAR,
453        clear: Clear::Directory,
454        note: Some(
455            "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
456        ),
457    },
458    Probe {
459        manager: "gradle",
460        kind: "wrapper distributions",
461        query: None,
462        query_suffix: None,
463        clear_command: GRADLE_DISTS_CLEAR,
464        clear: Clear::Directory,
465        note: Some(
466            "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
467        ),
468    },
469    // `dotnet nuget locals global-packages --list` answers `global-packages: <path>` —
470    // a labelled line, not a bare path — so the conventional locations are simpler and
471    // just as reliable. The clear command, however, is nuget's own.
472    Probe {
473        manager: "nuget",
474        kind: "global packages",
475        query: None,
476        query_suffix: None,
477        clear_command: "dotnet nuget locals global-packages --clear",
478        clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
479        note: Some(
480            "every .NET project on the machine restores from here; re-downloaded on the next restore",
481        ),
482    },
483    Probe {
484        manager: "vcpkg",
485        kind: "binary cache",
486        query: None,
487        query_suffix: None,
488        clear_command: VCPKG_ARCHIVES_CLEAR,
489        clear: Clear::Directory,
490        note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
491    },
492    Probe {
493        manager: "conan",
494        kind: "package cache",
495        query: None,
496        query_suffix: None,
497        clear_command: "conan remove \"*\" --confirm",
498        clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
499        note: Some(
500            "recipes and binaries shared by every Conan project; re-fetched on the next install",
501        ),
502    },
503    // `ccache --get-config cache_dir` prints the directory bare, which is exactly the
504    // shape `path_from_output` reads; the manual documents `-k`/`--get-config` and
505    // `-C`/`--clear` back to the 3.x line, so the long spellings work everywhere.
506    Probe {
507        manager: "ccache",
508        kind: "compiler cache",
509        query: Some(("ccache", &["--get-config", "cache_dir"])),
510        query_suffix: None,
511        clear_command: "ccache --clear",
512        clear: Clear::Command("ccache", &["--clear"]),
513        note: Some(
514            "compiled objects keyed by source and flags; the next build repopulates it one cache miss at a time",
515        ),
516    },
517    // sccache prints statistics, never paths — every `--show-stats` line is labelled —
518    // and ships no clear subcommand, so both halves of this row are the conventional
519    // locations.
520    Probe {
521        manager: "sccache",
522        kind: "compiler cache",
523        query: None,
524        query_suffix: None,
525        clear_command: SCCACHE_CLEAR,
526        clear: Clear::Directory,
527        note: Some(
528            "compiled objects keyed by source and flags; the next build repopulates it. If a clear is refused, `sccache --stop-server` first — the daemon holds the directory open",
529        ),
530    },
531    // Composer will say where its cache is, and asking is the only way to get it right:
532    // the directory moves with `COMPOSER_HOME`, with `COMPOSER_CACHE_DIR`, and with a
533    // `cache-dir` written into the global config, and the default differs on all three
534    // platforms. That is four ways to be wrong and one command that is not.
535    Probe {
536        manager: "composer",
537        kind: "cache",
538        query: Some(("composer", &["config", "--global", "cache-dir"])),
539        query_suffix: None,
540        clear_command: "composer clear-cache",
541        clear: Clear::Command("composer", &["clear-cache"]),
542        note: Some(
543            "downloaded package archives and repository metadata; re-fetched by the next composer install",
544        ),
545    },
546    // CocoaPods ships no command that prints the cache directory — `pod cache list`
547    // prints its *contents* — so this row is the conventional location plus the
548    // relocation variable. Emptying it is still CocoaPods' own job: the cache is keyed by
549    // pod name and version and it keeps an index of what is in there.
550    Probe {
551        manager: "cocoapods",
552        kind: "cache",
553        query: None,
554        query_suffix: None,
555        clear_command: "pod cache clean --all",
556        clear: Clear::Command("pod", &["cache", "clean", "--all"]),
557        note: Some("downloaded pod sources, re-fetched by the next pod install"),
558    },
559    Probe {
560        manager: "hex",
561        kind: "package cache",
562        query: None,
563        query_suffix: None,
564        clear_command: HEX_CACHE_CLEAR,
565        clear: Clear::Directory,
566        note: Some(
567            "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
568        ),
569    },
570    // ---------------------------------------------------------------------------
571    // Adapters that prune a project directory but whose machine-wide store had no
572    // probe: `devp run` cleaned the project and `devp caches` could not see where the
573    // bytes it had just freed came back from.
574    // ---------------------------------------------------------------------------
575    Probe {
576        manager: "bundler",
577        kind: "gem cache",
578        query: Some(("gem", &["env", "gemdir"])),
579        query_suffix: Some("cache"),
580        clear_command: BUNDLER_CACHE_CLEAR,
581        clear: Clear::Directory,
582        note: Some(
583            "only the `cache/` subdirectory of the gem home, which holds the downloaded \
584             `.gem` archives. The installed gems beside it, and the binstubs in `bin/`, \
585             are untouched — including anything put there by `gem install ./local.gem`, \
586             which no remote could hand back.",
587        ),
588    },
589    Probe {
590        manager: "dart",
591        kind: "pub cache",
592        query: None,
593        query_suffix: None,
594        clear_command: DART_HOSTED_CLEAR,
595        clear: Clear::Directory,
596        note: Some(
597            "only `hosted/`, the packages pub downloaded from a registry. The `git/` \
598             checkouts and the executables `dart pub global activate` installed live \
599             beside it and are left alone; `dart pub cache clean` would take all three.",
600        ),
601    },
602    Probe {
603        manager: "swift",
604        kind: "swiftpm cache",
605        query: None,
606        query_suffix: None,
607        clear_command: SWIFTPM_CACHE_CLEAR,
608        clear: Clear::Directory,
609        note: Some(
610            "the shared repository and manifest cache SwiftPM fills for every package on \
611             the machine. `swift build` re-clones what it needs, so the first build after \
612             this one is a network build.",
613        ),
614    },
615    Probe {
616        manager: "terraform",
617        kind: "plugin cache",
618        query: None,
619        query_suffix: None,
620        clear_command: TERRAFORM_PLUGIN_CLEAR,
621        clear: Clear::Directory,
622        note: Some(
623            "this exists only if you switched it on with `TF_PLUGIN_CACHE_DIR` or \
624             `plugin_cache_dir` in `.terraformrc`. Without it Terraform downloads a \
625             private copy of every provider into each project's `.terraform/` — which is \
626             what `devp run` prunes — and there is no shared store to report.",
627        ),
628    },
629    Probe {
630        manager: "poetry",
631        kind: "artifact cache",
632        query: Some(("poetry", &["config", "cache-dir"])),
633        query_suffix: Some("artifacts"),
634        clear_command: POETRY_ARTIFACTS_CLEAR,
635        clear: Clear::Directory,
636        note: Some(
637            "only `artifacts/`, the wheels and sdists Poetry downloaded. `virtualenvs/` \
638             sits in the same cache directory and holds the environments themselves — \
639             deleting that would not be clearing a cache.",
640        ),
641    },
642    Probe {
643        manager: "pdm",
644        kind: "cache",
645        query: Some(("pdm", &["config", "cache_dir"])),
646        query_suffix: None,
647        clear_command: "pdm cache clear",
648        clear: Clear::Command("pdm", &["cache", "clear"]),
649        note: None,
650    },
651    // ---------------------------------------------------------------------------
652    // Stores with no adapter of the same name, added because each of them routinely
653    // reaches gigabytes and nothing ever removes an old entry from them. They get no
654    // `dependents` count, for the reason `Dependents` gives.
655    // ---------------------------------------------------------------------------
656    Probe {
657        manager: "playwright",
658        kind: "browser bundles",
659        query: None,
660        query_suffix: None,
661        clear_command: PLAYWRIGHT_CLEAR,
662        clear: Clear::Directory,
663        note: Some(
664            "a full Chromium, Firefox and WebKit build per Playwright version, and the \
665             old ones are never removed when the dependency moves. `npx playwright \
666             install` re-downloads only the versions the installed Playwright asks for, \
667             which is usually why this comes back smaller than it was.",
668        ),
669    },
670    Probe {
671        manager: "puppeteer",
672        kind: "browser bundles",
673        query: None,
674        query_suffix: None,
675        clear_command: PUPPETEER_CLEAR,
676        clear: Clear::Directory,
677        note: Some(
678            "one Chrome build per Puppeteer version. `npx puppeteer browsers install` \
679             re-downloads the current one.",
680        ),
681    },
682    Probe {
683        manager: "huggingface",
684        kind: "hub cache",
685        query: None,
686        query_suffix: None,
687        clear_command: HUGGINGFACE_CLEAR,
688        clear: Clear::Directory,
689        note: Some(
690            "model weights, not packages. This is the one row here where re-downloading \
691             is measured in tens of gigabytes and can fail outright: a gated or \
692             now-private repository will not hand the file back, and a checkpoint from a \
693             revision that has since been deleted is gone. Worth looking at what is in it \
694             before emptying it.",
695        ),
696    },
697    Probe {
698        manager: "cypress",
699        kind: "binary cache",
700        query: None,
701        query_suffix: None,
702        clear_command: CYPRESS_CLEAR,
703        clear: Clear::Directory,
704        note: Some(
705            "one unpacked Electron application per Cypress version, around half a \
706             gigabyte each, and upgrading leaves the previous one in place. `npx cypress \
707             install` re-downloads the version the project pins.",
708        ),
709    },
710    Probe {
711        manager: "electron",
712        kind: "download cache",
713        query: None,
714        query_suffix: None,
715        clear_command: ELECTRON_CLEAR,
716        clear: Clear::Directory,
717        note: Some(
718            "the prebuilt Electron archives every `electron` dependency downloads, kept \
719             per version and per architecture. Re-downloaded on the next install.",
720        ),
721    },
722    Probe {
723        manager: "electron",
724        kind: "builder cache",
725        query: None,
726        query_suffix: None,
727        clear_command: ELECTRON_BUILDER_CLEAR,
728        clear: Clear::Directory,
729        note: Some(
730            "electron-builder's own store of signing tools, `winCodeSign`, `nsis` and the \
731             runtimes it packages with. Re-downloaded on the next build, which makes that \
732             build slow rather than broken.",
733        ),
734    },
735    Probe {
736        manager: "deno",
737        kind: "cache",
738        query: None,
739        query_suffix: None,
740        clear_command: "deno clean",
741        clear: Clear::Command("deno", &["clean"]),
742        note: None,
743    },
744];
745
746/// Run the `caches` command, for the whole machine or for one drive.
747pub fn run(json_output: bool, volume: Option<&str>) -> Result<()> {
748    // Before the size walk, so a drive that was mistyped costs nothing.
749    let volume = volume.map(resolve_volume).transpose()?;
750
751    let reg = registered();
752    let mut reports = collect(!json_output, reg.as_ref());
753    apply_caps(&mut reports, &caps());
754    let deps = reg.as_ref().map(|r| dependents(r, !json_output));
755    apply_dependents(&mut reports, deps.as_ref());
756
757    // Narrowed last, after every verdict is decided, so each one stays a statement about
758    // the whole machine. A cap is per manager wherever that manager's caches are, and
759    // "no registered repository uses this" must not become true just because the
760    // repositories that do use it are on another drive.
761    if let Some(root) = &volume {
762        reports = on_volume(reports, root);
763    }
764
765    // Asked here rather than left to `devp caches containers`, because the mistake this
766    // report exists to prevent is someone clearing 6 GB of npm cache while a stopped
767    // Docker daemon holds 40 GB they were never told about. It costs one `system df` per
768    // installed engine and nothing at all on a machine with none.
769    //
770    // Skipped under `--volume`: an engine reports its own disk from inside a VM image
771    // that has no path on this filesystem, so there is no honest drive to file it under
772    // and quietly filing it under this one would be a fabricated number.
773    let engines = if volume.is_some() {
774        Vec::new()
775    } else {
776        container_summary(!json_output)
777    };
778
779    if json_output {
780        return json::emit(&json::caches_document(
781            &reports,
782            deps.as_ref().map(|d| d.repositories),
783            &engines,
784            volume.as_deref(),
785        ));
786    }
787
788    print_report(&reports, deps.as_ref(), volume.as_deref());
789    crate::commands::containers::print_summary(&engines);
790    Ok(())
791}
792
793/// Turn what someone typed after `--volume` into the root of a drive or filesystem.
794///
795/// Anything on the volume is accepted, not just its root, because the question is "which
796/// drive" and a path the reader already has in their hand answers it.
797fn resolve_volume(arg: &str) -> Result<PathBuf> {
798    let path = absolutize(normalize_volume_arg(arg));
799    volume_root(&path).ok_or_else(|| {
800        anyhow::Error::new(crate::UsageError(format!(
801            "`{arg}` is not a drive or a path on one. Name a drive (`--volume V:`), a \
802             mount point (`--volume /mnt/data`), or any path that sits on the one you \
803             mean."
804        )))
805    })
806}
807
808/// A relative path names a volume as well as an absolute one, and `--volume .` — the
809/// drive you are standing on — is the shortest way to say it. Neither [`volume_root`]
810/// can see it: the Windows one reads a prefix a relative path has not got, and the Unix
811/// one walks ancestors that stop at the working directory instead of the mount point.
812///
813/// Only a path that exists is expanded. Every unrecognised word is also a valid relative
814/// path on Windows, so absolutising unconditionally would turn `--volume typo` into a
815/// silent report about the current drive — and "that is not a drive" is the answer that
816/// sends someone back to check what they typed.
817fn absolutize(path: PathBuf) -> PathBuf {
818    if path.is_absolute() || !path.exists() {
819        return path;
820    }
821    std::path::absolute(&path).unwrap_or(path)
822}
823
824/// What a person types when asked which drive, turned into a path with a root.
825///
826/// `V:` is a drive-*relative* path to Windows — "wherever the current directory on V:
827/// is" — and has no root component, so [`volume_root`] refuses it. It is also, with a
828/// bare `V`, exactly what gets typed. Anything else is passed through untouched and
829/// stands or falls as a path.
830#[cfg(windows)]
831fn normalize_volume_arg(arg: &str) -> PathBuf {
832    let trimmed = arg.trim();
833    let letter = match trimmed.as_bytes() {
834        [c] if c.is_ascii_alphabetic() => Some(*c),
835        [c, b':'] if c.is_ascii_alphabetic() => Some(*c),
836        _ => None,
837    };
838    match letter {
839        Some(c) => PathBuf::from(format!("{}:\\", c.to_ascii_uppercase() as char)),
840        None => PathBuf::from(trimmed),
841    }
842}
843
844/// Unix has no drive letters to expand, so a mount point is already a path.
845#[cfg(unix)]
846fn normalize_volume_arg(arg: &str) -> PathBuf {
847    PathBuf::from(arg.trim())
848}
849
850/// Whether two volume roots are the same volume.
851///
852/// Compared by what the prefix *means*, never by its text. A cache path that came back
853/// from `canonicalize` carries the verbatim prefix — `\?\C:\` — and the drive someone
854/// typed is `C:\`; those are one drive, and a string comparison says they are two, which
855/// is a filter that silently reports every machine as having no caches anywhere.
856#[cfg(windows)]
857fn same_volume(a: &Path, b: &Path) -> bool {
858    match (volume_key(a), volume_key(b)) {
859        (Some(a), Some(b)) => a == b,
860        _ => false,
861    }
862}
863
864/// What a Windows path's prefix names, with the verbatim spelling and the case removed.
865#[cfg(windows)]
866fn volume_key(path: &Path) -> Option<String> {
867    use std::path::{Component, Prefix};
868
869    let Some(Component::Prefix(prefix)) = path.components().next() else {
870        return None;
871    };
872    Some(match prefix.kind() {
873        Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
874            (letter.to_ascii_uppercase() as char).to_string()
875        }
876        Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => format!(
877            "{}\\{}",
878            server.to_string_lossy().to_uppercase(),
879            share.to_string_lossy().to_uppercase()
880        ),
881        other => format!("{other:?}").to_uppercase(),
882    })
883}
884
885/// Unix paths are bytes, and two mount points that differ in case are two mount points.
886#[cfg(unix)]
887fn same_volume(a: &Path, b: &Path) -> bool {
888    a == b
889}
890
891/// The rows that sit on one volume.
892///
893/// A row whose volume cannot be determined is dropped rather than kept: `--volume V:`
894/// asks for what is on V:, and "we could not tell" is not that.
895fn on_volume(reports: Vec<CacheReport>, root: &Path) -> Vec<CacheReport> {
896    reports
897        .into_iter()
898        .filter(|r| volume_root(&r.path).is_some_and(|v| same_volume(&v, root)))
899        .collect()
900}
901
902/// What each volume holds, largest first.
903///
904/// Rows whose volume is unknown are left out entirely rather than gathered under a
905/// heading, because a subtotal that does not add up to the total printed above it is
906/// worse than a subtotal that is missing.
907fn volume_totals(reports: &[CacheReport]) -> Vec<(String, u64)> {
908    let mut totals: Vec<(String, u64)> = Vec::new();
909    for r in reports {
910        let Some(root) = volume_root(&r.path) else {
911            continue;
912        };
913        let label = output::clean_path(&root);
914        match totals.iter_mut().find(|(l, _)| *l == label) {
915            Some((_, bytes)) => *bytes += r.bytes,
916            None => totals.push((label, r.bytes)),
917        }
918    }
919    totals.sort_by_key(|(_, bytes)| std::cmp::Reverse(*bytes));
920    totals
921}
922
923/// The container engines on this machine, behind the report's own spinner.
924fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
925    let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
926    let engines = crate::commands::containers::collect(None);
927    if let Some(pb) = pb {
928        pb.finish_and_clear();
929    }
930    engines
931}
932
933/// The user's `cache_max_gb`, or an empty map when the registry cannot be read.
934///
935/// A cap is a preference, and a preference that cannot be loaded is not a reason to
936/// refuse to report cache sizes — the command's whole job still works without it.
937fn caps() -> BTreeMap<String, u64> {
938    crate::config::Registry::load()
939        .map(|r| r.settings.cache_max_gb)
940        .unwrap_or_default()
941}
942
943/// Mark every row whose *manager* is over the cap set for it.
944///
945/// Split out from [`collect`] so the size walk stays a measurement and the verdict stays
946/// a separate, testable step over it.
947fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
948    let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
949    for r in reports.iter() {
950        *totals.entry(r.manager).or_default() += r.bytes;
951    }
952    for r in reports.iter_mut() {
953        // A cap naming the manager outranks the one that covers everything: somebody who
954        // wrote `default=10,npm=4` meant npm to be the exception, not to be held to both.
955        let Some(&gb) = caps
956            .get(r.manager)
957            .or_else(|| caps.get(constants::CACHE_CAP_DEFAULT_KEY))
958        else {
959            continue;
960        };
961        r.cap_gb = Some(gb);
962        r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
963            > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
964    }
965}
966
967/// The registered repositories that are actually on this disk.
968///
969/// Two of the questions this command answers are questions about the machine's
970/// repositories rather than about its caches — which filesystems hold projects, and
971/// which managers those projects use — so the registry is read once and handed to both.
972struct Registered {
973    /// Registry paths that still exist.
974    paths: Vec<PathBuf>,
975    /// The machine-wide scan depth, before any repository's own override.
976    scan_depth: usize,
977}
978
979/// Load the registry, or nothing when there is nothing in it worth loading.
980///
981/// `None` — not an empty list — for a registry that will not load, holds no
982/// repositories, or holds only paths that are no longer on disk. All three would
983/// otherwise make every cache on the machine read as used by nobody, and `--unused`
984/// would offer to empty the lot on the strength of a registry someone had simply not
985/// filled in yet.
986fn registered() -> Option<Registered> {
987    let registry = crate::config::Registry::load().ok()?;
988    let paths: Vec<PathBuf> = registry
989        .repositories
990        .keys()
991        .filter(|p| p.exists())
992        .cloned()
993        .collect();
994    if paths.is_empty() {
995        return None;
996    }
997    Some(Registered {
998        paths,
999        scan_depth: registry.settings.scan_depth,
1000    })
1001}
1002
1003/// How many registered repositories still use each package manager.
1004///
1005/// The report answers "how big is it". This answers the question that follows and that
1006/// nothing else on the machine can: *who still needs it*. A cache with no repository
1007/// behind it is sediment — everything in it was downloaded for projects that are no
1008/// longer here — and it is the only kind this tool will offer to clear on the strength
1009/// of a count.
1010struct Dependents {
1011    /// Registered repositories that are actually on this disk, and the denominator of
1012    /// every count below.
1013    repositories: usize,
1014    /// Repositories in which an adapter of this name was detected, keyed by manager.
1015    ///
1016    /// Only names that are both a cache in [`PROBES`] and an adapter appear at all. The
1017    /// rest are absent rather than zero, which is what carries the difference between
1018    /// "nothing uses it" and "dev-prune has no way to tell".
1019    by_manager: BTreeMap<&'static str, usize>,
1020}
1021
1022/// Count the repositories behind each cache.
1023///
1024/// Only ever called with a [`Registered`], which is the thing that carries "there is
1025/// something here to count against" — see [`registered`] for why the absence of one is
1026/// not the same as a count of zero.
1027fn dependents(reg: &Registered, spinner: bool) -> Dependents {
1028    let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
1029
1030    // Seeded at zero for every cache an adapter is named after, so a manager nothing uses
1031    // is a counted zero rather than a missing key. The twelve that are absent — `pip`,
1032    // `conda`, `nuget`, `conan`, `ccache`, `sccache`, `hex`, `playwright`, `puppeteer`,
1033    // `huggingface`, `cypress` and `electron` — stay absent: dev-prune ships no adapter
1034    // of those names,
1035    // and deciding that `venv` feeds `pip`, or that every `node_modules` on the disk
1036    // feeds the Playwright browser cache, would be a guess standing in for a
1037    // measurement.
1038    let mut by_manager: BTreeMap<&'static str, usize> = PROBES
1039        .iter()
1040        .map(|p| p.manager)
1041        .filter(|m| adapters::is_adapter_name(m))
1042        .map(|m| (m, 0))
1043        .collect();
1044
1045    for path in &reg.paths {
1046        // The repository's own `scan_depth` where it sets one, read exactly as a prune
1047        // pass reads it: a monorepo that had to raise its depth to be pruned properly has
1048        // to be walked to that same depth here, or its projects are invisible and the
1049        // managers behind them are undercounted.
1050        let depth = crate::workspace::clamp_depth(
1051            crate::config::PerRepoConfig::load_with_diagnostics(path)
1052                .ok()
1053                .flatten()
1054                .and_then(|c| c.scan_depth)
1055                .unwrap_or(reg.scan_depth),
1056        );
1057        let mut here: HashSet<&'static str> = HashSet::new();
1058        for project in crate::workspace::discover_all_to_depth(path, depth) {
1059            for adapter in &project.adapters {
1060                here.insert(adapter.name());
1061            }
1062        }
1063        for (manager, count) in by_manager.iter_mut() {
1064            if here.contains(manager) {
1065                *count += 1;
1066            }
1067        }
1068    }
1069
1070    if let Some(pb) = pb {
1071        pb.finish_and_clear();
1072    }
1073
1074    Dependents {
1075        repositories: reg.paths.len(),
1076        by_manager,
1077    }
1078}
1079
1080/// Hand each row the count for its manager, and leave the rest at `None`.
1081fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
1082    let Some(deps) = deps else {
1083        return;
1084    };
1085    for r in reports.iter_mut() {
1086        r.dependents = deps.by_manager.get(r.manager).copied();
1087    }
1088}
1089
1090/// What each manager's caches add up to, and how many rows it took.
1091///
1092/// The same total the cap is measured against, and for the same reason: "cargo" is one
1093/// cache to a person and two rows to this command. The row count rides along because a
1094/// total that spans more than one row has to say so where it is printed — see
1095/// [`used_by`].
1096fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, (u64, usize)> {
1097    let mut totals: BTreeMap<&'static str, (u64, usize)> = BTreeMap::new();
1098    for r in reports {
1099        let entry = totals.entry(r.manager).or_default();
1100        entry.0 += r.bytes;
1101        entry.1 += 1;
1102    }
1103    totals
1104}
1105
1106/// Find and size every cache on this machine, largest first.
1107fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
1108    let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
1109    let from = query_dir();
1110
1111    let mut seen: HashSet<PathBuf> = HashSet::new();
1112    let mut reports = Vec::new();
1113
1114    for probe in PROBES {
1115        let Some(path) = locate(probe, &from) else {
1116            continue;
1117        };
1118        // Canonical, because two probes can land on the same directory — `GOCACHE` and
1119        // `GOMODCACHE` are both under `~/.cache` on Linux, and a machine can be
1120        // configured to share them. Counting one twice would inflate the total, which is
1121        // the one number this command exists to get right. It also settles the spelling:
1122        // a manager answers in whatever case and separators it likes, and two rows
1123        // disagreeing about how to write `C:\Users` reads like a bug.
1124        let path = path.canonicalize().unwrap_or(path);
1125        if !seen.insert(path.clone()) {
1126            continue;
1127        }
1128        reports.push(CacheReport {
1129            manager: probe.manager,
1130            kind: probe.kind,
1131            bytes: adapters::dir_size(&path),
1132            path,
1133            clear_command: probe.clear_command.to_string(),
1134            clear: probe.clear,
1135            note: probe.note,
1136            cap_gb: None,
1137            over_cap: false,
1138            dependents: None,
1139            extra_args: Vec::new(),
1140        });
1141    }
1142
1143    // After the probes, so the ordinary case — home and projects on one filesystem, one
1144    // store, already found — does not get reported twice.
1145    for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
1146        if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
1147            continue;
1148        }
1149        reports.push(volume_store_report(store));
1150    }
1151
1152    if let Some(pb) = pb {
1153        pb.finish_and_clear();
1154    }
1155
1156    reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1157    reports
1158}
1159
1160/// Why a second pnpm store on one machine is not a duplicate.
1161const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
1162     this is the store for the projects on this volume";
1163
1164/// One row for a pnpm store that lives on a volume of its own.
1165///
1166/// The printed command and the arguments dev-prune runs are built from the same path, in
1167/// one place, because the whole point of printing a command is that it is the one being
1168/// run.
1169fn volume_store_report(store: PathBuf) -> CacheReport {
1170    let named = output::clean_path(&store);
1171    CacheReport {
1172        manager: "pnpm",
1173        kind: "store",
1174        bytes: adapters::dir_size(&store),
1175        clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
1176        extra_args: vec!["--store-dir".to_string(), named],
1177        path: store,
1178        clear: Clear::Command("pnpm", &["store", "prune"]),
1179        note: Some(PNPM_VOLUME_NOTE),
1180        cap_gb: None,
1181        over_cap: false,
1182        dependents: None,
1183    }
1184}
1185
1186/// Quote a path for the command line this report prints, and only when it needs it.
1187///
1188/// Only for display. The command dev-prune runs passes the path as one argument and
1189/// never goes near a shell.
1190fn shell_arg(named: &str) -> String {
1191    if named.contains(' ') {
1192        format!("\"{named}\"")
1193    } else {
1194        named.to_string()
1195    }
1196}
1197
1198/// pnpm stores sitting on a filesystem of their own, one per volume that holds a
1199/// registered repository.
1200///
1201/// pnpm hardlinks its store into every `node_modules` it fills, and a hardlink cannot
1202/// cross a filesystem. So a project that is not on the home directory's filesystem does
1203/// not use the store beside the home directory: pnpm puts one at the root of *that*
1204/// filesystem and fills it with everything those projects need. This is not a Windows
1205/// idea. It is the same rule for a second drive on Windows, a separate `/home` or
1206/// `/mnt/data` on Linux, and an external volume under `/Volumes` on macOS.
1207///
1208/// It has to be looked for, because the query the pnpm row otherwise trusts — `pnpm
1209/// store path` — answers for the filesystem it is run on, and it is run from the home
1210/// directory. On a machine whose projects all live on a second drive, that answer is a
1211/// nearly empty store and the real one, the multi-gigabyte one, is invisible.
1212fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
1213    // The volume the command was run from counts as well as the registered ones. A
1214    // machine with nothing linked yet has no registry to read, and standing in the
1215    // project whose store this is is the one moment dev-prune can still find it.
1216    let mut roots = volume_roots(repos);
1217    if let Ok(here) = std::env::current_dir()
1218        && let Some(root) = volume_root(&here)
1219        && !roots.contains(&root)
1220    {
1221        roots.push(root);
1222    }
1223    roots
1224        .into_iter()
1225        .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
1226        .filter(|store| store.is_dir())
1227        .collect()
1228}
1229
1230/// The distinct filesystems a set of repositories sits on, in the order first seen.
1231fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
1232    let mut roots: Vec<PathBuf> = Vec::new();
1233    for repo in repos {
1234        if let Some(root) = volume_root(repo)
1235            && !roots.contains(&root)
1236        {
1237            roots.push(root);
1238        }
1239    }
1240    roots
1241}
1242
1243/// The root of the filesystem `path` sits on.
1244///
1245/// Mount points are found by device number rather than by parsing a mount table:
1246/// `/proc/mounts` is Linux-only, the output of `mount` is not a format, and `st_dev` is
1247/// the same answer on every Unix. The highest ancestor still on the same device is where
1248/// the filesystem starts.
1249#[cfg(unix)]
1250pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
1251    use std::os::unix::fs::MetadataExt;
1252
1253    let dev = std::fs::metadata(path).ok()?.dev();
1254    let mut root = path.to_path_buf();
1255    for ancestor in path.ancestors().skip(1) {
1256        match std::fs::metadata(ancestor) {
1257            Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
1258            _ => break,
1259        }
1260    }
1261    Some(root)
1262}
1263
1264/// The root of the volume `path` sits on: `V:\`, or `\\server\share\` for a UNC path.
1265///
1266/// Windows can also mount a volume into an empty directory of another one, which this
1267/// does not see. A drive letter is what a developer with a second disk actually has, and
1268/// the cost of missing the other case is a cache that goes unreported rather than one
1269/// that is wrongly cleared.
1270#[cfg(windows)]
1271pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
1272    use std::path::Component;
1273
1274    let mut components = path.components();
1275    let Some(Component::Prefix(prefix)) = components.next() else {
1276        return None;
1277    };
1278    if components.next() != Some(Component::RootDir) {
1279        return None;
1280    }
1281    let mut root = PathBuf::from(prefix.as_os_str());
1282    root.push(Component::RootDir.as_os_str());
1283    Some(root)
1284}
1285
1286/// Where to run the "where is your cache?" queries from.
1287///
1288/// The home directory, not the current one. A project's `.npmrc` or `.cargo/config.toml`
1289/// can move the cache for that project alone, and answering with it would report a
1290/// directory that is not the machine's actual cache. Falling back to the current
1291/// directory is only for the case where there is no home directory at all.
1292fn query_dir() -> PathBuf {
1293    dirs::home_dir()
1294        .or_else(|| std::env::current_dir().ok())
1295        .unwrap_or_else(|| PathBuf::from("."))
1296}
1297
1298/// Resolve one probe to a directory that exists, or nothing.
1299fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
1300    if let Some((program, args)) = probe.query
1301        && adapters::binary_available(program)
1302    {
1303        let answered = adapters::capture_command_with_timeout(
1304            program,
1305            args,
1306            from,
1307            std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
1308        )
1309        .ok()
1310        .and_then(|raw| path_from_output(&raw))
1311        .map(|p| match probe.query_suffix {
1312            Some(suffix) => suffix.split('/').fold(p, |acc, seg| acc.join(seg)),
1313            None => p,
1314        })
1315        .filter(|p| p.is_dir());
1316        if answered.is_some() {
1317            return answered;
1318        }
1319    }
1320
1321    // Either the manager is not installed, or it is and its cache has never been
1322    // populated. The conventional location is still worth checking: an uninstalled
1323    // manager leaves its cache behind, and that is exactly the multi-gigabyte directory
1324    // nobody remembers.
1325    fallbacks(probe.manager, probe.kind)
1326        .into_iter()
1327        .find(|p| p.is_dir())
1328}
1329
1330/// Read a path out of a manager's answer.
1331///
1332/// The last non-empty line, because some managers print a notice first, and quotes are
1333/// stripped because `go env` quotes paths containing spaces on Windows.
1334fn path_from_output(raw: &str) -> Option<PathBuf> {
1335    let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
1336    let line = line.trim_matches('"');
1337    // npm answers `undefined` for a config key it does not have, and a manager that
1338    // errored can print anything at all. A relative path is never a machine-wide cache.
1339    if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
1340        return None;
1341    }
1342    Some(PathBuf::from(line))
1343}
1344
1345/// Conventional locations for a cache, most likely first.
1346fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
1347    let home = dirs::home_dir();
1348    let local = dirs::data_local_dir();
1349    let cache = dirs::cache_dir();
1350    // `rel` is split rather than joined whole so a Windows path never comes out as
1351    // `C:\Users\dev\go\pkg/mod`. `Path::join` accepts the forward slashes, it just keeps
1352    // them, and a report that spells the same drive two ways reads like a bug.
1353    let under = |base: &Option<PathBuf>, rel: &str| {
1354        base.as_ref()
1355            .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
1356    };
1357
1358    let candidates = match (manager, kind) {
1359        // `npm config get cache` answers `~/.npm` on Unix and `%LocalAppData%\npm-cache`
1360        // on Windows; the payload lives in `_cacache` underneath either one.
1361        ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
1362        ("pnpm", _) => vec![
1363            under(&local, "pnpm/store"),
1364            under(&home, ".local/share/pnpm/store"),
1365            under(&home, "Library/pnpm/store"),
1366            under(&home, ".pnpm-store"),
1367        ],
1368        ("yarn", _) => vec![
1369            under(&home, ".yarn/berry/cache"),
1370            under(&local, "Yarn/Cache"),
1371            under(&cache, "yarn"),
1372        ],
1373        ("bun", _) => vec![under(&home, ".bun/install/cache")],
1374        ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
1375        ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
1376        // `CONDA_PKGS_DIRS` names one directory in practice; conda's own multi-value
1377        // support for it is still a feature request, so this is not split on anything.
1378        // `CONDA_EXE` is `<root>/bin/conda` on Unix and `<root>\Scripts\conda.exe` on
1379        // Windows, so the grandparent is the installation root either way — the only way
1380        // to find a conda that is not in one of the default places. `~/.conda/pkgs` is
1381        // where conda falls back when the root is not writable, which is every managed
1382        // multi-user install.
1383        ("conda", _) => vec![
1384            std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
1385            std::env::var_os("CONDA_EXE")
1386                .map(PathBuf::from)
1387                .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
1388                .map(|root| root.join("pkgs")),
1389            under(&home, "miniconda3/pkgs"),
1390            under(&home, "anaconda3/pkgs"),
1391            under(&home, "miniforge3/pkgs"),
1392            under(&home, "mambaforge/pkgs"),
1393            under(&home, ".conda/pkgs"),
1394        ],
1395        ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
1396        ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
1397        ("go", "module cache") => vec![
1398            std::env::var_os("GOMODCACHE").map(PathBuf::from),
1399            std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
1400            under(&home, "go/pkg/mod"),
1401        ],
1402        ("go", _) => vec![
1403            std::env::var_os("GOCACHE").map(PathBuf::from),
1404            under(&cache, "go-build"),
1405            under(&local, "go-build"),
1406        ],
1407        ("maven", _) => vec![under(&home, ".m2/repository")],
1408        // GRADLE_USER_HOME relocates the whole ~/.gradle tree, caches and wrapper both.
1409        ("gradle", "caches") => vec![
1410            std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
1411            under(&home, ".gradle/caches"),
1412        ],
1413        ("gradle", _) => vec![
1414            std::env::var_os("GRADLE_USER_HOME")
1415                .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
1416            under(&home, ".gradle/wrapper/dists"),
1417        ],
1418        ("nuget", _) => vec![
1419            std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
1420            under(&home, ".nuget/packages"),
1421        ],
1422        ("vcpkg", _) => vec![
1423            std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
1424            under(&local, "vcpkg/archives"),
1425            under(&cache, "vcpkg/archives"),
1426        ],
1427        // Conan 2 keeps packages under <CONAN_HOME>/p; pointing at `p` rather than the
1428        // whole home keeps profiles and remotes out of the size (and out of harm's way).
1429        ("conan", _) => vec![
1430            std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
1431            under(&home, ".conan2/p"),
1432        ],
1433        // ccache prefers a `~/.ccache` that already exists over the platform cache
1434        // directory, so the legacy spot is checked first, the same way ccache does.
1435        ("ccache", _) => vec![
1436            std::env::var_os("CCACHE_DIR").map(PathBuf::from),
1437            under(&home, ".ccache"),
1438            under(&cache, "ccache"),
1439            under(&local, "ccache"),
1440        ],
1441        // Both Windows shapes are listed because sccache's documentation says
1442        // `%LOCALAPPDATA%\Mozilla\sccache` and the directories crate it resolves that
1443        // through appends a `cache` segment; the first that exists wins.
1444        ("sccache", _) => vec![
1445            std::env::var_os("SCCACHE_DIR").map(PathBuf::from),
1446            under(&local, "Mozilla/sccache/cache"),
1447            under(&local, "Mozilla/sccache"),
1448            under(&cache, "Mozilla.sccache"),
1449            under(&cache, "sccache"),
1450        ],
1451        // Only reached when `composer` is not installed, which is the case worth
1452        // covering: the cache a PHP toolchain left behind is the one nobody remembers.
1453        ("composer", _) => vec![
1454            std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
1455            std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
1456            under(&local, "Composer"),
1457            under(&cache, "composer"),
1458            under(&home, ".composer/cache"),
1459        ],
1460        // CocoaPods puts the cache under `~/Library/Caches` by name rather than through
1461        // the platform's cache directory, so this is `home` and not `cache` even on the
1462        // one platform where the two would agree.
1463        ("cocoapods", _) => vec![
1464            std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
1465            under(&home, "Library/Caches/CocoaPods"),
1466        ],
1467        // HEX_HOME moves the whole `.hex` tree; MIX_XDG puts it under the platform cache
1468        // directory instead. Both are checked because either can be set alone.
1469        ("hex", _) => vec![
1470            std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
1471            under(&home, ".hex/packages"),
1472            under(&cache, "hex/packages"),
1473        ],
1474        // `gem env gemdir` answers this whenever Ruby is installed. These are the shapes
1475        // a user-install tree takes when it is not — which is the case worth covering,
1476        // since a gem cache nobody can account for is one left behind by a toolchain
1477        // that has since been removed.
1478        ("bundler", _) => gem_cache_dirs(),
1479        ("dart", _) => vec![
1480            std::env::var_os("PUB_CACHE").map(|p| PathBuf::from(p).join("hosted")),
1481            under(&local, "Pub/Cache/hosted"),
1482            under(&home, ".pub-cache/hosted"),
1483        ],
1484        // SwiftPM's shared cache moved under the platform cache directory; `~/.swiftpm`
1485        // is where older toolchains put it and where one can still be sitting.
1486        ("swift", _) => vec![
1487            under(&cache, "org.swift.swiftpm"),
1488            under(&home, ".swiftpm/cache"),
1489        ],
1490        // Terraform has no default here at all: without `TF_PLUGIN_CACHE_DIR` or a
1491        // `plugin_cache_dir` line there is no shared store, and finding nothing is the
1492        // correct answer rather than a gap. The two paths are the ones the documentation
1493        // uses in its own example.
1494        ("terraform", _) => vec![
1495            std::env::var_os("TF_PLUGIN_CACHE_DIR").map(PathBuf::from),
1496            under(&home, ".terraform.d/plugin-cache"),
1497            under(&dirs::data_dir(), "terraform.d/plugin-cache"),
1498        ],
1499        ("poetry", _) => vec![
1500            std::env::var_os("POETRY_CACHE_DIR").map(|p| PathBuf::from(p).join("artifacts")),
1501            under(&cache, "pypoetry/Cache/artifacts"),
1502            under(&cache, "pypoetry/artifacts"),
1503        ],
1504        ("pdm", _) => vec![
1505            std::env::var_os("PDM_CACHE_DIR").map(PathBuf::from),
1506            under(&cache, "pdm/Cache"),
1507            under(&cache, "pdm"),
1508        ],
1509        ("playwright", _) => vec![
1510            std::env::var_os("PLAYWRIGHT_BROWSERS_PATH").map(PathBuf::from),
1511            under(&cache, "ms-playwright"),
1512        ],
1513        // Puppeteer joins `.cache` onto the home directory itself instead of asking the
1514        // platform where caches go, so this is one path on all three and a
1515        // `%LOCALAPPDATA%` guess would miss it on Windows.
1516        ("puppeteer", _) => vec![
1517            std::env::var_os("PUPPETEER_CACHE_DIR").map(PathBuf::from),
1518            under(&home, ".cache/puppeteer"),
1519        ],
1520        // Same shape, same reason: `huggingface_hub` defaults to `~/.cache/huggingface`
1521        // on every platform.
1522        ("huggingface", _) => vec![
1523            std::env::var_os("HF_HUB_CACHE").map(PathBuf::from),
1524            std::env::var_os("HF_HOME").map(|p| PathBuf::from(p).join("hub")),
1525            under(&home, ".cache/huggingface/hub"),
1526        ],
1527        // Cypress, Electron and electron-builder all take the platform cache directory
1528        // and add a `Cache` segment on Windows only. Both shapes are listed and the first
1529        // that exists wins, which is cheaper than a third `cfg` for one path segment.
1530        ("cypress", _) => vec![
1531            std::env::var_os("CYPRESS_CACHE_FOLDER").map(PathBuf::from),
1532            under(&cache, "Cypress/Cache"),
1533            under(&cache, "Cypress"),
1534        ],
1535        ("electron", "download cache") => vec![
1536            std::env::var_os("electron_config_cache").map(PathBuf::from),
1537            under(&cache, "electron/Cache"),
1538            under(&cache, "electron"),
1539        ],
1540        ("electron", _) => vec![
1541            under(&cache, "electron-builder/Cache"),
1542            under(&cache, "electron-builder"),
1543        ],
1544        ("deno", _) => vec![
1545            std::env::var_os("DENO_DIR").map(PathBuf::from),
1546            under(&cache, "deno"),
1547        ],
1548        _ => vec![],
1549    };
1550
1551    candidates.into_iter().flatten().collect()
1552}
1553
1554/// Every `cache/` directory under a conventional user-install gem tree.
1555///
1556/// The version segment in the middle — `~/.gem/ruby/3.4.0/cache` — is not something a
1557/// fixed path can name, so the parents are read instead of guessed at. The last entry is
1558/// unconditional so that this probe always has *some* conventional location to point at,
1559/// which is what keeps the "every probe can be found without its manager installed"
1560/// check meaningful rather than accidentally satisfied.
1561fn gem_cache_dirs() -> Vec<Option<PathBuf>> {
1562    let mut out = vec![std::env::var_os("GEM_HOME").map(|p| PathBuf::from(p).join("cache"))];
1563    let home = dirs::home_dir();
1564    for parent in [".gem/ruby", ".local/share/gem/ruby"] {
1565        let Some(base) = home
1566            .as_ref()
1567            .map(|h| parent.split('/').fold(h.clone(), |p, seg| p.join(seg)))
1568        else {
1569            continue;
1570        };
1571        if let Ok(entries) = std::fs::read_dir(&base) {
1572            out.extend(entries.flatten().map(|e| Some(e.path().join("cache"))));
1573        }
1574    }
1575    out.push(home.map(|h| h.join(".gem").join("cache")));
1576    out
1577}
1578
1579/// `CARGO_HOME`, or the default cargo puts it in.
1580fn cargo_home() -> PathBuf {
1581    std::env::var_os("CARGO_HOME")
1582        .map(PathBuf::from)
1583        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
1584        .unwrap_or_else(|| PathBuf::from(".cargo"))
1585}
1586
1587fn print_report(reports: &[CacheReport], deps: Option<&Dependents>, volume: Option<&Path>) {
1588    output::print_header(i18n::t("caches.header"));
1589
1590    if reports.is_empty() {
1591        println!();
1592        match volume {
1593            Some(root) => output::print_info(&format!(
1594                "No package manager cache on this machine sits on {}. They are somewhere \
1595                 else — run `devp caches` without `--volume` to see where.",
1596                output::clean_path(root)
1597            )),
1598            None => output::print_info(i18n::t("caches.nothing")),
1599        }
1600        return;
1601    }
1602
1603    println!();
1604    let totals = manager_totals(reports);
1605    // One line per manager, not per row: cargo's registry cache and its sources have the
1606    // same repositories behind them, and saying so twice reads as two findings.
1607    let mut counted: HashSet<&'static str> = HashSet::new();
1608    for r in reports {
1609        let label = format!("{} {}", r.manager, r.kind);
1610        println!(
1611            "  {:<30} {:>10}  {}",
1612            label,
1613            output::format_bytes(r.bytes),
1614            output::clean_path(&r.path)
1615        );
1616        // The manager's own command was the only one printed here, which left the
1617        // wrapper missing from the one place someone reads before deciding to empty
1618        // something. It is not a synonym for the line below it: what `devp caches clear`
1619        // frees is added to the lifetime total in `devp stats`, and the same command
1620        // typed into a terminal is invisible to dev-prune, so the report a week later is
1621        // short by exactly the space this cleared. The manual command still gets its
1622        // line — dev-prune runs it, and hiding what it runs would be worse than
1623        // repeating it.
1624        // Repeated on both of a manager's rows rather than named once: the rows are
1625        // ordered by size, so cargo's two are rarely adjacent and a command printed
1626        // beside the first would be nowhere near the second.
1627        let clearable = !matches!(r.clear, Clear::Manual { .. });
1628        if clearable {
1629            println!(
1630                "  {:<30} {:>10}  clear: devp caches clear {}",
1631                "", "", r.manager
1632            );
1633        }
1634        let verb = if clearable { "runs: " } else { "clear:" };
1635        println!("  {:<30} {:>10}  {verb} {}", "", "", r.clear_command);
1636        if let Some(note) = r.note {
1637            println!("  {:<30} {:>10}  {}", "", "", note);
1638        }
1639        if r.over_cap
1640            && let Some(gb) = r.cap_gb
1641        {
1642            println!(
1643                "  {:<30} {:>10}  over the {gb} GiB cap you set for {}",
1644                "", "", r.manager
1645            );
1646        }
1647        if let Some(n) = r.dependents
1648            && counted.insert(r.manager)
1649        {
1650            println!("  {:<30} {:>10}  {}", "", "", used_by(r, n, deps, &totals));
1651        }
1652        println!();
1653    }
1654
1655    let total: u64 = reports.iter().map(|r| r.bytes).sum();
1656    println!(
1657        "  {:<30} {:>10}  across {} {}",
1658        "Total",
1659        output::format_bytes(total),
1660        reports.len(),
1661        output::plural(reports.len(), "cache", "caches")
1662    );
1663
1664    // Where the total actually is. On a machine whose projects live on a second disk,
1665    // "22 GiB of caches" is not the number that decides anything — the two gigabytes
1666    // sitting on the drive that is full is. Printed only when there is more than one
1667    // volume to tell apart, so it never appears under `--volume`, where there is one by
1668    // construction and the line would only repeat the total above it.
1669    let by_volume = volume_totals(reports);
1670    if by_volume.len() > 1 {
1671        let named = by_volume
1672            .iter()
1673            .map(|(label, bytes)| format!("{label} {}", output::format_bytes(*bytes)))
1674            .collect::<Vec<_>>()
1675            .join(" · ");
1676        println!("  {:<30} {:>10}  {named}", "By drive", "");
1677    }
1678
1679    // A ranking, not a recommendation. Which of these is worth emptying depends on what
1680    // the reader is about to do with this machine, and inventing a threshold to call one
1681    // of them "too big" would be inventing a fact. Naming the order is enough.
1682    let ranked = costliest_per_repository(reports);
1683    if ranked.len() > 1 {
1684        let named = ranked
1685            .iter()
1686            .take(3)
1687            .map(|(m, b)| format!("{m} {}", output::format_bytes_weighted(*b)))
1688            .collect::<Vec<_>>()
1689            .join(" · ");
1690        println!("  {:<30} {:>10}  {named}", "Costliest per repository", "");
1691    }
1692
1693    if reports.iter().any(|r| r.over_cap) {
1694        println!();
1695        output::print_info(
1696            "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1697             --over-cap all` empties exactly those and leaves the rest alone.",
1698        );
1699    }
1700
1701    if reports.iter().any(|r| r.dependents == Some(0)) {
1702        println!();
1703        output::print_info(
1704            "The caches above that no registered repository uses were filled for projects that \
1705             are not here any more. `devp caches clear --unused all` empties exactly those. It \
1706             counts only repositories dev-prune knows about, so `devp link` anything you keep \
1707             outside the registry before trusting the number.",
1708        );
1709    }
1710
1711    if let Some(root) = volume {
1712        println!();
1713        output::print_info(&format!(
1714            "Only the caches on {} are above, and the container engines are not among \
1715             them: an engine reports its own disk from inside a VM image with no path on \
1716             this filesystem, so there is no drive to file it under — `devp caches \
1717             docker` has that number. The clear commands above are not drive-specific \
1718             either. They empty that manager's cache wherever it is, which for every \
1719             manager but pnpm is one place.",
1720            output::clean_path(root)
1721        ));
1722    }
1723
1724    println!();
1725    output::print_info(
1726        "Nothing above was deleted. A cache is shared by every project on the machine, so \
1727         no single repository's lockfile can prove it is recoverable — and it is what \
1728         makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1729         will ever touch one. When you want the space more than the speed, run a clear \
1730         command yourself, or `devp caches clear <manager>` — only the second is counted \
1731         in `devp stats`, because dev-prune never sees the first.",
1732    );
1733}
1734
1735/// The one line that says who still needs this manager's caches.
1736///
1737/// The size beside the count is the manager's whole footprint divided by the number of
1738/// repositories behind it, which is the figure that actually decides anything: two
1739/// repositories holding a 12 GiB cache between them is 6 GiB each and worth a look; forty
1740/// repositories holding the same 12 GiB is 300 MiB each and is the cache doing its job.
1741///
1742/// Returned bold, because it is the conclusion of its block and everything above it is
1743/// plumbing — the path you already know and the command you only need once you have
1744/// decided. Set in the same weight as the rest, "cargo is used by 1 of 46 registered
1745/// repositories" was something you had to read the whole report to find. Weight and not
1746/// colour: whether a cache with one dependent is a problem depends on what the reader is
1747/// about to do, and a colour would answer that question for them.
1748fn used_by(
1749    r: &CacheReport,
1750    dependents: usize,
1751    deps: Option<&Dependents>,
1752    totals: &BTreeMap<&'static str, (u64, usize)>,
1753) -> String {
1754    if dependents == 0 {
1755        return format!("no registered repository uses {}", r.manager)
1756            .bold()
1757            .to_string();
1758    }
1759    let registered = deps.map_or(dependents, |d| d.repositories);
1760    let (total, rows) = totals.get(r.manager).copied().unwrap_or((r.bytes, 1));
1761    // Named rather than implied. The label column is blank on a continuation line, and
1762    // the figure is the manager's total across every row it has — so on go's two rows the
1763    // number beside "go build cache" is larger than that row's size and reads as an
1764    // arithmetic error until the sentence says what it summed.
1765    let share = output::format_bytes(total / dependents as u64);
1766    let each = if rows > 1 {
1767        format!("{share} each across its {rows} caches")
1768    } else {
1769        format!("{share} each")
1770    };
1771    format!(
1772        "{} is used by {dependents} of {registered} registered {} · {each}",
1773        r.manager,
1774        output::plural(registered, "repository", "repositories"),
1775    )
1776    .bold()
1777    .to_string()
1778}
1779
1780/// Every manager something still needs, by what it costs one of them, worst first.
1781///
1782/// The report is ordered by total size, and the cache that costs a single repository the
1783/// most is routinely not the biggest one on the machine — a 2 GiB store serving one
1784/// project is a worse deal than a 10 GiB store serving eighteen, and the report as it
1785/// stands makes you read thirteen blocks and do that arithmetic yourself.
1786fn costliest_per_repository(reports: &[CacheReport]) -> Vec<(&'static str, u64)> {
1787    let totals = manager_totals(reports);
1788    // Per manager, not per row, for the same reason `used_by` prints once per manager:
1789    // cargo's registry cache and its sources are one cache with one set of dependents.
1790    let mut per: BTreeMap<&'static str, u64> = BTreeMap::new();
1791    for r in reports {
1792        if let Some(n) = r.dependents.filter(|n| *n > 0) {
1793            let total = totals.get(r.manager).map_or(r.bytes, |t| t.0);
1794            per.insert(r.manager, total / n as u64);
1795        }
1796    }
1797    let mut ranked: Vec<(&'static str, u64)> = per.into_iter().collect();
1798    // Size, then name: two managers costing the same must not swap places between runs.
1799    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
1800    ranked
1801}
1802
1803/// What happened to one cache.
1804pub struct ClearOutcome {
1805    /// The package manager that owned it.
1806    pub manager: &'static str,
1807    /// Which of that manager's caches this was.
1808    pub kind: &'static str,
1809    /// Where it is.
1810    pub path: PathBuf,
1811    /// Size before, as this command measured it.
1812    pub before: u64,
1813    /// Size after, measured again rather than assumed. `pnpm store prune` and `uv cache
1814    /// prune` deliberately keep what is still referenced, so subtracting is the only
1815    /// honest way to say what actually went.
1816    pub after: u64,
1817    /// `None` when it worked; otherwise why it did not, phrased for a human.
1818    pub problem: Option<String>,
1819}
1820
1821impl ClearOutcome {
1822    /// Bytes given back to the disk.
1823    pub fn freed(&self) -> u64 {
1824        self.before.saturating_sub(self.after)
1825    }
1826}
1827
1828/// Run `dev-prune caches clear <target>`.
1829///
1830/// `target` is a manager name, a comma-separated list of them, or `all`. Everything
1831/// about to be emptied is named and sized first, and unless `--yes` answers for the
1832/// user, it asks. `over_cap` narrows the selection to managers that have outgrown their
1833/// `cache_max_gb` entry, and `unused` to managers no registered repository uses at all.
1834/// `except` names managers `all` must leave alone — the one-time blacklist to the
1835/// list's whitelist. `include_volumes` belongs to one container engine named alone; the
1836/// engine module enforces the rest of what that flag refuses.
1837pub fn run_clear(
1838    target: &str,
1839    except: Option<&str>,
1840    over_cap: bool,
1841    unused: bool,
1842    include_volumes: bool,
1843    yes: bool,
1844    dry_run: bool,
1845    json_output: bool,
1846) -> Result<()> {
1847    let targets: Vec<&str> = target
1848        .split(',')
1849        .map(str::trim)
1850        .filter(|t| !t.is_empty())
1851        .collect();
1852    if targets.is_empty() {
1853        return Err(anyhow::Error::new(crate::UsageError(format!(
1854            "`{target}` names no cache. Name a manager, a comma-separated list of them, \
1855             or `all`."
1856        ))));
1857    }
1858    let all = targets.len() == 1 && targets[0].eq_ignore_ascii_case("all");
1859    // Checked here rather than in the engine module so `clear all --include-volumes`
1860    // and `clear npm --include-volumes` both answer before any measuring starts.
1861    if include_volumes
1862        && !(targets.len() == 1 && crate::commands::containers::is_engine(targets[0]))
1863    {
1864        return Err(anyhow::Error::new(crate::UsageError(
1865            "`--include-volumes` lists a container engine's unused volumes, so it needs \
1866             one engine named alone: `devp caches clear docker --include-volumes`."
1867                .to_string(),
1868        )));
1869    }
1870    // `npm,all` reads as a contradiction, so it is one: either the list decides, or
1871    // `all` does — with `--except` to carve pieces out of it.
1872    if !all && targets.iter().any(|t| t.eq_ignore_ascii_case("all")) {
1873        return Err(anyhow::Error::new(crate::UsageError(
1874            "`all` already includes every manager, so a list containing it decides \
1875             nothing. Name just the managers to clear, or run `devp caches clear all \
1876             --except <managers>` to leave some out."
1877                .to_string(),
1878        )));
1879    }
1880    let excepts: Vec<&str> = match except {
1881        // `--except` composes with `all`; a named list already says exactly what to
1882        // clear, so pairing the two would mean subtracting from a list the user just
1883        // wrote — dropping the name from the list is the honest spelling.
1884        Some(_) if !all => {
1885            return Err(anyhow::Error::new(crate::UsageError(
1886                "`--except` leaves managers out of `all`. You already named exactly \
1887                 what to clear — drop the unwanted names from the list instead."
1888                    .to_string(),
1889            )));
1890        }
1891        Some(e) => e
1892            .split(',')
1893            .map(str::trim)
1894            .filter(|t| !t.is_empty())
1895            .collect(),
1896        None => Vec::new(),
1897    };
1898    let target = targets[0];
1899    // An engine is cleared by the module that knows how to ask it questions; the dispatch
1900    // is here because `caches clear docker` is where a person looks for it.
1901    //
1902    // `all` deliberately does not reach it. A container store is the biggest thing on the
1903    // disk and the slowest to put back, and "clear all the caches" typed in a hurry
1904    // should not also mean re-pulling every base image tomorrow morning. Naming the
1905    // engine is the consent.
1906    if !all && targets.len() == 1 && crate::commands::containers::is_engine(target) {
1907        if over_cap || unused {
1908            return Err(anyhow::Error::new(crate::UsageError(format!(
1909                "`--over-cap` and `--unused` pick package manager caches by size cap and by \
1910                 which repositories still need them. Neither applies to {target}: an image \
1911                 belongs to no repository and `cache_max_gb` does not cover one. Run `devp \
1912                 caches clear {target}` on its own."
1913            ))));
1914        }
1915        return crate::commands::containers::run_clear(
1916            target,
1917            include_volumes,
1918            yes,
1919            dry_run,
1920            json_output,
1921        );
1922    }
1923    // An engine elsewhere in a list would otherwise fall into the unknown-manager
1924    // error below, which is the wrong answer: `clear docker` works, it just has to be
1925    // named alone — that naming is the consent, and a list dilutes it.
1926    if targets.len() > 1
1927        && let Some(engine) = targets
1928            .iter()
1929            .find(|t| crate::commands::containers::is_engine(t))
1930    {
1931        return Err(anyhow::Error::new(crate::UsageError(format!(
1932            "`{engine}` is a container engine, and an engine is cleared by naming it \
1933             alone: `devp caches clear {engine}`. Run that separately from the \
1934             package-manager list."
1935        ))));
1936    }
1937    for t in &targets {
1938        if !all && !PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(t)) {
1939            return Err(anyhow::Error::new(crate::UsageError(format!(
1940                "`{t}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1941                known_managers().join(", ")
1942            ))));
1943        }
1944    }
1945    for e in &excepts {
1946        if crate::commands::containers::is_engine(e) {
1947            return Err(anyhow::Error::new(crate::UsageError(format!(
1948                "`all` never reaches a container engine, so there is no `{e}` to leave \
1949                 out. Drop it from `--except`."
1950            ))));
1951        }
1952        if !PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(e)) {
1953            return Err(anyhow::Error::new(crate::UsageError(format!(
1954                "`{e}` is not a manager dev-prune knows a cache for, so `--except` has \
1955                 nothing to leave out. Known managers: {}.",
1956                known_managers().join(", ")
1957            ))));
1958        }
1959    }
1960    // Naming a manager dev-prune only ever reports is asking for the one thing this
1961    // command does not do, so the reason is the answer — and it is the same answer
1962    // whether or not the store is on this machine, which is why it comes from the table
1963    // rather than from a size walk that would end in "nothing to clear". A list gets
1964    // the same refusal: putting maven *in writing* is not quieter consent than naming
1965    // it alone.
1966    for t in &targets {
1967        if !all
1968            && let Some(probe) = manual_only(t)
1969            && let Clear::Manual { why } = probe.clear
1970        {
1971            return Err(anyhow::Error::new(crate::UsageError(format!(
1972                "{why} The command is: {}",
1973                probe.clear_command
1974            ))));
1975        }
1976    }
1977
1978    // A prompt nobody can answer is a hang, and the "pass --yes" line printed in its
1979    // place would land in the middle of the JSON document and break the parse.
1980    if json_output && !yes && !dry_run {
1981        return Err(anyhow::Error::new(crate::UsageError(
1982            "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1983             to see what would go."
1984                .to_string(),
1985        )));
1986    }
1987
1988    // Caps are applied to the whole measurement, before the name filter: a cap is per
1989    // manager and a manager's total is the sum of its rows, so narrowing first would let
1990    // `clear cargo --over-cap` compare a cap against half a cache.
1991    let reg = registered();
1992    let mut measured = collect(!json_output, reg.as_ref());
1993    apply_caps(&mut measured, &caps());
1994
1995    // `--unused` is the only selection here that acts on a count rather than on a size,
1996    // so it refuses to run without one. An empty registry would otherwise make every
1997    // cache on the machine look unused, and this flag would agree to empty all of them.
1998    let deps = if unused {
1999        let Some(reg) = reg.as_ref() else {
2000            return Err(anyhow::Error::new(crate::UsageError(
2001                "`--unused` empties the caches no registered repository needs, and there are no \
2002                 registered repositories on this disk to check against — every cache would look \
2003                 unused. Register what you keep with `devp link` first."
2004                    .to_string(),
2005            )));
2006        };
2007        Some(dependents(reg, !json_output))
2008    } else {
2009        None
2010    };
2011    apply_dependents(&mut measured, deps.as_ref());
2012
2013    // Split before anything is printed. A plan that lists a store dev-prune is never
2014    // going to empty is a promise it cannot keep, and the JSON record of the run would
2015    // carry the same lie.
2016    let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
2017        .into_iter()
2018        .filter(|r| {
2019            if all {
2020                !excepts.iter().any(|e| r.manager.eq_ignore_ascii_case(e))
2021            } else {
2022                targets.iter().any(|t| r.manager.eq_ignore_ascii_case(t))
2023            }
2024        })
2025        .filter(|r| !over_cap || r.over_cap)
2026        .filter(|r| !unused || r.dependents == Some(0))
2027        .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
2028
2029    if reports.is_empty() {
2030        if json_output {
2031            return json::emit(&json::caches_clear_plan_document(&reports, &kept));
2032        }
2033        if unused {
2034            output::print_info(
2035                "Every cache on this machine is used by at least one registered repository, or \
2036                 is one dev-prune cannot attribute to any — nothing to clear.",
2037            );
2038            return Ok(());
2039        }
2040        if over_cap {
2041            // Two very different situations read the same from here — no caps set at
2042            // all, and caps set that nothing has reached — so say which one it is. The
2043            // first is a setting the user has not made yet; the second is good news.
2044            output::print_info(if caps().is_empty() {
2045                "No cache size caps are set, so nothing is over one. Set one for every manager \
2046                 with `devp config set cache_max_gb default=10`, or pick them out one at a time \
2047                 in `devp config wizard`."
2048            } else {
2049                "Every capped cache is under its cap — nothing to clear."
2050            });
2051            return Ok(());
2052        }
2053        output::print_info(&format!(
2054            "No {} cache on this machine — nothing to clear.",
2055            if all {
2056                "package manager".to_string()
2057            } else {
2058                targets.join(", ")
2059            }
2060        ));
2061        return Ok(());
2062    }
2063
2064    if dry_run {
2065        if json_output {
2066            return json::emit(&json::caches_clear_plan_document(&reports, &kept));
2067        }
2068        print_kept(&kept);
2069        print_clear_plan(&reports, true);
2070        return Ok(());
2071    }
2072
2073    if !json_output {
2074        print_kept(&kept);
2075        print_clear_plan(&reports, false);
2076        if !confirm_clear(yes) {
2077            output::print_info("Nothing was cleared.");
2078            return Ok(());
2079        }
2080    }
2081
2082    let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
2083    // Before either output path, so both credit it. Everything above this line has
2084    // already returned — a dry run never reaches here, and neither does a
2085    // declined confirmation.
2086    record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
2087
2088    if json_output {
2089        json::emit(&json::caches_clear_document(&outcomes, &kept))?;
2090    } else {
2091        print_clear_result(&outcomes);
2092    }
2093
2094    // Reported first, then failed: the rows above are the useful part, and a caller
2095    // reading only the exit code still learns that something did not go.
2096    let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
2097    if failed > 0 {
2098        anyhow::bail!(
2099            "{failed} {} could not be cleared.",
2100            output::plural(failed, "cache", "caches")
2101        );
2102    }
2103    Ok(())
2104}
2105
2106/// The entry to explain when every cache `target` names is one dev-prune only reports.
2107///
2108/// `None` for a manager with anything clearable under it, and for a name that matches
2109/// nothing — the caller has already rejected those.
2110fn manual_only(target: &str) -> Option<&'static Probe> {
2111    let matching: Vec<&Probe> = PROBES
2112        .iter()
2113        .filter(|p| p.manager.eq_ignore_ascii_case(target))
2114        .collect();
2115    if matching.is_empty()
2116        || matching
2117            .iter()
2118            .any(|p| !matches!(p.clear, Clear::Manual { .. }))
2119    {
2120        return None;
2121    }
2122    matching.first().copied()
2123}
2124
2125/// Whether `name` is a cache manager dev-prune knows, for validating `cache_max_gb`.
2126pub fn is_cache_manager(name: &str) -> bool {
2127    PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
2128}
2129
2130/// Every manager name `clear` accepts, in report order, without repeats.
2131pub fn known_managers() -> Vec<&'static str> {
2132    let mut names: Vec<&'static str> = Vec::new();
2133    for probe in PROBES {
2134        if !names.contains(&probe.manager) {
2135            names.push(probe.manager);
2136        }
2137    }
2138    names
2139}
2140
2141/// Empty one cache, and measure what that actually gave back.
2142fn clear_one(report: &CacheReport) -> ClearOutcome {
2143    let problem = match report.clear {
2144        Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
2145        Clear::Directory => remove_cache_dir(&report.path),
2146        // `run_clear` filters these out before they reach here. Reporting the reason
2147        // rather than falling through to a delete keeps that a refactoring bug instead
2148        // of a silently emptied Maven repository.
2149        Clear::Manual { why } => Some(why.to_string()),
2150    };
2151    ClearOutcome {
2152        manager: report.manager,
2153        kind: report.kind,
2154        path: report.path.clone(),
2155        before: report.bytes,
2156        // Re-measured even after a failure: a clear that died half-way still freed
2157        // something, and calling that zero sends someone looking for space already back.
2158        after: adapters::dir_size(&report.path),
2159        problem,
2160    }
2161}
2162
2163/// Hand the cache to the manager that owns it.
2164fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
2165    if !adapters::binary_available(program) {
2166        return Some(format!(
2167            "`{program}` is not on PATH — only it knows what in this cache is still \
2168             referenced, so dev-prune will not delete the directory in its place."
2169        ));
2170    }
2171    // Whatever the row added to the printed command is added to this one too, or the
2172    // command a user was shown and the command that ran are two different commands.
2173    let mut all: Vec<&str> = args.to_vec();
2174    all.extend(extra.iter().map(String::as_str));
2175    adapters::run_command_with_timeout(
2176        program,
2177        &all,
2178        &query_dir(),
2179        std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
2180    )
2181    .err()
2182    .map(|e| format!("{e:#}"))
2183}
2184
2185/// Delete the directory, for the managers that ship no way to ask.
2186fn remove_cache_dir(path: &Path) -> Option<String> {
2187    // `remove_dir_all` is not atomic, and a machine-wide cache is exactly where an
2188    // antivirus scan or a background build is most likely to be holding a file open.
2189    // The same one retry as the prune pass, for the same reason.
2190    std::fs::remove_dir_all(path)
2191        .or_else(|_| {
2192            std::thread::sleep(std::time::Duration::from_millis(250));
2193            std::fs::remove_dir_all(path)
2194        })
2195        .err()
2196        // "Not found" on the retry means the first attempt did finish after all.
2197        .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
2198        .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
2199}
2200
2201/// Name what was left alone, and why, before naming what is about to go.
2202fn print_kept(kept: &[CacheReport]) {
2203    for r in kept {
2204        let Clear::Manual { why } = r.clear else {
2205            continue;
2206        };
2207        println!();
2208        output::print_info(&format!(
2209            "Keeping {} {} ({} at {}). {why}",
2210            r.manager,
2211            r.kind,
2212            output::format_bytes(r.bytes),
2213            output::clean_path(&r.path)
2214        ));
2215    }
2216}
2217
2218/// Name everything that is about to go, and what it costs, before any of it goes.
2219fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
2220    output::print_header(if dry_run {
2221        i18n::t("caches.header.would_clear")
2222    } else {
2223        i18n::t("caches.header.about_to_clear")
2224    });
2225
2226    println!();
2227    for r in reports {
2228        println!(
2229            "  {:<30} {:>10}  {}",
2230            format!("{} {}", r.manager, r.kind),
2231            output::format_bytes(r.bytes),
2232            output::clean_path(&r.path)
2233        );
2234        println!("  {:<30} {:>10}  via: {}", "", "", r.clear_command);
2235    }
2236
2237    println!();
2238    let total: u64 = reports.iter().map(|r| r.bytes).sum();
2239    println!(
2240        "  {:<30} {:>10}  across {} {}",
2241        "Total",
2242        output::format_bytes(total),
2243        reports.len(),
2244        output::plural(reports.len(), "cache", "caches")
2245    );
2246
2247    println!();
2248    output::print_info(
2249        "Nothing in a cache is lost — every manager above re-downloads what it needs. \
2250         The cost is time: the next install, and the next `devp restore`, in every \
2251         project on this machine.",
2252    );
2253}
2254
2255/// Add what was just emptied to the machine's running total, for `devp stats`.
2256///
2257/// Best-effort, and silent when it fails. The space is already back whether or not the
2258/// note about it lands, and a registry that cannot be written — a read-only
2259/// home directory, a disk that just filled — must not turn a successful
2260/// clear into a failed command.
2261fn record_cache_clear(bytes: u64) {
2262    if bytes == 0 {
2263        return;
2264    }
2265    if let Ok(mut registry) = crate::config::Registry::load() {
2266        registry.record_cache_clear(bytes);
2267        let _ = registry.save();
2268    }
2269}
2270
2271/// What actually went.
2272/// These caches are the ones the next `npm install` does not quietly refill: the
2273/// binaries only come back when their own installer runs, and someone who
2274/// clears them mid-project hits a broken test run before they hit anything else. Say
2275/// the command while the person who just cleared it is still looking.
2276const REINSTALL_HINTS: &[(&str, &str)] = &[
2277    ("playwright", "npx playwright install"),
2278    ("puppeteer", "npx puppeteer browsers install"),
2279    ("cypress", "npx cypress install"),
2280];
2281
2282fn print_clear_result(outcomes: &[ClearOutcome]) {
2283    println!();
2284    for o in outcomes {
2285        let label = format!("{} {}", o.manager, o.kind);
2286        println!(
2287            "  {:<30} {:>10}  {}",
2288            label,
2289            output::format_bytes(o.freed()),
2290            if o.problem.is_some() {
2291                "not cleared"
2292            } else {
2293                "cleared"
2294            }
2295        );
2296        if let Some(why) = &o.problem {
2297            println!("  {:<30} {:>10}  {why}", "", "");
2298        }
2299    }
2300
2301    println!();
2302    let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
2303    output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
2304
2305    for (manager, command) in REINSTALL_HINTS {
2306        if outcomes
2307            .iter()
2308            .any(|o| o.manager == *manager && o.problem.is_none())
2309        {
2310            output::print_info(&format!("{manager} comes back with `{command}`."));
2311        }
2312    }
2313}
2314
2315/// Ask before anything is emptied. `--yes` answers for the user; a pipe or a script
2316/// without it gets a "no" plus the flag to pass next time.
2317pub fn confirm_clear(yes: bool) -> bool {
2318    use std::io::{IsTerminal, Write};
2319    if yes {
2320        return true;
2321    }
2322    if !std::io::stdin().is_terminal() {
2323        output::print_info("Not running in a terminal — pass `--yes` to clear these.");
2324        return false;
2325    }
2326    // Default no. Nothing here is unrecoverable, but it is every other project's time
2327    // being spent, and a reflexive Enter should not be what spends it. The question goes
2328    // to stderr so a piped stdout cannot eat it.
2329    eprint!("Clear them? [y/N]: ");
2330    if std::io::stderr().flush().is_err() {
2331        return false;
2332    }
2333    let mut input = String::new();
2334    if std::io::stdin().read_line(&mut input).is_err() {
2335        return false;
2336    }
2337    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
2338}
2339
2340#[cfg(test)]
2341mod tests {
2342    use super::*;
2343
2344    #[test]
2345    fn every_probe_can_be_found_without_its_manager_installed() {
2346        // A probe with no query and no fallbacks is a row that can never appear, which
2347        // is a silent hole in the report rather than a test failure anywhere else.
2348        for probe in PROBES {
2349            assert!(
2350                !fallbacks(probe.manager, probe.kind).is_empty(),
2351                "{} {} has no conventional location",
2352                probe.manager,
2353                probe.kind
2354            );
2355        }
2356    }
2357
2358    #[test]
2359    fn every_reinstall_hint_names_a_probed_manager() {
2360        // A hint for a manager no probe reports can never print, and a renamed probe
2361        // would orphan its hint silently. Tie the two tables together here.
2362        for (manager, command) in REINSTALL_HINTS {
2363            assert!(
2364                PROBES.iter().any(|p| p.manager == *manager),
2365                "reinstall hint for {manager} ({command}) matches no probe"
2366            );
2367        }
2368    }
2369
2370    #[test]
2371    fn every_probe_names_the_command_that_clears_it() {
2372        for probe in PROBES {
2373            assert!(
2374                !probe.clear_command.trim().is_empty(),
2375                "{} {} reports a size with no way to act on it",
2376                probe.manager,
2377                probe.kind
2378            );
2379        }
2380    }
2381
2382    #[test]
2383    fn the_probed_managers_with_no_adapter_of_the_same_name_are_pinned() {
2384        // The report, `--unused`, SKILL.md, the CLI reference and llms.txt all state this
2385        // split in prose, and it went out wrong once already: the docs named a manager
2386        // that had since grown an adapter and omitted one that never had. Pin the list
2387        // here so the next adapter makes the claim fail rather than quietly rot.
2388        let orphans: Vec<&str> = PROBES
2389            .iter()
2390            .map(|p| p.manager)
2391            .filter(|m| !adapters::is_adapter_name(m))
2392            .collect::<std::collections::BTreeSet<_>>()
2393            .into_iter()
2394            .collect();
2395        assert_eq!(
2396            orphans,
2397            [
2398                "ccache",
2399                "conan",
2400                "conda",
2401                "cypress",
2402                "electron",
2403                "hex",
2404                "huggingface",
2405                "nuget",
2406                "pip",
2407                "playwright",
2408                "puppeteer",
2409                "sccache",
2410            ]
2411        );
2412    }
2413
2414    #[test]
2415    fn no_two_probes_describe_the_same_cache() {
2416        let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
2417        let count = keys.len();
2418        keys.sort_unstable();
2419        keys.dedup();
2420        assert_eq!(keys.len(), count, "two probes share a manager and kind");
2421    }
2422
2423    #[test]
2424    fn a_managers_answer_is_read_off_the_last_line() {
2425        // npm prints notices before the value it was asked for.
2426        let raw = if cfg!(windows) {
2427            "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
2428        } else {
2429            "npm warn config global deprecated\n/home/dev/.npm\n"
2430        };
2431        assert!(path_from_output(raw).is_some());
2432    }
2433
2434    #[test]
2435    fn quoted_paths_lose_their_quotes() {
2436        let raw = if cfg!(windows) {
2437            "\"C:\\Program Files\\go\\pkg\\mod\"\n"
2438        } else {
2439            "\"/opt/go path/pkg/mod\"\n"
2440        };
2441        let path = path_from_output(raw).expect("a quoted path is still a path");
2442        assert!(!path.to_string_lossy().contains('"'));
2443    }
2444
2445    #[test]
2446    fn a_non_answer_is_not_mistaken_for_a_path() {
2447        // Each of these has been an actual answer from a package manager at some point,
2448        // and treating any of them as a directory would size the wrong thing.
2449        for raw in [
2450            "",
2451            "\n \n",
2452            "undefined\n",
2453            "not a command\n",
2454            "./relative\n",
2455        ] {
2456            assert!(
2457                path_from_output(raw).is_none(),
2458                "{raw:?} was accepted as a cache path"
2459            );
2460        }
2461    }
2462
2463    #[test]
2464    fn the_cargo_rows_point_inside_the_registry() {
2465        // Both cargo rows are fallback-only — cargo has no "where is your cache" query —
2466        // so a wrong path here is a row that silently reports 0 B forever.
2467        for kind in ["registry cache", "registry sources"] {
2468            let path = fallbacks("cargo", kind).remove(0);
2469            assert!(
2470                path.starts_with(cargo_home().join("registry")),
2471                "{kind} resolved outside the cargo registry: {}",
2472                path.display()
2473            );
2474        }
2475    }
2476
2477    #[test]
2478    fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
2479        // conda keeps its package cache *inside* the installation, so a location one
2480        // component short of `pkgs` names the environments, the interpreter and every
2481        // other thing conda put there. `conda clean` would never touch those, but the
2482        // row prints the path it sized as well, and a multi-gigabyte figure next to
2483        // `~/miniconda3` is an invitation to delete the wrong directory by hand.
2484        let home = dirs::home_dir().expect("a home directory");
2485        let found = fallbacks("conda", "package cache");
2486
2487        for install in [
2488            "miniconda3",
2489            "anaconda3",
2490            "miniforge3",
2491            "mambaforge",
2492            ".conda",
2493        ] {
2494            let want = home.join(install).join("pkgs");
2495            assert!(
2496                found.contains(&want),
2497                "{} is not among conda's conventional locations",
2498                want.display()
2499            );
2500            assert!(
2501                !found.contains(&home.join(install)),
2502                "{} is the installation, not its package cache",
2503                home.join(install).display()
2504            );
2505        }
2506    }
2507
2508    #[test]
2509    fn the_report_is_ordered_by_what_is_worth_clearing() {
2510        let mut reports = [
2511            CacheReport {
2512                manager: "npm",
2513                kind: "cache",
2514                path: PathBuf::from("/a"),
2515                bytes: 10,
2516                clear_command: "x".to_string(),
2517                clear: Clear::Command("npm", &["cache"]),
2518                note: None,
2519                cap_gb: None,
2520                over_cap: false,
2521                dependents: None,
2522                extra_args: Vec::new(),
2523            },
2524            CacheReport {
2525                manager: "go",
2526                kind: "module cache",
2527                path: PathBuf::from("/b"),
2528                bytes: 4_000,
2529                clear_command: "y".to_string(),
2530                clear: Clear::Directory,
2531                note: None,
2532                cap_gb: None,
2533                over_cap: false,
2534                dependents: None,
2535                extra_args: Vec::new(),
2536            },
2537        ];
2538        reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
2539        assert_eq!(reports[0].manager, "go");
2540    }
2541
2542    /// A report row, with only the fields this ranking reads set to anything.
2543    fn sized(manager: &'static str, bytes: u64, dependents: Option<usize>) -> CacheReport {
2544        CacheReport {
2545            manager,
2546            kind: "cache",
2547            path: PathBuf::from("/x"),
2548            bytes,
2549            clear_command: "x".to_string(),
2550            clear: Clear::Directory,
2551            note: None,
2552            cap_gb: None,
2553            over_cap: false,
2554            dependents,
2555            extra_args: Vec::new(),
2556        }
2557    }
2558
2559    #[test]
2560    fn the_costliest_cache_per_repository_is_not_the_biggest_one() {
2561        // The shape that made this worth printing at all: npm is five times the size of
2562        // the pnpm store and a fifth of the cost, because eighteen repositories share it.
2563        let reports = [
2564            sized("npm", 10_240, Some(18)),
2565            sized("pnpm", 2_048, Some(1)),
2566            sized("cargo", 300, Some(2)),
2567            sized("cargo", 100, Some(2)),
2568            sized("bun", 512, Some(0)),
2569            sized("nuget", 900, None),
2570        ];
2571        assert_eq!(
2572            costliest_per_repository(&reports),
2573            vec![("pnpm", 2_048), ("npm", 568), ("cargo", 200)],
2574        );
2575    }
2576
2577    #[test]
2578    fn every_probe_clears_with_the_command_it_prints() {
2579        // The table tells you what to type and `clear` types it for you. If those two
2580        // ever name different programs, one of them is lying to the user.
2581        for probe in PROBES {
2582            let printed = probe.clear_command;
2583            match probe.clear {
2584                Clear::Command(program, args) => {
2585                    assert!(
2586                        printed.starts_with(program),
2587                        "{} {} prints `{printed}` but runs `{program}`",
2588                        probe.manager,
2589                        probe.kind
2590                    );
2591                    for arg in args {
2592                        // `conan remove "*"` is quoted for a shell and unquoted for a
2593                        // spawn, which is exactly the kind of drift worth catching.
2594                        assert!(
2595                            printed.contains(arg.trim_matches('"')),
2596                            "{} {} prints `{printed}` but passes `{arg}`",
2597                            probe.manager,
2598                            probe.kind
2599                        );
2600                    }
2601                }
2602                // A manual entry is still a directory delete — it is just one the user
2603                // runs. The printed command is the whole of what they get, so it has to
2604                // be there.
2605                Clear::Directory | Clear::Manual { .. } => assert!(
2606                    printed.contains("rm -rf") || printed.contains("Remove-Item"),
2607                    "{} {} deletes a directory but prints `{printed}`",
2608                    probe.manager,
2609                    probe.kind
2610                ),
2611            }
2612        }
2613    }
2614
2615    #[test]
2616    fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
2617        // `~/.m2/repository` is an install target as well as a download cache, and the
2618        // artifacts `mvn install:install-file` puts there exist nowhere else. It is
2619        // reported and sized like everything else and deleted by nothing.
2620        let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
2621        assert!(!maven.is_empty(), "maven is no longer reported at all");
2622        for probe in maven {
2623            assert!(
2624                matches!(probe.clear, Clear::Manual { .. }),
2625                "maven {} would be emptied by dev-prune",
2626                probe.kind
2627            );
2628        }
2629    }
2630
2631    #[test]
2632    fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
2633        // `run_clear` filters these out long before here. This is the last line of
2634        // defence: if a future refactor drops that filter, the failure has to be a
2635        // reported problem and not an emptied Maven repository.
2636        let dir = tempfile::tempdir().unwrap();
2637        let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
2638        std::fs::write(&artifact, b"nowhere else").unwrap();
2639
2640        let outcome = clear_one(&CacheReport {
2641            manager: "maven",
2642            kind: "local repository",
2643            path: dir.path().to_path_buf(),
2644            bytes: 12,
2645            clear_command: MAVEN_REPO_CLEAR.to_string(),
2646            clear: Clear::Manual { why: MAVEN_MANUAL },
2647            note: None,
2648            cap_gb: None,
2649            over_cap: false,
2650            dependents: None,
2651            extra_args: Vec::new(),
2652        });
2653
2654        assert!(artifact.exists(), "the store was emptied after all");
2655        assert!(
2656            outcome.problem.is_some(),
2657            "it reported success without doing anything"
2658        );
2659    }
2660
2661    #[test]
2662    fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
2663        // The unhelpful failure this guards against is "No maven cache on this machine",
2664        // which is both untrue and no help at all.
2665        let err = run_clear("maven", None, false, false, false, true, true, false).unwrap_err();
2666        assert!(
2667            err.downcast_ref::<crate::UsageError>().is_some(),
2668            "expected a usage error, got: {err:#}"
2669        );
2670        let text = format!("{err}");
2671        assert!(
2672            text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
2673            "the refusal names neither the reason nor the command: {text}"
2674        );
2675    }
2676
2677    #[test]
2678    fn every_manager_in_the_report_can_be_named_to_clear() {
2679        let names = known_managers();
2680        for probe in PROBES {
2681            assert!(
2682                names.contains(&probe.manager),
2683                "{} is reported but `devp caches clear {}` would not find it",
2684                probe.manager,
2685                probe.manager
2686            );
2687        }
2688        // cargo, go and gradle each have two rows; naming one clears both, and offering
2689        // the name twice in the error message reads like a bug.
2690        let mut sorted = names.clone();
2691        sorted.sort_unstable();
2692        sorted.dedup();
2693        assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
2694    }
2695
2696    #[test]
2697    fn an_unknown_manager_is_a_usage_error() {
2698        // Returns before anything is measured, so this touches nothing.
2699        let err = run_clear("nonesuch", None, false, false, false, true, true, false).unwrap_err();
2700        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2701    }
2702
2703    #[test]
2704    fn a_manual_only_manager_in_a_list_gets_the_same_refusal_as_alone() {
2705        // Writing `npm,maven` is not quieter consent than `maven`; the refusal must
2706        // stay loud and still name the command the user can run themselves.
2707        let err = run_clear("npm,maven", None, false, false, false, true, true, false).unwrap_err();
2708        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2709        let text = format!("{err}");
2710        assert!(
2711            text.contains(MAVEN_REPO_CLEAR),
2712            "the refusal does not name the command: {text}"
2713        );
2714    }
2715
2716    #[test]
2717    fn all_inside_a_list_is_a_usage_error() {
2718        let err = run_clear("npm,all", None, false, false, false, true, true, false).unwrap_err();
2719        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2720        let text = format!("{err}");
2721        assert!(
2722            text.contains("--except"),
2723            "the error should point at the fix: {text}"
2724        );
2725    }
2726
2727    #[test]
2728    fn except_with_a_named_target_is_a_usage_error() {
2729        let err = run_clear("npm", Some("uv"), false, false, false, true, true, false).unwrap_err();
2730        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2731    }
2732
2733    #[test]
2734    fn an_unknown_name_in_except_is_a_usage_error() {
2735        let err = run_clear(
2736            "all",
2737            Some("nonesuch"),
2738            false,
2739            false,
2740            false,
2741            true,
2742            true,
2743            false,
2744        )
2745        .unwrap_err();
2746        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2747    }
2748
2749    #[test]
2750    fn an_engine_in_a_list_is_refused_with_the_standalone_command() {
2751        let err =
2752            run_clear("npm,docker", None, false, false, false, true, true, false).unwrap_err();
2753        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2754        let text = format!("{err}");
2755        assert!(
2756            text.contains("devp caches clear docker"),
2757            "the error should name the command that does work: {text}"
2758        );
2759    }
2760
2761    #[test]
2762    fn an_engine_in_except_is_a_usage_error() {
2763        // `all` never reaches an engine, so excepting one means the user misread what
2764        // `all` covers — worth saying rather than silently accepting a no-op.
2765        let err = run_clear(
2766            "all",
2767            Some("docker"),
2768            false,
2769            false,
2770            false,
2771            true,
2772            true,
2773            false,
2774        )
2775        .unwrap_err();
2776        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2777    }
2778
2779    #[test]
2780    fn a_target_of_only_commas_names_nothing() {
2781        let err = run_clear(",", None, false, false, false, true, true, false).unwrap_err();
2782        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2783    }
2784
2785    #[test]
2786    fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
2787        let err = run_clear("npm", None, false, false, false, false, false, true).unwrap_err();
2788        assert!(err.downcast_ref::<crate::UsageError>().is_some());
2789    }
2790
2791    #[test]
2792    fn removing_a_directory_reports_nothing_when_it_worked() {
2793        let dir = tempfile::tempdir().unwrap();
2794        let cache = dir.path().join("cache");
2795        std::fs::create_dir(&cache).unwrap();
2796        std::fs::write(cache.join("blob"), b"x").unwrap();
2797
2798        assert!(remove_cache_dir(&cache).is_none());
2799        assert!(!cache.exists());
2800        // Already gone is not a failure: the retry can win the race the first attempt
2801        // lost, and reporting that as an error would fail a clear that succeeded.
2802        assert!(remove_cache_dir(&cache).is_none());
2803    }
2804
2805    #[test]
2806    fn clearing_a_directory_reports_what_actually_went() {
2807        let dir = tempfile::tempdir().unwrap();
2808        let cache = dir.path().join("store");
2809        std::fs::create_dir(&cache).unwrap();
2810        std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
2811        let before = adapters::dir_size(&cache);
2812
2813        let outcome = clear_one(&CacheReport {
2814            manager: "cargo",
2815            kind: "registry cache",
2816            path: cache.clone(),
2817            bytes: before,
2818            clear_command: "rm -rf".to_string(),
2819            clear: Clear::Directory,
2820            note: None,
2821            cap_gb: None,
2822            over_cap: false,
2823            dependents: None,
2824            extra_args: Vec::new(),
2825        });
2826
2827        assert!(outcome.problem.is_none());
2828        assert_eq!(outcome.after, 0);
2829        // Measured, not assumed: `before - after`, so a partial clear reports a partial
2830        // number instead of the whole directory.
2831        assert_eq!(outcome.freed(), before);
2832        assert!(!cache.exists());
2833    }
2834
2835    #[test]
2836    fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
2837        // The one case where dev-prune declines to fall back to deleting the directory:
2838        // only the manager knows what in its store is still referenced.
2839        let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
2840        assert!(problem.is_some_and(|p| p.contains("not on PATH")));
2841    }
2842
2843    /// One row, sized in whole gibibytes so the arithmetic in these tests is readable.
2844    fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
2845        CacheReport {
2846            manager,
2847            kind,
2848            path: PathBuf::from("/cache").join(manager).join(kind),
2849            bytes: gib * crate::constants::BYTES_PER_GIB,
2850            clear_command: "x".to_string(),
2851            clear: Clear::Directory,
2852            note: None,
2853            cap_gb: None,
2854            over_cap: false,
2855            dependents: None,
2856            extra_args: Vec::new(),
2857        }
2858    }
2859
2860    /// A count for every manager named, and nothing for the rest.
2861    fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
2862        Dependents {
2863            repositories,
2864            by_manager: counts.iter().copied().collect(),
2865        }
2866    }
2867
2868    #[test]
2869    fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
2870        // `pip`, `nuget`, `conan`, `conda` and `hex` are caches dev-prune ships
2871        // no adapter for. Deciding that `venv` feeds `pip` or that `mix` feeds `hex`
2872        // would be a guess standing in for a measurement, and the guess that reads `0`
2873        // is the one that gets a cache on a machine full of Python cleared.
2874        let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
2875        apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
2876
2877        assert_eq!(reports[0].dependents, Some(2));
2878        assert_eq!(
2879            reports[1].dependents, None,
2880            "pip has no adapter of its name, so there is nothing to count"
2881        );
2882    }
2883
2884    #[test]
2885    fn no_registry_leaves_every_count_unanswered() {
2886        // The failure this exists for: an empty registry counting to zero everywhere, and
2887        // `--unused` then offering to empty every cache on the machine.
2888        let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
2889        apply_dependents(&mut reports, None);
2890        assert!(reports.iter().all(|r| r.dependents.is_none()));
2891    }
2892
2893    #[test]
2894    fn a_manager_nothing_uses_is_a_counted_zero() {
2895        // The one state `--unused` is allowed to act on, and the only thing that separates
2896        // it from the unanswered case above.
2897        let mut reports = vec![row("go", "module cache", 3)];
2898        apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
2899        assert_eq!(reports[0].dependents, Some(0));
2900        assert!(
2901            used_by(&reports[0], 0, None, &manager_totals(&reports))
2902                .contains("no registered repository uses go")
2903        );
2904    }
2905
2906    #[test]
2907    fn the_per_repository_share_is_the_managers_whole_footprint() {
2908        // Same arithmetic as the cap, for the same reason: cargo is one cache to a person
2909        // and two rows to this command, so six plus six across two repositories is 6 GiB
2910        // each and not 3.
2911        let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2912        let line = used_by(
2913            &reports[0],
2914            2,
2915            Some(&counted(2, &[("cargo", 2)])),
2916            &manager_totals(&reports),
2917        );
2918        assert!(
2919            line.contains("cargo is used by 2 of 2 registered repositories")
2920                && line.contains("6 GiB each across its 2 caches"),
2921            "{line}"
2922        );
2923    }
2924
2925    #[test]
2926    fn one_cache_does_not_say_how_many_it_summed() {
2927        // The clause exists to explain a figure larger than the row above it. A manager
2928        // with a single row has no such gap, and "across its 1 caches" would be noise.
2929        let reports = vec![row("bun", "cache", 4)];
2930        let line = used_by(
2931            &reports[0],
2932            2,
2933            Some(&counted(2, &[("bun", 2)])),
2934            &manager_totals(&reports),
2935        );
2936        assert!(line.ends_with("2 GiB each"), "{line}");
2937    }
2938
2939    #[test]
2940    fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
2941        // Whatever a filesystem's root turns out to be on this platform, a path can only
2942        // ever sit underneath its own. A root that is not an ancestor would send the
2943        // `.pnpm-store` probe at some unrelated directory.
2944        let dir = tempfile::tempdir().unwrap();
2945        let nested = dir.path().join("a").join("b");
2946        std::fs::create_dir_all(&nested).unwrap();
2947
2948        let root = volume_root(&nested).expect("a real directory sits on some filesystem");
2949        assert!(
2950            nested.starts_with(&root),
2951            "{} is not under {}",
2952            nested.display(),
2953            root.display()
2954        );
2955        assert!(root.is_dir(), "{} is not a directory", root.display());
2956    }
2957
2958    #[cfg(windows)]
2959    #[test]
2960    fn a_windows_volume_root_is_the_drive_and_nothing_more() {
2961        // `V:\`, not `V:` and not `V:\Code`. The store this feeds is at the root of the
2962        // drive, so an answer one component too deep finds nothing and an answer with no
2963        // separator names the *current* directory on that drive instead of its root.
2964        let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
2965        assert_eq!(root, PathBuf::from("V:\\"));
2966        assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
2967    }
2968
2969    #[cfg(windows)]
2970    #[test]
2971    fn the_drive_someone_types_and_the_drive_on_disk_are_the_same_drive() {
2972        // Cache paths come back from `canonicalize`, which stamps the verbatim prefix on
2973        // them, and nobody types `\\?\C:\`. Comparing the two as text is a filter that
2974        // reports every machine as having no caches on any drive — which is exactly what
2975        // it did before this was a function.
2976        assert!(same_volume(Path::new(r"\\?\C:\"), Path::new(r"C:\")));
2977        assert!(same_volume(Path::new(r"c:\"), Path::new(r"C:\")));
2978        assert!(!same_volume(Path::new(r"C:\"), Path::new(r"V:\")));
2979    }
2980
2981    #[cfg(windows)]
2982    #[test]
2983    fn a_bare_drive_letter_is_what_people_type_and_has_to_resolve() {
2984        // `V:` is drive-*relative* to Windows — the current directory on V:, with no root
2985        // component — so it is refused by the parser it has to survive. It is also the
2986        // first thing anyone types after `--volume`, as is a bare `V`.
2987        for typed in ["V", "v", "V:", "v:", r"V:\", r"V:\Code\ProjectCode"] {
2988            assert_eq!(
2989                resolve_volume(typed).unwrap(),
2990                PathBuf::from(r"V:\"),
2991                "{typed}"
2992            );
2993        }
2994        assert!(resolve_volume("not-a-path-at-all").is_err());
2995    }
2996
2997    #[test]
2998    fn the_drive_you_are_standing_on_is_a_dot() {
2999        // `--volume .` is the shortest way to name the drive you are on, and it is what
3000        // the reference tells people to type. A relative path reaches neither
3001        // `volume_root`: one reads a prefix it has not got, the other walks ancestors
3002        // that stop at the working directory rather than the mount point.
3003        let here = std::env::current_dir().unwrap();
3004        assert_eq!(resolve_volume(".").unwrap(), volume_root(&here).unwrap());
3005        // A word that is not a path is still a usage error, and not a silent report
3006        // about the current drive.
3007        assert!(resolve_volume("definitely-not-a-drive").is_err());
3008    }
3009
3010    #[test]
3011    fn narrowing_to_one_drive_keeps_only_what_is_on_it() {
3012        // The whole point of the flag: a drive that is nearly full is the only drive that
3013        // matters, and the twenty gigabytes on the other one are noise.
3014        let dir = tempfile::tempdir().unwrap();
3015        let here = dir.path().join("cache");
3016        std::fs::create_dir_all(&here).unwrap();
3017        let root = volume_root(&here).unwrap();
3018
3019        let mut on_this_one = sized("pnpm", 2_048, Some(1));
3020        on_this_one.path = here;
3021        let mut nowhere = sized("npm", 10_240, Some(18));
3022        nowhere.path = PathBuf::from("relative/and/unresolvable");
3023
3024        let kept = on_volume(vec![on_this_one, nowhere], &root);
3025        assert_eq!(
3026            kept.len(),
3027            1,
3028            "a row whose drive is unknown is not on this one"
3029        );
3030        assert_eq!(kept[0].manager, "pnpm");
3031    }
3032
3033    #[test]
3034    fn the_per_drive_line_adds_up_and_leads_with_the_biggest() {
3035        // A subtotal a reader cannot add back up to the printed total is worse than no
3036        // subtotal, so a row whose drive cannot be determined is left out rather than
3037        // filed under a guess.
3038        let dir = tempfile::tempdir().unwrap();
3039        let here = dir.path().join("cache");
3040        std::fs::create_dir_all(&here).unwrap();
3041
3042        let mut small = sized("pnpm", 100, None);
3043        small.path = here.clone();
3044        let mut large = sized("npm", 900, None);
3045        large.path = here;
3046        let mut unknown = sized("uv", 500, None);
3047        unknown.path = PathBuf::from("relative/and/unresolvable");
3048
3049        let totals = volume_totals(&[small, large, unknown]);
3050        assert_eq!(totals.len(), 1);
3051        assert_eq!(totals[0].1, 1_000);
3052    }
3053
3054    #[test]
3055    fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
3056        // Forty-six repositories on one drive is one store to look for, not forty-six
3057        // identical rows.
3058        let dir = tempfile::tempdir().unwrap();
3059        let a = dir.path().join("one");
3060        let b = dir.path().join("two");
3061        std::fs::create_dir_all(&a).unwrap();
3062        std::fs::create_dir_all(&b).unwrap();
3063
3064        assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
3065        assert!(volume_roots(&[]).is_empty());
3066    }
3067
3068    #[test]
3069    fn a_volume_stores_printed_command_is_the_one_that_runs() {
3070        // The reason this row exists at all is that `pnpm store prune` on its own prunes
3071        // the store for the filesystem it is run on, which is not this one. Printing a
3072        // command that names the store and running one that does not would be worse than
3073        // never reporting it.
3074        let dir = tempfile::tempdir().unwrap();
3075        let store = dir.path().join(".pnpm-store");
3076        std::fs::create_dir_all(&store).unwrap();
3077
3078        let report = volume_store_report(store.clone());
3079        let named = output::clean_path(&store);
3080        assert_eq!(
3081            report.extra_args,
3082            vec!["--store-dir".to_string(), named.clone()]
3083        );
3084        assert!(
3085            report.clear_command.contains(&named),
3086            "the printed command does not name the store: {}",
3087            report.clear_command
3088        );
3089        assert!(matches!(
3090            report.clear,
3091            Clear::Command("pnpm", ["store", "prune"])
3092        ));
3093    }
3094
3095    #[test]
3096    fn only_a_path_with_a_space_in_it_is_quoted() {
3097        // The quoting is for the human reading the line. dev-prune passes the path as one
3098        // argument and never hands it to a shell, so quoting everything would print a
3099        // command that differs from the one that ran for no reason at all.
3100        assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
3101        assert_eq!(
3102            shell_arg("/mnt/my data/.pnpm-store"),
3103            "\"/mnt/my data/.pnpm-store\""
3104        );
3105    }
3106
3107    #[test]
3108    fn a_cap_is_measured_against_the_managers_whole_footprint() {
3109        // cargo keeps a registry cache and an unpacked source tree, and "cargo is over
3110        // ten gigabytes" is a statement about the pair. Six plus six clears a cap of ten
3111        // that neither row reaches on its own.
3112        let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
3113        apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
3114        assert!(
3115            reports.iter().all(|r| r.over_cap),
3116            "both rows belong to the manager that went over"
3117        );
3118        assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
3119    }
3120
3121    #[test]
3122    fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
3123        let mut reports = vec![row("npm", "cache", 3)];
3124        apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
3125        // The cap is still reported, because "capped and fine" is worth seeing — it is
3126        // the difference between a setting that is working and one nobody made.
3127        assert_eq!(reports[0].cap_gb, Some(10));
3128        assert!(!reports[0].over_cap);
3129    }
3130
3131    #[test]
3132    fn the_default_cap_covers_a_manager_nobody_named() {
3133        let caps = BTreeMap::from([(constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10)]);
3134        let mut reports = vec![row("uv", "cache", 40), row("npm", "cache", 3)];
3135        apply_caps(&mut reports, &caps);
3136        assert!(reports[0].over_cap);
3137        assert_eq!(reports[1].cap_gb, Some(10));
3138        assert!(!reports[1].over_cap);
3139    }
3140
3141    #[test]
3142    fn a_manager_named_outright_is_not_also_held_to_the_default() {
3143        // `default=10,npm=4` means npm is the exception. If the default won, the
3144        // exception would be unreachable; if both applied, 5 GiB of npm would be over
3145        // one cap and under another and the report would have to pick.
3146        let caps = BTreeMap::from([
3147            (constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10),
3148            ("npm".to_string(), 4),
3149        ]);
3150        let mut reports = vec![row("npm", "cache", 5)];
3151        apply_caps(&mut reports, &caps);
3152        assert_eq!(reports[0].cap_gb, Some(4));
3153        assert!(reports[0].over_cap);
3154    }
3155
3156    #[test]
3157    fn a_manager_with_no_cap_is_never_called_too_big() {
3158        // The default is an empty map, and an empty map has to mean "no opinion" rather
3159        // than "zero", or every cache on the machine would report as over-size.
3160        let mut reports = vec![row("uv", "cache", 40)];
3161        apply_caps(&mut reports, &BTreeMap::new());
3162        assert_eq!(reports[0].cap_gb, None);
3163        assert!(!reports[0].over_cap);
3164    }
3165
3166    #[test]
3167    fn one_managers_cap_says_nothing_about_another() {
3168        let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
3169        apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
3170        assert!(reports[0].over_cap);
3171        assert!(
3172            !reports[1].over_cap,
3173            "go has no cap and did not acquire npm's"
3174        );
3175    }
3176
3177    #[test]
3178    fn exactly_at_the_cap_is_not_over_it() {
3179        // A cap of ten means ten is allowed. Off by one here would mark a cache the
3180        // moment it hit the number the user chose as acceptable.
3181        let mut reports = vec![row("pnpm", "store", 10)];
3182        apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
3183        assert!(!reports[0].over_cap);
3184    }
3185
3186    #[test]
3187    fn every_cache_manager_answers_to_its_own_name() {
3188        // `cache_max_gb` is validated against this, so a probe the check does not know
3189        // would be a manager `devp caches clear` accepts and `devp config set` rejects.
3190        for probe in PROBES {
3191            assert!(
3192                is_cache_manager(probe.manager),
3193                "{} is reported but cannot be capped",
3194                probe.manager
3195            );
3196        }
3197        assert!(!is_cache_manager("dev-prune-no-such-manager"));
3198    }
3199}