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