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