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;
45
46use crate::adapters;
47use crate::constants;
48use crate::json;
49use crate::output;
50
51/// One cache directory that exists on this machine.
52pub struct CacheReport {
53    /// The package manager that owns it.
54    pub manager: &'static str,
55    /// Which of that manager's caches this is, when it keeps more than one.
56    pub kind: &'static str,
57    /// Where it actually is, as resolved on this machine.
58    pub path: PathBuf,
59    /// Total size on disk.
60    pub bytes: u64,
61    /// The command that empties it, as a human would type it.
62    ///
63    /// Owned rather than borrowed because one row's command names a path: a pnpm store
64    /// on a volume of its own is emptied by `pnpm store prune --store-dir <that store>`,
65    /// and no fixed string can say which one.
66    pub clear_command: String,
67    /// How `devp caches clear` empties it.
68    pub clear: Clear,
69    /// What the user gives up by running that command, when it is more than time.
70    pub note: Option<&'static str>,
71    /// The size cap set for this manager in `cache_max_gb`, in gibibytes.
72    ///
73    /// `None` when none is set, which is the default and means this cache is never
74    /// called too big.
75    pub cap_gb: Option<u64>,
76    /// Whether this manager's caches add up to more than [`Self::cap_gb`].
77    ///
78    /// Per *manager*, not per row: cargo keeps a registry cache and an unpacked source
79    /// tree, go keeps a build cache and a module cache, and "cargo is over ten
80    /// gigabytes" is a statement about the pair. Every row of an over-cap manager is
81    /// marked, because clearing only one of them is not what the cap asked for.
82    pub over_cap: bool,
83    /// How many registered repositories use this manager, or `None` where dev-prune
84    /// cannot say.
85    ///
86    /// `None` is not zero. It is the honest answer for the five caches no adapter is
87    /// named after — `pip`, `nuget`, `vcpkg`, `conan`, `conda`, `hex` — where deciding which
88    /// projects feed them would mean inventing a mapping dev-prune has never verified,
89    /// and it is the answer again when there is no registry to compare against. Only
90    /// `Some(0)` means "nothing registered on this machine needs this", and that is the
91    /// one reading `devp caches clear --unused` is allowed to act on.
92    pub dependents: Option<usize>,
93    /// Arguments appended to [`Self::clear`]'s command for this row alone.
94    ///
95    /// Empty for every cache a manager finds on its own. It exists for the one that a
96    /// manager does *not*: `pnpm store prune` prunes the store for the filesystem it is
97    /// run on, so emptying a store on another volume means naming it. Appended to both
98    /// the command dev-prune runs and the [`Self::clear_command`] it prints, so the two
99    /// cannot say different things.
100    pub extra_args: Vec<String>,
101}
102
103/// How one cache is emptied.
104#[derive(Clone, Copy)]
105pub enum Clear {
106    /// The manager's own subcommand, as `(program, args)`. Preferred wherever one
107    /// exists — `pnpm store prune` and `uv cache prune` keep what is still referenced,
108    /// which no directory delete can work out.
109    Command(&'static str, &'static [&'static str]),
110    /// Delete the directory this command resolved and sized. Only for the managers that
111    /// ship nothing equivalent.
112    Directory,
113    /// Report it, print the command, and refuse to run it. For the one store that is not
114    /// a cache: see the maven entry for the reason a deletion here cannot be proven
115    /// recoverable. `why` is printed to the user in place of doing it.
116    Manual { why: &'static str },
117}
118
119/// How to find one cache.
120struct Probe {
121    manager: &'static str,
122    kind: &'static str,
123    /// The manager's own answer to "where is it?", as `(program, args)`.
124    ///
125    /// All of these print a path and exit; none of them writes anything or creates the
126    /// directory. `None` means the ecosystem has no such query and only the conventional
127    /// locations are available.
128    query: Option<(&'static str, &'static [&'static str])>,
129    clear_command: &'static str,
130    clear: Clear,
131    note: Option<&'static str>,
132}
133
134/// cargo ships no cache subcommand, so the only honest "how do I clear this" is the
135/// deletion itself. `cargo build` re-downloads and re-extracts what it needs.
136#[cfg(windows)]
137const CARGO_CACHE_CLEAR: &str =
138    r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
139#[cfg(not(windows))]
140const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
141
142#[cfg(windows)]
143const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
144#[cfg(not(windows))]
145const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
146
147/// Maven has no cache subcommand either — `mvn dependency:purge-local-repository`
148/// exists, but it needs a project to run in and re-resolves as it purges, which is not
149/// "clear the cache". The honest command is the deletion, so that is what gets printed —
150/// but dev-prune does not run it. See [`MAVEN_MANUAL`].
151#[cfg(windows)]
152const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
153#[cfg(not(windows))]
154const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
155
156/// Why `devp caches clear maven` refuses.
157///
158/// `~/.m2/repository` is the one entry in this table that is not a cache, and Maven does
159/// not call it one either — it is the *local repository*, and `mvn install` writes into
160/// it. Two things live there that no remote can hand back:
161///
162/// * artifacts put there by `mvn install:install-file`, which is the documented way to
163///   use a jar that is in no repository at all — a driver behind a click-through
164///   licence, a partner SDK, an internal artifact from before there was an internal
165///   Nexus. There is nothing to re-download them *from*.
166/// * `-SNAPSHOT` builds of the user's own modules, which are recoverable only for as
167///   long as the source that produced them is still on the machine and still builds.
168///
169/// Maven does record which remote each artifact came from, in a `_remote.repositories`
170/// file it documents as internal and free to change without notice — and one written
171/// only by Maven 3 and later, so an older or legacy-mode repository has none at all.
172/// Deleting on the strength of that would mean betting the unrecoverable half of the
173/// tree on a file format with no compatibility promise. Sizing it and printing the
174/// command is the whole of what can be done honestly.
175const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
176     download cache: `mvn install` and `install:install-file` write artifacts there \
177     that exist nowhere else, and nothing in the tree tells them apart from the \
178     downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
179     the command; running it is yours to decide.";
180
181#[cfg(windows)]
182const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
183#[cfg(not(windows))]
184const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
185
186#[cfg(windows)]
187const GRADLE_DISTS_CLEAR: &str =
188    r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
189#[cfg(not(windows))]
190const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
191
192#[cfg(windows)]
193const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
194#[cfg(not(windows))]
195const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
196
197/// Hex has no cache-clearing task. hexpm/hex#344 asked for one and there still is not
198/// one, so the honest command is the deletion; `mix deps.get` re-fetches the tarballs.
199#[cfg(windows)]
200const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
201#[cfg(not(windows))]
202const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
203
204const PROBES: &[Probe] = &[
205    Probe {
206        manager: "npm",
207        kind: "cache",
208        query: Some(("npm", &["config", "get", "cache"])),
209        clear_command: "npm cache clean --force",
210        clear: Clear::Command("npm", &["cache", "clean", "--force"]),
211        note: None,
212    },
213    Probe {
214        manager: "pnpm",
215        kind: "store",
216        query: Some(("pnpm", &["store", "path"])),
217        clear_command: "pnpm store prune",
218        clear: Clear::Command("pnpm", &["store", "prune"]),
219        note: Some(
220            "hardlinked into every node_modules it filled; emptying it is what makes the \
221             next pnpm install a download",
222        ),
223    },
224    Probe {
225        manager: "yarn",
226        kind: "cache",
227        query: Some(("yarn", &["cache", "dir"])),
228        clear_command: "yarn cache clean",
229        clear: Clear::Command("yarn", &["cache", "clean"]),
230        note: None,
231    },
232    Probe {
233        manager: "bun",
234        kind: "cache",
235        query: Some(("bun", &["pm", "cache"])),
236        clear_command: "bun pm cache rm",
237        clear: Clear::Command("bun", &["pm", "cache", "rm"]),
238        note: None,
239    },
240    Probe {
241        manager: "uv",
242        kind: "cache",
243        query: Some(("uv", &["cache", "dir"])),
244        // `prune` drops what nothing can use again and keeps the rest; `uv cache clean`
245        // is the sledgehammer, and is not what most people mean by "clear the cache".
246        clear_command: "uv cache prune",
247        clear: Clear::Command("uv", &["cache", "prune"]),
248        note: None,
249    },
250    Probe {
251        manager: "pip",
252        kind: "cache",
253        query: Some(("pip", &["cache", "dir"])),
254        clear_command: "pip cache purge",
255        clear: Clear::Command("pip", &["cache", "purge"]),
256        note: None,
257    },
258    // conda ships a command that prints the package directories, but it is `conda config
259    // --show pkgs_dirs` and conda takes seconds to start on a cold shell — the same price
260    // Maven charges, for the same read-only size report. So this row is the conventional
261    // locations plus `CONDA_EXE`, which every conda shell exports and which names the
262    // installation root wherever someone put it.
263    Probe {
264        manager: "conda",
265        kind: "package cache",
266        query: None,
267        clear_command: "conda clean --packages --tarballs --yes",
268        clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
269        note: Some(
270            "unpacked packages and downloaded archives; conda keeps what its \
271             environments use, except any it linked by symlink rather than hardlink",
272        ),
273    },
274    Probe {
275        manager: "cargo",
276        kind: "registry cache",
277        query: None,
278        clear_command: CARGO_CACHE_CLEAR,
279        clear: Clear::Directory,
280        note: Some("the downloaded .crate archives; clearing them means downloading again"),
281    },
282    Probe {
283        manager: "cargo",
284        kind: "registry sources",
285        query: None,
286        clear_command: CARGO_SRC_CLEAR,
287        clear: Clear::Directory,
288        note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
289    },
290    Probe {
291        manager: "go",
292        kind: "module cache",
293        query: Some(("go", &["env", "GOMODCACHE"])),
294        clear_command: "go clean -modcache",
295        clear: Clear::Command("go", &["clean", "-modcache"]),
296        note: None,
297    },
298    Probe {
299        manager: "go",
300        kind: "build cache",
301        query: Some(("go", &["env", "GOCACHE"])),
302        clear_command: "go clean -cache",
303        clear: Clear::Command("go", &["clean", "-cache"]),
304        note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
305    },
306    // `mvn help:evaluate -Dexpression=settings.localRepository` would answer precisely,
307    // but it boots a JVM, resolves the help plugin over the network on first use, and
308    // takes several seconds — the wrong trade for a read-only size report. A relocated
309    // repository (settings.xml `<localRepository>`) is rare enough to miss.
310    Probe {
311        manager: "maven",
312        kind: "local repository",
313        query: None,
314        clear_command: MAVEN_REPO_CLEAR,
315        clear: Clear::Manual { why: MAVEN_MANUAL },
316        note: Some(
317            "every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
318        ),
319    },
320    Probe {
321        manager: "gradle",
322        kind: "caches",
323        query: None,
324        clear_command: GRADLE_CACHE_CLEAR,
325        clear: Clear::Directory,
326        note: Some(
327            "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
328        ),
329    },
330    Probe {
331        manager: "gradle",
332        kind: "wrapper distributions",
333        query: None,
334        clear_command: GRADLE_DISTS_CLEAR,
335        clear: Clear::Directory,
336        note: Some(
337            "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
338        ),
339    },
340    // `dotnet nuget locals global-packages --list` answers `global-packages: <path>` —
341    // a labelled line, not a bare path — so the conventional locations are simpler and
342    // just as reliable. The clear command, however, is nuget's own.
343    Probe {
344        manager: "nuget",
345        kind: "global packages",
346        query: None,
347        clear_command: "dotnet nuget locals global-packages --clear",
348        clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
349        note: Some(
350            "every .NET project on the machine restores from here; re-downloaded on the next restore",
351        ),
352    },
353    Probe {
354        manager: "vcpkg",
355        kind: "binary cache",
356        query: None,
357        clear_command: VCPKG_ARCHIVES_CLEAR,
358        clear: Clear::Directory,
359        note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
360    },
361    Probe {
362        manager: "conan",
363        kind: "package cache",
364        query: None,
365        clear_command: "conan remove \"*\" --confirm",
366        clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
367        note: Some(
368            "recipes and binaries shared by every Conan project; re-fetched on the next install",
369        ),
370    },
371    // Composer will say where its cache is, and asking is the only way to get it right:
372    // the directory moves with `COMPOSER_HOME`, with `COMPOSER_CACHE_DIR`, and with a
373    // `cache-dir` written into the global config, and the default differs on all three
374    // platforms. That is four ways to be wrong and one command that is not.
375    Probe {
376        manager: "composer",
377        kind: "cache",
378        query: Some(("composer", &["config", "--global", "cache-dir"])),
379        clear_command: "composer clear-cache",
380        clear: Clear::Command("composer", &["clear-cache"]),
381        note: Some(
382            "downloaded package archives and repository metadata; re-fetched by the next composer install",
383        ),
384    },
385    // CocoaPods ships no command that prints the cache directory — `pod cache list`
386    // prints its *contents* — so this row is the conventional location plus the
387    // relocation variable. Emptying it is still CocoaPods' own job: the cache is keyed by
388    // pod name and version and it keeps an index of what is in there.
389    Probe {
390        manager: "cocoapods",
391        kind: "cache",
392        query: None,
393        clear_command: "pod cache clean --all",
394        clear: Clear::Command("pod", &["cache", "clean", "--all"]),
395        note: Some("downloaded pod sources, re-fetched by the next pod install"),
396    },
397    Probe {
398        manager: "hex",
399        kind: "package cache",
400        query: None,
401        clear_command: HEX_CACHE_CLEAR,
402        clear: Clear::Directory,
403        note: Some(
404            "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
405        ),
406    },
407];
408
409/// Run the `caches` command.
410pub fn run(json_output: bool) -> Result<()> {
411    let reg = registered();
412    let mut reports = collect(!json_output, reg.as_ref());
413    apply_caps(&mut reports, &caps());
414    let deps = reg.as_ref().map(|r| dependents(r, !json_output));
415    apply_dependents(&mut reports, deps.as_ref());
416
417    // Asked here rather than left to `devp caches containers`, because the mistake this
418    // report exists to prevent is someone clearing 6 GB of npm cache while a stopped
419    // Docker daemon holds 40 GB they were never told about. It costs one `system df` per
420    // installed engine and nothing at all on a machine with none.
421    let engines = container_summary(!json_output);
422
423    if json_output {
424        return json::emit(&json::caches_document(
425            &reports,
426            deps.as_ref().map(|d| d.repositories),
427            &engines,
428        ));
429    }
430
431    print_report(&reports, deps.as_ref());
432    crate::commands::containers::print_summary(&engines);
433    Ok(())
434}
435
436/// The container engines on this machine, behind the report's own spinner.
437fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
438    let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
439    let engines = crate::commands::containers::collect(None);
440    if let Some(pb) = pb {
441        pb.finish_and_clear();
442    }
443    engines
444}
445
446/// The user's `cache_max_gb`, or an empty map when the registry cannot be read.
447///
448/// A cap is a preference, and a preference that cannot be loaded is not a reason to
449/// refuse to report cache sizes — the command's whole job still works without it.
450fn caps() -> BTreeMap<String, u64> {
451    crate::config::Registry::load()
452        .map(|r| r.settings.cache_max_gb)
453        .unwrap_or_default()
454}
455
456/// Mark every row whose *manager* is over the cap set for it.
457///
458/// Split out from [`collect`] so the size walk stays a measurement and the verdict stays
459/// a separate, testable step over it.
460fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
461    let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
462    for r in reports.iter() {
463        *totals.entry(r.manager).or_default() += r.bytes;
464    }
465    for r in reports.iter_mut() {
466        let Some(&gb) = caps.get(r.manager) else {
467            continue;
468        };
469        r.cap_gb = Some(gb);
470        r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
471            > gb.saturating_mul(crate::constants::BYTES_PER_GIB);
472    }
473}
474
475/// The registered repositories that are actually on this disk.
476///
477/// Two of the questions this command answers are questions about the machine's
478/// repositories rather than about its caches — which filesystems hold projects, and
479/// which managers those projects use — so the registry is read once and handed to both.
480struct Registered {
481    /// Registry paths that still exist.
482    paths: Vec<PathBuf>,
483    /// The machine-wide scan depth, before any repository's own override.
484    scan_depth: usize,
485}
486
487/// Load the registry, or nothing when there is nothing in it worth loading.
488///
489/// `None` — not an empty list — for a registry that will not load, holds no
490/// repositories, or holds only paths that are no longer on disk. All three would
491/// otherwise make every cache on the machine read as used by nobody, and `--unused`
492/// would offer to empty the lot on the strength of a registry someone had simply not
493/// filled in yet.
494fn registered() -> Option<Registered> {
495    let registry = crate::config::Registry::load().ok()?;
496    let paths: Vec<PathBuf> = registry
497        .repositories
498        .keys()
499        .filter(|p| p.exists())
500        .cloned()
501        .collect();
502    if paths.is_empty() {
503        return None;
504    }
505    Some(Registered {
506        paths,
507        scan_depth: registry.settings.scan_depth,
508    })
509}
510
511/// How many registered repositories still use each package manager.
512///
513/// The report answers "how big is it". This answers the question that follows and that
514/// nothing else on the machine can: *who still needs it*. A cache with no repository
515/// behind it is sediment — everything in it was downloaded for projects that are no
516/// longer here — and it is the only kind this tool will offer to clear on the strength
517/// of a count.
518struct Dependents {
519    /// Registered repositories that are actually on this disk, and the denominator of
520    /// every count below.
521    repositories: usize,
522    /// Repositories in which an adapter of this name was detected, keyed by manager.
523    ///
524    /// Only names that are both a cache in [`PROBES`] and an adapter appear at all. The
525    /// rest are absent rather than zero, which is what carries the difference between
526    /// "nothing uses it" and "dev-prune has no way to tell".
527    by_manager: BTreeMap<&'static str, usize>,
528}
529
530/// Count the repositories behind each cache.
531///
532/// Only ever called with a [`Registered`], which is the thing that carries "there is
533/// something here to count against" — see [`registered`] for why the absence of one is
534/// not the same as a count of zero.
535fn dependents(reg: &Registered, spinner: bool) -> Dependents {
536    let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
537
538    // Seeded at zero for every cache an adapter is named after, so a manager nothing uses
539    // is a counted zero rather than a missing key. The five that are absent — `pip`,
540    // `conda`, `nuget`, `conan` and `hex` — stay absent: dev-prune ships no adapter of
541    // those names, and deciding that `venv` feeds `pip` or that `mix` feeds `hex` would
542    // be a guess standing in for a measurement.
543    let mut by_manager: BTreeMap<&'static str, usize> = PROBES
544        .iter()
545        .map(|p| p.manager)
546        .filter(|m| adapters::is_adapter_name(m))
547        .map(|m| (m, 0))
548        .collect();
549
550    for path in &reg.paths {
551        // The repository's own `scan_depth` where it sets one, read exactly as a prune
552        // pass reads it: a monorepo that had to raise its depth to be pruned properly has
553        // to be walked to that same depth here, or its projects are invisible and the
554        // managers behind them are undercounted.
555        let depth = crate::workspace::clamp_depth(
556            crate::config::PerRepoConfig::load_with_diagnostics(path)
557                .ok()
558                .flatten()
559                .and_then(|c| c.scan_depth)
560                .unwrap_or(reg.scan_depth),
561        );
562        let mut here: HashSet<&'static str> = HashSet::new();
563        for project in crate::workspace::discover_all_to_depth(path, depth) {
564            for adapter in &project.adapters {
565                here.insert(adapter.name());
566            }
567        }
568        for (manager, count) in by_manager.iter_mut() {
569            if here.contains(manager) {
570                *count += 1;
571            }
572        }
573    }
574
575    if let Some(pb) = pb {
576        pb.finish_and_clear();
577    }
578
579    Dependents {
580        repositories: reg.paths.len(),
581        by_manager,
582    }
583}
584
585/// Hand each row the count for its manager, and leave the rest at `None`.
586fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
587    let Some(deps) = deps else {
588        return;
589    };
590    for r in reports.iter_mut() {
591        r.dependents = deps.by_manager.get(r.manager).copied();
592    }
593}
594
595/// What each manager's caches add up to, across every row it has.
596///
597/// The same total the cap is measured against, and for the same reason: "cargo" is one
598/// cache to a person and two rows to this command.
599fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, u64> {
600    let mut totals: BTreeMap<&'static str, u64> = BTreeMap::new();
601    for r in reports {
602        *totals.entry(r.manager).or_default() += r.bytes;
603    }
604    totals
605}
606
607/// Find and size every cache on this machine, largest first.
608fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
609    let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
610    let from = query_dir();
611
612    let mut seen: HashSet<PathBuf> = HashSet::new();
613    let mut reports = Vec::new();
614
615    for probe in PROBES {
616        let Some(path) = locate(probe, &from) else {
617            continue;
618        };
619        // Canonical, because two probes can land on the same directory — `GOCACHE` and
620        // `GOMODCACHE` are both under `~/.cache` on Linux, and a machine can be
621        // configured to share them. Counting one twice would inflate the total, which is
622        // the one number this command exists to get right. It also settles the spelling:
623        // a manager answers in whatever case and separators it likes, and two rows
624        // disagreeing about how to write `C:\Users` reads like a bug.
625        let path = path.canonicalize().unwrap_or(path);
626        if !seen.insert(path.clone()) {
627            continue;
628        }
629        reports.push(CacheReport {
630            manager: probe.manager,
631            kind: probe.kind,
632            bytes: adapters::dir_size(&path),
633            path,
634            clear_command: probe.clear_command.to_string(),
635            clear: probe.clear,
636            note: probe.note,
637            cap_gb: None,
638            over_cap: false,
639            dependents: None,
640            extra_args: Vec::new(),
641        });
642    }
643
644    // After the probes, so the ordinary case — home and projects on one filesystem, one
645    // store, already found — does not get reported twice.
646    for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
647        if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
648            continue;
649        }
650        reports.push(volume_store_report(store));
651    }
652
653    if let Some(pb) = pb {
654        pb.finish_and_clear();
655    }
656
657    reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
658    reports
659}
660
661/// Why a second pnpm store on one machine is not a duplicate.
662const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
663     this is the store for the projects on this volume";
664
665/// One row for a pnpm store that lives on a volume of its own.
666///
667/// The printed command and the arguments dev-prune runs are built from the same path, in
668/// one place, because the whole point of printing a command is that it is the one being
669/// run.
670fn volume_store_report(store: PathBuf) -> CacheReport {
671    let named = output::clean_path(&store);
672    CacheReport {
673        manager: "pnpm",
674        kind: "store",
675        bytes: adapters::dir_size(&store),
676        clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
677        extra_args: vec!["--store-dir".to_string(), named],
678        path: store,
679        clear: Clear::Command("pnpm", &["store", "prune"]),
680        note: Some(PNPM_VOLUME_NOTE),
681        cap_gb: None,
682        over_cap: false,
683        dependents: None,
684    }
685}
686
687/// Quote a path for the command line this report prints, and only when it needs it.
688///
689/// Only for display. The command dev-prune runs passes the path as one argument and
690/// never goes near a shell.
691fn shell_arg(named: &str) -> String {
692    if named.contains(' ') {
693        format!("\"{named}\"")
694    } else {
695        named.to_string()
696    }
697}
698
699/// pnpm stores sitting on a filesystem of their own, one per volume that holds a
700/// registered repository.
701///
702/// pnpm hardlinks its store into every `node_modules` it fills, and a hardlink cannot
703/// cross a filesystem. So a project that is not on the home directory's filesystem does
704/// not use the store beside the home directory: pnpm puts one at the root of *that*
705/// filesystem and fills it with everything those projects need. This is not a Windows
706/// idea. It is the same rule for a second drive on Windows, a separate `/home` or
707/// `/mnt/data` on Linux, and an external volume under `/Volumes` on macOS.
708///
709/// It has to be looked for, because the query the pnpm row otherwise trusts — `pnpm
710/// store path` — answers for the filesystem it is run on, and it is run from the home
711/// directory. On a machine whose projects all live on a second drive, that answer is a
712/// nearly empty store and the real one, the multi-gigabyte one, is invisible.
713fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
714    // The volume the command was run from counts as well as the registered ones. A
715    // machine with nothing linked yet has no registry to read, and standing in the
716    // project whose store this is is the one moment dev-prune can still find it.
717    let mut roots = volume_roots(repos);
718    if let Ok(here) = std::env::current_dir()
719        && let Some(root) = volume_root(&here)
720        && !roots.contains(&root)
721    {
722        roots.push(root);
723    }
724    roots
725        .into_iter()
726        .map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
727        .filter(|store| store.is_dir())
728        .collect()
729}
730
731/// The distinct filesystems a set of repositories sits on, in the order first seen.
732fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
733    let mut roots: Vec<PathBuf> = Vec::new();
734    for repo in repos {
735        if let Some(root) = volume_root(repo)
736            && !roots.contains(&root)
737        {
738            roots.push(root);
739        }
740    }
741    roots
742}
743
744/// The root of the filesystem `path` sits on.
745///
746/// Mount points are found by device number rather than by parsing a mount table:
747/// `/proc/mounts` is Linux-only, the output of `mount` is not a format, and `st_dev` is
748/// the same answer on every Unix. The highest ancestor still on the same device is where
749/// the filesystem starts.
750#[cfg(unix)]
751fn volume_root(path: &Path) -> Option<PathBuf> {
752    use std::os::unix::fs::MetadataExt;
753
754    let dev = std::fs::metadata(path).ok()?.dev();
755    let mut root = path.to_path_buf();
756    for ancestor in path.ancestors().skip(1) {
757        match std::fs::metadata(ancestor) {
758            Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
759            _ => break,
760        }
761    }
762    Some(root)
763}
764
765/// The root of the volume `path` sits on: `V:\`, or `\\server\share\` for a UNC path.
766///
767/// Windows can also mount a volume into an empty directory of another one, which this
768/// does not see. A drive letter is what a developer with a second disk actually has, and
769/// the cost of missing the other case is a cache that goes unreported rather than one
770/// that is wrongly cleared.
771#[cfg(windows)]
772fn volume_root(path: &Path) -> Option<PathBuf> {
773    use std::path::Component;
774
775    let mut components = path.components();
776    let Some(Component::Prefix(prefix)) = components.next() else {
777        return None;
778    };
779    if components.next() != Some(Component::RootDir) {
780        return None;
781    }
782    let mut root = PathBuf::from(prefix.as_os_str());
783    root.push(Component::RootDir.as_os_str());
784    Some(root)
785}
786
787/// Where to run the "where is your cache?" queries from.
788///
789/// The home directory, not the current one. A project's `.npmrc` or `.cargo/config.toml`
790/// can move the cache for that project alone, and answering with it would report a
791/// directory that is not the machine's actual cache. Falling back to the current
792/// directory is only for the case where there is no home directory at all.
793fn query_dir() -> PathBuf {
794    dirs::home_dir()
795        .or_else(|| std::env::current_dir().ok())
796        .unwrap_or_else(|| PathBuf::from("."))
797}
798
799/// Resolve one probe to a directory that exists, or nothing.
800fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
801    if let Some((program, args)) = probe.query
802        && adapters::binary_available(program)
803    {
804        let answered = adapters::capture_command_with_timeout(
805            program,
806            args,
807            from,
808            std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
809        )
810        .ok()
811        .and_then(|raw| path_from_output(&raw))
812        .filter(|p| p.is_dir());
813        if answered.is_some() {
814            return answered;
815        }
816    }
817
818    // Either the manager is not installed, or it is and its cache has never been
819    // populated. The conventional location is still worth checking: an uninstalled
820    // manager leaves its cache behind, and that is exactly the multi-gigabyte directory
821    // nobody remembers.
822    fallbacks(probe.manager, probe.kind)
823        .into_iter()
824        .find(|p| p.is_dir())
825}
826
827/// Read a path out of a manager's answer.
828///
829/// The last non-empty line, because some managers print a notice first, and quotes are
830/// stripped because `go env` quotes paths containing spaces on Windows.
831fn path_from_output(raw: &str) -> Option<PathBuf> {
832    let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
833    let line = line.trim_matches('"');
834    // npm answers `undefined` for a config key it does not have, and a manager that
835    // errored can print anything at all. A relative path is never a machine-wide cache.
836    if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
837        return None;
838    }
839    Some(PathBuf::from(line))
840}
841
842/// Conventional locations for a cache, most likely first.
843fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
844    let home = dirs::home_dir();
845    let local = dirs::data_local_dir();
846    let cache = dirs::cache_dir();
847    // `rel` is split rather than joined whole so a Windows path never comes out as
848    // `C:\Users\dev\go\pkg/mod`. `Path::join` accepts the forward slashes, it just keeps
849    // them, and a report that spells the same drive two ways reads like a bug.
850    let under = |base: &Option<PathBuf>, rel: &str| {
851        base.as_ref()
852            .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
853    };
854
855    let candidates = match (manager, kind) {
856        // `npm config get cache` answers `~/.npm` on Unix and `%LocalAppData%\npm-cache`
857        // on Windows; the payload lives in `_cacache` underneath either one.
858        ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
859        ("pnpm", _) => vec![
860            under(&local, "pnpm/store"),
861            under(&home, ".local/share/pnpm/store"),
862            under(&home, "Library/pnpm/store"),
863            under(&home, ".pnpm-store"),
864        ],
865        ("yarn", _) => vec![
866            under(&home, ".yarn/berry/cache"),
867            under(&local, "Yarn/Cache"),
868            under(&cache, "yarn"),
869        ],
870        ("bun", _) => vec![under(&home, ".bun/install/cache")],
871        ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
872        ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
873        // `CONDA_PKGS_DIRS` names one directory in practice; conda's own multi-value
874        // support for it is still a feature request, so this is not split on anything.
875        // `CONDA_EXE` is `<root>/bin/conda` on Unix and `<root>\Scripts\conda.exe` on
876        // Windows, so the grandparent is the installation root either way — the only way
877        // to find a conda that is not in one of the default places. `~/.conda/pkgs` is
878        // where conda falls back when the root is not writable, which is every managed
879        // multi-user install.
880        ("conda", _) => vec![
881            std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
882            std::env::var_os("CONDA_EXE")
883                .map(PathBuf::from)
884                .and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
885                .map(|root| root.join("pkgs")),
886            under(&home, "miniconda3/pkgs"),
887            under(&home, "anaconda3/pkgs"),
888            under(&home, "miniforge3/pkgs"),
889            under(&home, "mambaforge/pkgs"),
890            under(&home, ".conda/pkgs"),
891        ],
892        ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
893        ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
894        ("go", "module cache") => vec![
895            std::env::var_os("GOMODCACHE").map(PathBuf::from),
896            std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
897            under(&home, "go/pkg/mod"),
898        ],
899        ("go", _) => vec![
900            std::env::var_os("GOCACHE").map(PathBuf::from),
901            under(&cache, "go-build"),
902            under(&local, "go-build"),
903        ],
904        ("maven", _) => vec![under(&home, ".m2/repository")],
905        // GRADLE_USER_HOME relocates the whole ~/.gradle tree, caches and wrapper both.
906        ("gradle", "caches") => vec![
907            std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
908            under(&home, ".gradle/caches"),
909        ],
910        ("gradle", _) => vec![
911            std::env::var_os("GRADLE_USER_HOME")
912                .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
913            under(&home, ".gradle/wrapper/dists"),
914        ],
915        ("nuget", _) => vec![
916            std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
917            under(&home, ".nuget/packages"),
918        ],
919        ("vcpkg", _) => vec![
920            std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
921            under(&local, "vcpkg/archives"),
922            under(&cache, "vcpkg/archives"),
923        ],
924        // Conan 2 keeps packages under <CONAN_HOME>/p; pointing at `p` rather than the
925        // whole home keeps profiles and remotes out of the size (and out of harm's way).
926        ("conan", _) => vec![
927            std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
928            under(&home, ".conan2/p"),
929        ],
930        // Only reached when `composer` is not installed, which is the case worth
931        // covering: the cache a PHP toolchain left behind is the one nobody remembers.
932        ("composer", _) => vec![
933            std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
934            std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
935            under(&local, "Composer"),
936            under(&cache, "composer"),
937            under(&home, ".composer/cache"),
938        ],
939        // CocoaPods puts the cache under `~/Library/Caches` by name rather than through
940        // the platform's cache directory, so this is `home` and not `cache` even on the
941        // one platform where the two would agree.
942        ("cocoapods", _) => vec![
943            std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
944            under(&home, "Library/Caches/CocoaPods"),
945        ],
946        // HEX_HOME moves the whole `.hex` tree; MIX_XDG puts it under the platform cache
947        // directory instead. Both are checked because either can be set alone.
948        ("hex", _) => vec![
949            std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
950            under(&home, ".hex/packages"),
951            under(&cache, "hex/packages"),
952        ],
953        _ => vec![],
954    };
955
956    candidates.into_iter().flatten().collect()
957}
958
959/// `CARGO_HOME`, or the default cargo puts it in.
960fn cargo_home() -> PathBuf {
961    std::env::var_os("CARGO_HOME")
962        .map(PathBuf::from)
963        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
964        .unwrap_or_else(|| PathBuf::from(".cargo"))
965}
966
967fn print_report(reports: &[CacheReport], deps: Option<&Dependents>) {
968    output::print_header("Package manager caches");
969
970    if reports.is_empty() {
971        println!();
972        output::print_info("No package manager caches found on this machine.");
973        return;
974    }
975
976    println!();
977    let totals = manager_totals(reports);
978    // One line per manager, not per row: cargo's registry cache and its sources have the
979    // same repositories behind them, and saying so twice reads as two findings.
980    let mut counted: HashSet<&'static str> = HashSet::new();
981    for r in reports {
982        let label = format!("{} {}", r.manager, r.kind);
983        println!(
984            "  {:<30} {:>10}  {}",
985            label,
986            output::format_bytes(r.bytes),
987            output::clean_path(&r.path)
988        );
989        println!("  {:<30} {:>10}  clear: {}", "", "", r.clear_command);
990        if let Some(note) = r.note {
991            println!("  {:<30} {:>10}  {}", "", "", note);
992        }
993        if r.over_cap
994            && let Some(gb) = r.cap_gb
995        {
996            println!(
997                "  {:<30} {:>10}  over the {gb} GiB cap you set for {}",
998                "", "", r.manager
999            );
1000        }
1001        if let Some(n) = r.dependents
1002            && counted.insert(r.manager)
1003        {
1004            println!("  {:<30} {:>10}  {}", "", "", used_by(r, n, deps, &totals));
1005        }
1006        println!();
1007    }
1008
1009    let total: u64 = reports.iter().map(|r| r.bytes).sum();
1010    println!(
1011        "  {:<30} {:>10}  across {} {}",
1012        "Total",
1013        output::format_bytes(total),
1014        reports.len(),
1015        output::plural(reports.len(), "cache", "caches")
1016    );
1017
1018    if reports.iter().any(|r| r.over_cap) {
1019        println!();
1020        output::print_info(
1021            "The caches marked above have outgrown the cap you set for them. `devp caches clear \
1022             --over-cap all` empties exactly those and leaves the rest alone.",
1023        );
1024    }
1025
1026    if reports.iter().any(|r| r.dependents == Some(0)) {
1027        println!();
1028        output::print_info(
1029            "The caches above that no registered repository uses were filled for projects that \
1030             are not here any more. `devp caches clear --unused all` empties exactly those. It \
1031             counts only repositories dev-prune knows about, so `devp link` anything you keep \
1032             outside the registry before trusting the number.",
1033        );
1034    }
1035
1036    println!();
1037    output::print_info(
1038        "Nothing above was deleted. A cache is shared by every project on the machine, so \
1039         no single repository's lockfile can prove it is recoverable — and it is what \
1040         makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
1041         will ever touch one. When you want the space more than the speed, run a clear \
1042         command yourself, or `devp caches clear <manager>`.",
1043    );
1044}
1045
1046/// The one line that says who still needs this manager's caches.
1047///
1048/// The size beside the count is the manager's whole footprint divided by the number of
1049/// repositories behind it, which is the figure that actually decides anything: two
1050/// repositories holding a 12 GiB cache between them is 6 GiB each and worth a look; forty
1051/// repositories holding the same 12 GiB is 300 MiB each and is the cache doing its job.
1052fn used_by(
1053    r: &CacheReport,
1054    dependents: usize,
1055    deps: Option<&Dependents>,
1056    totals: &BTreeMap<&'static str, u64>,
1057) -> String {
1058    if dependents == 0 {
1059        return format!("no registered repository uses {}", r.manager);
1060    }
1061    let registered = deps.map_or(dependents, |d| d.repositories);
1062    let total = totals.get(r.manager).copied().unwrap_or(r.bytes);
1063    // Named rather than implied. The label column is blank on a continuation line, and
1064    // the figure is the manager's total across every row it has — so on go's two rows the
1065    // number beside "go build cache" is not that row's size, and the sentence has to say
1066    // whose it is.
1067    format!(
1068        "{} is used by {dependents} of {registered} registered {} · {} each",
1069        r.manager,
1070        output::plural(registered, "repository", "repositories"),
1071        output::format_bytes(total / dependents as u64)
1072    )
1073}
1074
1075/// What happened to one cache.
1076pub struct ClearOutcome {
1077    /// The package manager that owned it.
1078    pub manager: &'static str,
1079    /// Which of that manager's caches this was.
1080    pub kind: &'static str,
1081    /// Where it is.
1082    pub path: PathBuf,
1083    /// Size before, as this command measured it.
1084    pub before: u64,
1085    /// Size after, measured again rather than assumed. `pnpm store prune` and `uv cache
1086    /// prune` deliberately keep what is still referenced, so subtracting is the only
1087    /// honest way to say what actually went.
1088    pub after: u64,
1089    /// `None` when it worked; otherwise why it did not, phrased for a human.
1090    pub problem: Option<String>,
1091}
1092
1093impl ClearOutcome {
1094    /// Bytes given back to the disk.
1095    pub fn freed(&self) -> u64 {
1096        self.before.saturating_sub(self.after)
1097    }
1098}
1099
1100/// Run `dev-prune caches clear <target>`.
1101///
1102/// `target` is a manager name or `all`. Everything about to be emptied is named and
1103/// sized first, and unless `--yes` answers for the user, it asks. `over_cap` narrows the
1104/// selection to managers that have outgrown their `cache_max_gb` entry, and `unused` to
1105/// managers no registered repository uses at all.
1106pub fn run_clear(
1107    target: &str,
1108    over_cap: bool,
1109    unused: bool,
1110    yes: bool,
1111    dry_run: bool,
1112    json_output: bool,
1113) -> Result<()> {
1114    let all = target.eq_ignore_ascii_case("all");
1115    // A container engine is a thing `devp caches` reports on, so its name is a plausible
1116    // thing to type here. "not a manager dev-prune knows" would be both wrong and a dead
1117    // end; the answer is that this tool does not delete container disk, and where to go
1118    // to see it.
1119    if !all && crate::commands::containers::is_engine(target) {
1120        return Err(anyhow::Error::new(crate::UsageError(format!(
1121            "dev-prune reports {target}'s disk use and never deletes it — an image has no \
1122             lockfile to prove it can be rebuilt, and a volume cannot be rebuilt at all. \
1123             `devp caches {target}` shows what it is holding and prints the prune commands \
1124             for you to run."
1125        ))));
1126    }
1127    if !all
1128        && !PROBES
1129            .iter()
1130            .any(|p| p.manager.eq_ignore_ascii_case(target))
1131    {
1132        return Err(anyhow::Error::new(crate::UsageError(format!(
1133            "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
1134            known_managers().join(", ")
1135        ))));
1136    }
1137    // Naming a manager dev-prune only ever reports is asking for the one thing this
1138    // command does not do, so the reason is the answer — and it is the same answer
1139    // whether or not the store is on this machine, which is why it comes from the table
1140    // rather than from a size walk that would end in "nothing to clear".
1141    if !all
1142        && let Some(probe) = manual_only(target)
1143        && let Clear::Manual { why } = probe.clear
1144    {
1145        return Err(anyhow::Error::new(crate::UsageError(format!(
1146            "{why} The command is: {}",
1147            probe.clear_command
1148        ))));
1149    }
1150
1151    // A prompt nobody can answer is a hang, and the "pass --yes" line printed in its
1152    // place would land in the middle of the JSON document and break the parse.
1153    if json_output && !yes && !dry_run {
1154        return Err(anyhow::Error::new(crate::UsageError(
1155            "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
1156             to see what would go."
1157                .to_string(),
1158        )));
1159    }
1160
1161    // Caps are applied to the whole measurement, before the name filter: a cap is per
1162    // manager and a manager's total is the sum of its rows, so narrowing first would let
1163    // `clear cargo --over-cap` compare a cap against half a cache.
1164    let reg = registered();
1165    let mut measured = collect(!json_output, reg.as_ref());
1166    apply_caps(&mut measured, &caps());
1167
1168    // `--unused` is the only selection here that acts on a count rather than on a size,
1169    // so it refuses to run without one. An empty registry would otherwise make every
1170    // cache on the machine look unused, and this flag would agree to empty all of them.
1171    let deps = if unused {
1172        let Some(reg) = reg.as_ref() else {
1173            return Err(anyhow::Error::new(crate::UsageError(
1174                "`--unused` empties the caches no registered repository needs, and there are no \
1175                 registered repositories on this disk to check against — every cache would look \
1176                 unused. Register what you keep with `devp link` first."
1177                    .to_string(),
1178            )));
1179        };
1180        Some(dependents(reg, !json_output))
1181    } else {
1182        None
1183    };
1184    apply_dependents(&mut measured, deps.as_ref());
1185
1186    // Split before anything is printed. A plan that lists a store dev-prune is never
1187    // going to empty is a promise it cannot keep, and the JSON record of the run would
1188    // carry the same lie.
1189    let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
1190        .into_iter()
1191        .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
1192        .filter(|r| !over_cap || r.over_cap)
1193        .filter(|r| !unused || r.dependents == Some(0))
1194        .partition(|r| !matches!(r.clear, Clear::Manual { .. }));
1195
1196    if reports.is_empty() {
1197        if json_output {
1198            return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1199        }
1200        if unused {
1201            output::print_info(
1202                "Every cache on this machine is used by at least one registered repository, or \
1203                 is one dev-prune cannot attribute to any — nothing to clear.",
1204            );
1205            return Ok(());
1206        }
1207        if over_cap {
1208            // Two very different situations read the same from here — no caps set at
1209            // all, and caps set that nothing has reached — so say which one it is. The
1210            // first is a setting the user has not made yet; the second is good news.
1211            output::print_info(if caps().is_empty() {
1212                "No cache size caps are set, so nothing is over one. Set them with `devp config \
1213                 set cache_max_gb npm=10,uv=10`, or in `devp config wizard`."
1214            } else {
1215                "Every capped cache is under its cap — nothing to clear."
1216            });
1217            return Ok(());
1218        }
1219        output::print_info(&format!(
1220            "No {} cache on this machine — nothing to clear.",
1221            if all { "package manager" } else { target }
1222        ));
1223        return Ok(());
1224    }
1225
1226    if dry_run {
1227        if json_output {
1228            return json::emit(&json::caches_clear_plan_document(&reports, &kept));
1229        }
1230        print_kept(&kept);
1231        print_clear_plan(&reports, true);
1232        return Ok(());
1233    }
1234
1235    if !json_output {
1236        print_kept(&kept);
1237        print_clear_plan(&reports, false);
1238        if !confirm_clear(yes) {
1239            output::print_info("Nothing was cleared.");
1240            return Ok(());
1241        }
1242    }
1243
1244    let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
1245    // Before either output path, so both credit it. Everything above this line has
1246    // already returned — a dry run never reaches here, and neither does a
1247    // declined confirmation.
1248    record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
1249
1250    if json_output {
1251        json::emit(&json::caches_clear_document(&outcomes, &kept))?;
1252    } else {
1253        print_clear_result(&outcomes);
1254    }
1255
1256    // Reported first, then failed: the rows above are the useful part, and a caller
1257    // reading only the exit code still learns that something did not go.
1258    let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
1259    if failed > 0 {
1260        anyhow::bail!(
1261            "{failed} {} could not be cleared.",
1262            output::plural(failed, "cache", "caches")
1263        );
1264    }
1265    Ok(())
1266}
1267
1268/// The entry to explain when every cache `target` names is one dev-prune only reports.
1269///
1270/// `None` for a manager with anything clearable under it, and for a name that matches
1271/// nothing — the caller has already rejected those.
1272fn manual_only(target: &str) -> Option<&'static Probe> {
1273    let matching: Vec<&Probe> = PROBES
1274        .iter()
1275        .filter(|p| p.manager.eq_ignore_ascii_case(target))
1276        .collect();
1277    if matching.is_empty()
1278        || matching
1279            .iter()
1280            .any(|p| !matches!(p.clear, Clear::Manual { .. }))
1281    {
1282        return None;
1283    }
1284    matching.first().copied()
1285}
1286
1287/// Whether `name` is a cache manager dev-prune knows, for validating `cache_max_gb`.
1288pub fn is_cache_manager(name: &str) -> bool {
1289    PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
1290}
1291
1292/// Every manager name `clear` accepts, in report order, without repeats.
1293pub fn known_managers() -> Vec<&'static str> {
1294    let mut names: Vec<&'static str> = Vec::new();
1295    for probe in PROBES {
1296        if !names.contains(&probe.manager) {
1297            names.push(probe.manager);
1298        }
1299    }
1300    names
1301}
1302
1303/// Empty one cache, and measure what that actually gave back.
1304fn clear_one(report: &CacheReport) -> ClearOutcome {
1305    let problem = match report.clear {
1306        Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
1307        Clear::Directory => remove_cache_dir(&report.path),
1308        // `run_clear` filters these out before they reach here. Reporting the reason
1309        // rather than falling through to a delete keeps that a refactoring bug instead
1310        // of a silently emptied Maven repository.
1311        Clear::Manual { why } => Some(why.to_string()),
1312    };
1313    ClearOutcome {
1314        manager: report.manager,
1315        kind: report.kind,
1316        path: report.path.clone(),
1317        before: report.bytes,
1318        // Re-measured even after a failure: a clear that died half-way still freed
1319        // something, and calling that zero sends someone looking for space already back.
1320        after: adapters::dir_size(&report.path),
1321        problem,
1322    }
1323}
1324
1325/// Hand the cache to the manager that owns it.
1326fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
1327    if !adapters::binary_available(program) {
1328        return Some(format!(
1329            "`{program}` is not on PATH — only it knows what in this cache is still \
1330             referenced, so dev-prune will not delete the directory in its place."
1331        ));
1332    }
1333    // Whatever the row added to the printed command is added to this one too, or the
1334    // command a user was shown and the command that ran are two different commands.
1335    let mut all: Vec<&str> = args.to_vec();
1336    all.extend(extra.iter().map(String::as_str));
1337    adapters::run_command_with_timeout(
1338        program,
1339        &all,
1340        &query_dir(),
1341        std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
1342    )
1343    .err()
1344    .map(|e| format!("{e:#}"))
1345}
1346
1347/// Delete the directory, for the managers that ship no way to ask.
1348fn remove_cache_dir(path: &Path) -> Option<String> {
1349    // `remove_dir_all` is not atomic, and a machine-wide cache is exactly where an
1350    // antivirus scan or a background build is most likely to be holding a file open.
1351    // The same one retry as the prune pass, for the same reason.
1352    std::fs::remove_dir_all(path)
1353        .or_else(|_| {
1354            std::thread::sleep(std::time::Duration::from_millis(250));
1355            std::fs::remove_dir_all(path)
1356        })
1357        .err()
1358        // "Not found" on the retry means the first attempt did finish after all.
1359        .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
1360        .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
1361}
1362
1363/// Name what was left alone, and why, before naming what is about to go.
1364fn print_kept(kept: &[CacheReport]) {
1365    for r in kept {
1366        let Clear::Manual { why } = r.clear else {
1367            continue;
1368        };
1369        println!();
1370        output::print_info(&format!(
1371            "Keeping {} {} ({} at {}). {why}",
1372            r.manager,
1373            r.kind,
1374            output::format_bytes(r.bytes),
1375            output::clean_path(&r.path)
1376        ));
1377    }
1378}
1379
1380/// Name everything that is about to go, and what it costs, before any of it goes.
1381fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
1382    output::print_header(if dry_run {
1383        "Would clear"
1384    } else {
1385        "About to clear"
1386    });
1387
1388    println!();
1389    for r in reports {
1390        println!(
1391            "  {:<30} {:>10}  {}",
1392            format!("{} {}", r.manager, r.kind),
1393            output::format_bytes(r.bytes),
1394            output::clean_path(&r.path)
1395        );
1396        println!("  {:<30} {:>10}  via: {}", "", "", r.clear_command);
1397    }
1398
1399    println!();
1400    let total: u64 = reports.iter().map(|r| r.bytes).sum();
1401    println!(
1402        "  {:<30} {:>10}  across {} {}",
1403        "Total",
1404        output::format_bytes(total),
1405        reports.len(),
1406        output::plural(reports.len(), "cache", "caches")
1407    );
1408
1409    println!();
1410    output::print_info(
1411        "Nothing in a cache is lost — every manager above re-downloads what it needs. \
1412         The cost is time: the next install, and the next `devp restore`, in every \
1413         project on this machine.",
1414    );
1415}
1416
1417/// Add what was just emptied to the machine's running total, for `devp stats`.
1418///
1419/// Best-effort, and silent when it fails. The space is already back whether or not the
1420/// note about it lands, and a registry that cannot be written — a read-only
1421/// home directory, a disk that just filled — must not turn a successful
1422/// clear into a failed command.
1423fn record_cache_clear(bytes: u64) {
1424    if bytes == 0 {
1425        return;
1426    }
1427    if let Ok(mut registry) = crate::config::Registry::load() {
1428        registry.record_cache_clear(bytes);
1429        let _ = registry.save();
1430    }
1431}
1432
1433/// What actually went.
1434fn print_clear_result(outcomes: &[ClearOutcome]) {
1435    println!();
1436    for o in outcomes {
1437        let label = format!("{} {}", o.manager, o.kind);
1438        println!(
1439            "  {:<30} {:>10}  {}",
1440            label,
1441            output::format_bytes(o.freed()),
1442            if o.problem.is_some() {
1443                "not cleared"
1444            } else {
1445                "cleared"
1446            }
1447        );
1448        if let Some(why) = &o.problem {
1449            println!("  {:<30} {:>10}  {why}", "", "");
1450        }
1451    }
1452
1453    println!();
1454    let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
1455    output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
1456}
1457
1458/// Ask before anything is emptied. `--yes` answers for the user; a pipe or a script
1459/// without it gets a "no" plus the flag to pass next time.
1460fn confirm_clear(yes: bool) -> bool {
1461    use std::io::{IsTerminal, Write};
1462    if yes {
1463        return true;
1464    }
1465    if !std::io::stdin().is_terminal() {
1466        output::print_info("Not running in a terminal — pass `--yes` to clear these.");
1467        return false;
1468    }
1469    // Default no. Nothing here is unrecoverable, but it is every other project's time
1470    // being spent, and a reflexive Enter should not be what spends it. The question goes
1471    // to stderr so a piped stdout cannot eat it.
1472    eprint!("Clear them? [y/N]: ");
1473    if std::io::stderr().flush().is_err() {
1474        return false;
1475    }
1476    let mut input = String::new();
1477    if std::io::stdin().read_line(&mut input).is_err() {
1478        return false;
1479    }
1480    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485    use super::*;
1486
1487    #[test]
1488    fn every_probe_can_be_found_without_its_manager_installed() {
1489        // A probe with no query and no fallbacks is a row that can never appear, which
1490        // is a silent hole in the report rather than a test failure anywhere else.
1491        for probe in PROBES {
1492            assert!(
1493                !fallbacks(probe.manager, probe.kind).is_empty(),
1494                "{} {} has no conventional location",
1495                probe.manager,
1496                probe.kind
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn every_probe_names_the_command_that_clears_it() {
1503        for probe in PROBES {
1504            assert!(
1505                !probe.clear_command.trim().is_empty(),
1506                "{} {} reports a size with no way to act on it",
1507                probe.manager,
1508                probe.kind
1509            );
1510        }
1511    }
1512
1513    #[test]
1514    fn only_five_probed_managers_have_no_adapter_of_the_same_name() {
1515        // The report, `--unused`, SKILL.md, the CLI reference and llms.txt all state this
1516        // split in prose, and it went out wrong once already: the docs named a manager
1517        // that had since grown an adapter and omitted one that never had. Pin the five
1518        // here so the next adapter makes the claim fail rather than quietly rot.
1519        let orphans: Vec<&str> = PROBES
1520            .iter()
1521            .map(|p| p.manager)
1522            .filter(|m| !adapters::is_adapter_name(m))
1523            .collect::<std::collections::BTreeSet<_>>()
1524            .into_iter()
1525            .collect();
1526        assert_eq!(orphans, ["conan", "conda", "hex", "nuget", "pip"]);
1527    }
1528
1529    #[test]
1530    fn no_two_probes_describe_the_same_cache() {
1531        let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
1532        let count = keys.len();
1533        keys.sort_unstable();
1534        keys.dedup();
1535        assert_eq!(keys.len(), count, "two probes share a manager and kind");
1536    }
1537
1538    #[test]
1539    fn a_managers_answer_is_read_off_the_last_line() {
1540        // npm prints notices before the value it was asked for.
1541        let raw = if cfg!(windows) {
1542            "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
1543        } else {
1544            "npm warn config global deprecated\n/home/dev/.npm\n"
1545        };
1546        assert!(path_from_output(raw).is_some());
1547    }
1548
1549    #[test]
1550    fn quoted_paths_lose_their_quotes() {
1551        let raw = if cfg!(windows) {
1552            "\"C:\\Program Files\\go\\pkg\\mod\"\n"
1553        } else {
1554            "\"/opt/go path/pkg/mod\"\n"
1555        };
1556        let path = path_from_output(raw).expect("a quoted path is still a path");
1557        assert!(!path.to_string_lossy().contains('"'));
1558    }
1559
1560    #[test]
1561    fn a_non_answer_is_not_mistaken_for_a_path() {
1562        // Each of these has been an actual answer from a package manager at some point,
1563        // and treating any of them as a directory would size the wrong thing.
1564        for raw in [
1565            "",
1566            "\n \n",
1567            "undefined\n",
1568            "not a command\n",
1569            "./relative\n",
1570        ] {
1571            assert!(
1572                path_from_output(raw).is_none(),
1573                "{raw:?} was accepted as a cache path"
1574            );
1575        }
1576    }
1577
1578    #[test]
1579    fn the_cargo_rows_point_inside_the_registry() {
1580        // Both cargo rows are fallback-only — cargo has no "where is your cache" query —
1581        // so a wrong path here is a row that silently reports 0 B forever.
1582        for kind in ["registry cache", "registry sources"] {
1583            let path = fallbacks("cargo", kind).remove(0);
1584            assert!(
1585                path.starts_with(cargo_home().join("registry")),
1586                "{kind} resolved outside the cargo registry: {}",
1587                path.display()
1588            );
1589        }
1590    }
1591
1592    #[test]
1593    fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
1594        // conda keeps its package cache *inside* the installation, so a location one
1595        // component short of `pkgs` names the environments, the interpreter and every
1596        // other thing conda put there. `conda clean` would never touch those, but the
1597        // row prints the path it sized as well, and a multi-gigabyte figure next to
1598        // `~/miniconda3` is an invitation to delete the wrong directory by hand.
1599        let home = dirs::home_dir().expect("a home directory");
1600        let found = fallbacks("conda", "package cache");
1601
1602        for install in [
1603            "miniconda3",
1604            "anaconda3",
1605            "miniforge3",
1606            "mambaforge",
1607            ".conda",
1608        ] {
1609            let want = home.join(install).join("pkgs");
1610            assert!(
1611                found.contains(&want),
1612                "{} is not among conda's conventional locations",
1613                want.display()
1614            );
1615            assert!(
1616                !found.contains(&home.join(install)),
1617                "{} is the installation, not its package cache",
1618                home.join(install).display()
1619            );
1620        }
1621    }
1622
1623    #[test]
1624    fn the_report_is_ordered_by_what_is_worth_clearing() {
1625        let mut reports = [
1626            CacheReport {
1627                manager: "npm",
1628                kind: "cache",
1629                path: PathBuf::from("/a"),
1630                bytes: 10,
1631                clear_command: "x".to_string(),
1632                clear: Clear::Command("npm", &["cache"]),
1633                note: None,
1634                cap_gb: None,
1635                over_cap: false,
1636                dependents: None,
1637                extra_args: Vec::new(),
1638            },
1639            CacheReport {
1640                manager: "go",
1641                kind: "module cache",
1642                path: PathBuf::from("/b"),
1643                bytes: 4_000,
1644                clear_command: "y".to_string(),
1645                clear: Clear::Directory,
1646                note: None,
1647                cap_gb: None,
1648                over_cap: false,
1649                dependents: None,
1650                extra_args: Vec::new(),
1651            },
1652        ];
1653        reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
1654        assert_eq!(reports[0].manager, "go");
1655    }
1656
1657    #[test]
1658    fn every_probe_clears_with_the_command_it_prints() {
1659        // The table tells you what to type and `clear` types it for you. If those two
1660        // ever name different programs, one of them is lying to the user.
1661        for probe in PROBES {
1662            let printed = probe.clear_command;
1663            match probe.clear {
1664                Clear::Command(program, args) => {
1665                    assert!(
1666                        printed.starts_with(program),
1667                        "{} {} prints `{printed}` but runs `{program}`",
1668                        probe.manager,
1669                        probe.kind
1670                    );
1671                    for arg in args {
1672                        // `conan remove "*"` is quoted for a shell and unquoted for a
1673                        // spawn, which is exactly the kind of drift worth catching.
1674                        assert!(
1675                            printed.contains(arg.trim_matches('"')),
1676                            "{} {} prints `{printed}` but passes `{arg}`",
1677                            probe.manager,
1678                            probe.kind
1679                        );
1680                    }
1681                }
1682                // A manual entry is still a directory delete — it is just one the user
1683                // runs. The printed command is the whole of what they get, so it has to
1684                // be there.
1685                Clear::Directory | Clear::Manual { .. } => assert!(
1686                    printed.contains("rm -rf") || printed.contains("Remove-Item"),
1687                    "{} {} deletes a directory but prints `{printed}`",
1688                    probe.manager,
1689                    probe.kind
1690                ),
1691            }
1692        }
1693    }
1694
1695    #[test]
1696    fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
1697        // `~/.m2/repository` is an install target as well as a download cache, and the
1698        // artifacts `mvn install:install-file` puts there exist nowhere else. It is
1699        // reported and sized like everything else and deleted by nothing.
1700        let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
1701        assert!(!maven.is_empty(), "maven is no longer reported at all");
1702        for probe in maven {
1703            assert!(
1704                matches!(probe.clear, Clear::Manual { .. }),
1705                "maven {} would be emptied by dev-prune",
1706                probe.kind
1707            );
1708        }
1709    }
1710
1711    #[test]
1712    fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
1713        // `run_clear` filters these out long before here. This is the last line of
1714        // defence: if a future refactor drops that filter, the failure has to be a
1715        // reported problem and not an emptied Maven repository.
1716        let dir = tempfile::tempdir().unwrap();
1717        let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
1718        std::fs::write(&artifact, b"nowhere else").unwrap();
1719
1720        let outcome = clear_one(&CacheReport {
1721            manager: "maven",
1722            kind: "local repository",
1723            path: dir.path().to_path_buf(),
1724            bytes: 12,
1725            clear_command: MAVEN_REPO_CLEAR.to_string(),
1726            clear: Clear::Manual { why: MAVEN_MANUAL },
1727            note: None,
1728            cap_gb: None,
1729            over_cap: false,
1730            dependents: None,
1731            extra_args: Vec::new(),
1732        });
1733
1734        assert!(artifact.exists(), "the store was emptied after all");
1735        assert!(
1736            outcome.problem.is_some(),
1737            "it reported success without doing anything"
1738        );
1739    }
1740
1741    #[test]
1742    fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
1743        // The unhelpful failure this guards against is "No maven cache on this machine",
1744        // which is both untrue and no help at all.
1745        let err = run_clear("maven", false, false, true, true, false).unwrap_err();
1746        assert!(
1747            err.downcast_ref::<crate::UsageError>().is_some(),
1748            "expected a usage error, got: {err:#}"
1749        );
1750        let text = format!("{err}");
1751        assert!(
1752            text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
1753            "the refusal names neither the reason nor the command: {text}"
1754        );
1755    }
1756
1757    #[test]
1758    fn every_manager_in_the_report_can_be_named_to_clear() {
1759        let names = known_managers();
1760        for probe in PROBES {
1761            assert!(
1762                names.contains(&probe.manager),
1763                "{} is reported but `devp caches clear {}` would not find it",
1764                probe.manager,
1765                probe.manager
1766            );
1767        }
1768        // cargo, go and gradle each have two rows; naming one clears both, and offering
1769        // the name twice in the error message reads like a bug.
1770        let mut sorted = names.clone();
1771        sorted.sort_unstable();
1772        sorted.dedup();
1773        assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
1774    }
1775
1776    #[test]
1777    fn an_unknown_manager_is_a_usage_error() {
1778        // Returns before anything is measured, so this touches nothing.
1779        let err = run_clear("nonesuch", false, false, true, true, false).unwrap_err();
1780        assert!(err.downcast_ref::<crate::UsageError>().is_some());
1781    }
1782
1783    #[test]
1784    fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
1785        let err = run_clear("npm", false, false, false, false, true).unwrap_err();
1786        assert!(err.downcast_ref::<crate::UsageError>().is_some());
1787    }
1788
1789    #[test]
1790    fn removing_a_directory_reports_nothing_when_it_worked() {
1791        let dir = tempfile::tempdir().unwrap();
1792        let cache = dir.path().join("cache");
1793        std::fs::create_dir(&cache).unwrap();
1794        std::fs::write(cache.join("blob"), b"x").unwrap();
1795
1796        assert!(remove_cache_dir(&cache).is_none());
1797        assert!(!cache.exists());
1798        // Already gone is not a failure: the retry can win the race the first attempt
1799        // lost, and reporting that as an error would fail a clear that succeeded.
1800        assert!(remove_cache_dir(&cache).is_none());
1801    }
1802
1803    #[test]
1804    fn clearing_a_directory_reports_what_actually_went() {
1805        let dir = tempfile::tempdir().unwrap();
1806        let cache = dir.path().join("store");
1807        std::fs::create_dir(&cache).unwrap();
1808        std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
1809        let before = adapters::dir_size(&cache);
1810
1811        let outcome = clear_one(&CacheReport {
1812            manager: "cargo",
1813            kind: "registry cache",
1814            path: cache.clone(),
1815            bytes: before,
1816            clear_command: "rm -rf".to_string(),
1817            clear: Clear::Directory,
1818            note: None,
1819            cap_gb: None,
1820            over_cap: false,
1821            dependents: None,
1822            extra_args: Vec::new(),
1823        });
1824
1825        assert!(outcome.problem.is_none());
1826        assert_eq!(outcome.after, 0);
1827        // Measured, not assumed: `before - after`, so a partial clear reports a partial
1828        // number instead of the whole directory.
1829        assert_eq!(outcome.freed(), before);
1830        assert!(!cache.exists());
1831    }
1832
1833    #[test]
1834    fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
1835        // The one case where dev-prune declines to fall back to deleting the directory:
1836        // only the manager knows what in its store is still referenced.
1837        let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
1838        assert!(problem.is_some_and(|p| p.contains("not on PATH")));
1839    }
1840
1841    /// One row, sized in whole gibibytes so the arithmetic in these tests is readable.
1842    fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
1843        CacheReport {
1844            manager,
1845            kind,
1846            path: PathBuf::from("/cache").join(manager).join(kind),
1847            bytes: gib * crate::constants::BYTES_PER_GIB,
1848            clear_command: "x".to_string(),
1849            clear: Clear::Directory,
1850            note: None,
1851            cap_gb: None,
1852            over_cap: false,
1853            dependents: None,
1854            extra_args: Vec::new(),
1855        }
1856    }
1857
1858    /// A count for every manager named, and nothing for the rest.
1859    fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
1860        Dependents {
1861            repositories,
1862            by_manager: counts.iter().copied().collect(),
1863        }
1864    }
1865
1866    #[test]
1867    fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
1868        // `pip`, `nuget`, `conan`, `conda` and `hex` are caches dev-prune ships
1869        // no adapter for. Deciding that `venv` feeds `pip` or that `mix` feeds `hex`
1870        // would be a guess standing in for a measurement, and the guess that reads `0`
1871        // is the one that gets a cache on a machine full of Python cleared.
1872        let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
1873        apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
1874
1875        assert_eq!(reports[0].dependents, Some(2));
1876        assert_eq!(
1877            reports[1].dependents, None,
1878            "pip has no adapter of its name, so there is nothing to count"
1879        );
1880    }
1881
1882    #[test]
1883    fn no_registry_leaves_every_count_unanswered() {
1884        // The failure this exists for: an empty registry counting to zero everywhere, and
1885        // `--unused` then offering to empty every cache on the machine.
1886        let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
1887        apply_dependents(&mut reports, None);
1888        assert!(reports.iter().all(|r| r.dependents.is_none()));
1889    }
1890
1891    #[test]
1892    fn a_manager_nothing_uses_is_a_counted_zero() {
1893        // The one state `--unused` is allowed to act on, and the only thing that separates
1894        // it from the unanswered case above.
1895        let mut reports = vec![row("go", "module cache", 3)];
1896        apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
1897        assert_eq!(reports[0].dependents, Some(0));
1898        assert!(
1899            used_by(&reports[0], 0, None, &manager_totals(&reports))
1900                .contains("no registered repository uses go")
1901        );
1902    }
1903
1904    #[test]
1905    fn the_per_repository_share_is_the_managers_whole_footprint() {
1906        // Same arithmetic as the cap, for the same reason: cargo is one cache to a person
1907        // and two rows to this command, so six plus six across two repositories is 6 GiB
1908        // each and not 3.
1909        let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
1910        let line = used_by(
1911            &reports[0],
1912            2,
1913            Some(&counted(2, &[("cargo", 2)])),
1914            &manager_totals(&reports),
1915        );
1916        assert!(
1917            line.contains("cargo is used by 2 of 2 registered repositories")
1918                && line.contains("6 GiB"),
1919            "{line}"
1920        );
1921    }
1922
1923    #[test]
1924    fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
1925        // Whatever a filesystem's root turns out to be on this platform, a path can only
1926        // ever sit underneath its own. A root that is not an ancestor would send the
1927        // `.pnpm-store` probe at some unrelated directory.
1928        let dir = tempfile::tempdir().unwrap();
1929        let nested = dir.path().join("a").join("b");
1930        std::fs::create_dir_all(&nested).unwrap();
1931
1932        let root = volume_root(&nested).expect("a real directory sits on some filesystem");
1933        assert!(
1934            nested.starts_with(&root),
1935            "{} is not under {}",
1936            nested.display(),
1937            root.display()
1938        );
1939        assert!(root.is_dir(), "{} is not a directory", root.display());
1940    }
1941
1942    #[cfg(windows)]
1943    #[test]
1944    fn a_windows_volume_root_is_the_drive_and_nothing_more() {
1945        // `V:\`, not `V:` and not `V:\Code`. The store this feeds is at the root of the
1946        // drive, so an answer one component too deep finds nothing and an answer with no
1947        // separator names the *current* directory on that drive instead of its root.
1948        let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
1949        assert_eq!(root, PathBuf::from("V:\\"));
1950        assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
1951    }
1952
1953    #[test]
1954    fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
1955        // Forty-six repositories on one drive is one store to look for, not forty-six
1956        // identical rows.
1957        let dir = tempfile::tempdir().unwrap();
1958        let a = dir.path().join("one");
1959        let b = dir.path().join("two");
1960        std::fs::create_dir_all(&a).unwrap();
1961        std::fs::create_dir_all(&b).unwrap();
1962
1963        assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
1964        assert!(volume_roots(&[]).is_empty());
1965    }
1966
1967    #[test]
1968    fn a_volume_stores_printed_command_is_the_one_that_runs() {
1969        // The reason this row exists at all is that `pnpm store prune` on its own prunes
1970        // the store for the filesystem it is run on, which is not this one. Printing a
1971        // command that names the store and running one that does not would be worse than
1972        // never reporting it.
1973        let dir = tempfile::tempdir().unwrap();
1974        let store = dir.path().join(".pnpm-store");
1975        std::fs::create_dir_all(&store).unwrap();
1976
1977        let report = volume_store_report(store.clone());
1978        let named = output::clean_path(&store);
1979        assert_eq!(
1980            report.extra_args,
1981            vec!["--store-dir".to_string(), named.clone()]
1982        );
1983        assert!(
1984            report.clear_command.contains(&named),
1985            "the printed command does not name the store: {}",
1986            report.clear_command
1987        );
1988        assert!(matches!(
1989            report.clear,
1990            Clear::Command("pnpm", ["store", "prune"])
1991        ));
1992    }
1993
1994    #[test]
1995    fn only_a_path_with_a_space_in_it_is_quoted() {
1996        // The quoting is for the human reading the line. dev-prune passes the path as one
1997        // argument and never hands it to a shell, so quoting everything would print a
1998        // command that differs from the one that ran for no reason at all.
1999        assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
2000        assert_eq!(
2001            shell_arg("/mnt/my data/.pnpm-store"),
2002            "\"/mnt/my data/.pnpm-store\""
2003        );
2004    }
2005
2006    #[test]
2007    fn a_cap_is_measured_against_the_managers_whole_footprint() {
2008        // cargo keeps a registry cache and an unpacked source tree, and "cargo is over
2009        // ten gigabytes" is a statement about the pair. Six plus six clears a cap of ten
2010        // that neither row reaches on its own.
2011        let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
2012        apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
2013        assert!(
2014            reports.iter().all(|r| r.over_cap),
2015            "both rows belong to the manager that went over"
2016        );
2017        assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
2018    }
2019
2020    #[test]
2021    fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
2022        let mut reports = vec![row("npm", "cache", 3)];
2023        apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2024        // The cap is still reported, because "capped and fine" is worth seeing — it is
2025        // the difference between a setting that is working and one nobody made.
2026        assert_eq!(reports[0].cap_gb, Some(10));
2027        assert!(!reports[0].over_cap);
2028    }
2029
2030    #[test]
2031    fn a_manager_with_no_cap_is_never_called_too_big() {
2032        // The default is an empty map, and an empty map has to mean "no opinion" rather
2033        // than "zero", or every cache on the machine would report as over-size.
2034        let mut reports = vec![row("uv", "cache", 40)];
2035        apply_caps(&mut reports, &BTreeMap::new());
2036        assert_eq!(reports[0].cap_gb, None);
2037        assert!(!reports[0].over_cap);
2038    }
2039
2040    #[test]
2041    fn one_managers_cap_says_nothing_about_another() {
2042        let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
2043        apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
2044        assert!(reports[0].over_cap);
2045        assert!(
2046            !reports[1].over_cap,
2047            "go has no cap and did not acquire npm's"
2048        );
2049    }
2050
2051    #[test]
2052    fn exactly_at_the_cap_is_not_over_it() {
2053        // A cap of ten means ten is allowed. Off by one here would mark a cache the
2054        // moment it hit the number the user chose as acceptable.
2055        let mut reports = vec![row("pnpm", "store", 10)];
2056        apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
2057        assert!(!reports[0].over_cap);
2058    }
2059
2060    #[test]
2061    fn every_cache_manager_answers_to_its_own_name() {
2062        // `cache_max_gb` is validated against this, so a probe the check does not know
2063        // would be a manager `devp caches clear` accepts and `devp config set` rejects.
2064        for probe in PROBES {
2065            assert!(
2066                is_cache_manager(probe.manager),
2067                "{} is reported but cannot be capped",
2068                probe.manager
2069            );
2070        }
2071        assert!(!is_cache_manager("dev-prune-no-such-manager"));
2072    }
2073}