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. Only the managers that ship no such subcommand —
29// cargo, maven, gradle, vcpkg — are cleared by removing the directory, and the path
30// removed is the one this command resolved and sized, never a string handed to a shell.
31//
32// Each manager is asked where its own cache lives rather than being assumed — a
33// `CARGO_HOME`, a `--cache-dir`, a corporate `.npmrc` all move it. Every one of those
34// queries is read-only, and a manager that is not installed falls back to the
35// conventional location, so a cache left behind by an uninstalled manager still shows up.
36
37use std::collections::HashSet;
38use std::path::{Path, PathBuf};
39
40use anyhow::Result;
41
42use crate::adapters;
43use crate::constants;
44use crate::json;
45use crate::output;
46
47/// One cache directory that exists on this machine.
48pub struct CacheReport {
49    /// The package manager that owns it.
50    pub manager: &'static str,
51    /// Which of that manager's caches this is, when it keeps more than one.
52    pub kind: &'static str,
53    /// Where it actually is, as resolved on this machine.
54    pub path: PathBuf,
55    /// Total size on disk.
56    pub bytes: u64,
57    /// The command that empties it, as a human would type it.
58    pub clear_command: &'static str,
59    /// How `devp caches clear` empties it.
60    pub clear: Clear,
61    /// What the user gives up by running that command, when it is more than time.
62    pub note: Option<&'static str>,
63}
64
65/// How one cache is emptied.
66#[derive(Clone, Copy)]
67pub enum Clear {
68    /// The manager's own subcommand, as `(program, args)`. Preferred wherever one
69    /// exists — `pnpm store prune` and `uv cache prune` keep what is still referenced,
70    /// which no directory delete can work out.
71    Command(&'static str, &'static [&'static str]),
72    /// Delete the directory this command resolved and sized. Only for the managers that
73    /// ship nothing equivalent.
74    Directory,
75}
76
77/// How to find one cache.
78struct Probe {
79    manager: &'static str,
80    kind: &'static str,
81    /// The manager's own answer to "where is it?", as `(program, args)`.
82    ///
83    /// All of these print a path and exit; none of them writes anything or creates the
84    /// directory. `None` means the ecosystem has no such query and only the conventional
85    /// locations are available.
86    query: Option<(&'static str, &'static [&'static str])>,
87    clear_command: &'static str,
88    clear: Clear,
89    note: Option<&'static str>,
90}
91
92/// cargo ships no cache subcommand, so the only honest "how do I clear this" is the
93/// deletion itself. `cargo build` re-downloads and re-extracts what it needs.
94#[cfg(windows)]
95const CARGO_CACHE_CLEAR: &str =
96    r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
97#[cfg(not(windows))]
98const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
99
100#[cfg(windows)]
101const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
102#[cfg(not(windows))]
103const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
104
105/// Maven has no cache subcommand either — `mvn dependency:purge-local-repository`
106/// exists, but it needs a project to run in and re-resolves as it purges, which is not
107/// "clear the cache". The honest command is the deletion.
108#[cfg(windows)]
109const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
110#[cfg(not(windows))]
111const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
112
113#[cfg(windows)]
114const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
115#[cfg(not(windows))]
116const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
117
118#[cfg(windows)]
119const GRADLE_DISTS_CLEAR: &str =
120    r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
121#[cfg(not(windows))]
122const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
123
124#[cfg(windows)]
125const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
126#[cfg(not(windows))]
127const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
128
129/// Hex has no cache-clearing task. hexpm/hex#344 asked for one and there still is not
130/// one, so the honest command is the deletion; `mix deps.get` re-fetches the tarballs.
131#[cfg(windows)]
132const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
133#[cfg(not(windows))]
134const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
135
136const PROBES: &[Probe] = &[
137    Probe {
138        manager: "npm",
139        kind: "cache",
140        query: Some(("npm", &["config", "get", "cache"])),
141        clear_command: "npm cache clean --force",
142        clear: Clear::Command("npm", &["cache", "clean", "--force"]),
143        note: None,
144    },
145    Probe {
146        manager: "pnpm",
147        kind: "store",
148        query: Some(("pnpm", &["store", "path"])),
149        clear_command: "pnpm store prune",
150        clear: Clear::Command("pnpm", &["store", "prune"]),
151        note: Some(
152            "hardlinked into every node_modules on the machine; emptying it is what makes \
153             the next pnpm install a download",
154        ),
155    },
156    Probe {
157        manager: "yarn",
158        kind: "cache",
159        query: Some(("yarn", &["cache", "dir"])),
160        clear_command: "yarn cache clean",
161        clear: Clear::Command("yarn", &["cache", "clean"]),
162        note: None,
163    },
164    Probe {
165        manager: "bun",
166        kind: "cache",
167        query: Some(("bun", &["pm", "cache"])),
168        clear_command: "bun pm cache rm",
169        clear: Clear::Command("bun", &["pm", "cache", "rm"]),
170        note: None,
171    },
172    Probe {
173        manager: "uv",
174        kind: "cache",
175        query: Some(("uv", &["cache", "dir"])),
176        // `prune` drops what nothing can use again and keeps the rest; `uv cache clean`
177        // is the sledgehammer, and is not what most people mean by "clear the cache".
178        clear_command: "uv cache prune",
179        clear: Clear::Command("uv", &["cache", "prune"]),
180        note: None,
181    },
182    Probe {
183        manager: "pip",
184        kind: "cache",
185        query: Some(("pip", &["cache", "dir"])),
186        clear_command: "pip cache purge",
187        clear: Clear::Command("pip", &["cache", "purge"]),
188        note: None,
189    },
190    Probe {
191        manager: "cargo",
192        kind: "registry cache",
193        query: None,
194        clear_command: CARGO_CACHE_CLEAR,
195        clear: Clear::Directory,
196        note: Some("the downloaded .crate archives; clearing them means downloading again"),
197    },
198    Probe {
199        manager: "cargo",
200        kind: "registry sources",
201        query: None,
202        clear_command: CARGO_SRC_CLEAR,
203        clear: Clear::Directory,
204        note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
205    },
206    Probe {
207        manager: "go",
208        kind: "module cache",
209        query: Some(("go", &["env", "GOMODCACHE"])),
210        clear_command: "go clean -modcache",
211        clear: Clear::Command("go", &["clean", "-modcache"]),
212        note: None,
213    },
214    Probe {
215        manager: "go",
216        kind: "build cache",
217        query: Some(("go", &["env", "GOCACHE"])),
218        clear_command: "go clean -cache",
219        clear: Clear::Command("go", &["clean", "-cache"]),
220        note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
221    },
222    // `mvn help:evaluate -Dexpression=settings.localRepository` would answer precisely,
223    // but it boots a JVM, resolves the help plugin over the network on first use, and
224    // takes several seconds — the wrong trade for a read-only size report. A relocated
225    // repository (settings.xml `<localRepository>`) is rare enough to miss.
226    Probe {
227        manager: "maven",
228        kind: "local repository",
229        query: None,
230        clear_command: MAVEN_REPO_CLEAR,
231        clear: Clear::Directory,
232        note: Some(
233            "every Maven build on the machine resolves from here; the next build re-downloads what it needs",
234        ),
235    },
236    Probe {
237        manager: "gradle",
238        kind: "caches",
239        query: None,
240        clear_command: GRADLE_CACHE_CLEAR,
241        clear: Clear::Directory,
242        note: Some(
243            "downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
244        ),
245    },
246    Probe {
247        manager: "gradle",
248        kind: "wrapper distributions",
249        query: None,
250        clear_command: GRADLE_DISTS_CLEAR,
251        clear: Clear::Directory,
252        note: Some(
253            "one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
254        ),
255    },
256    // `dotnet nuget locals global-packages --list` answers `global-packages: <path>` —
257    // a labelled line, not a bare path — so the conventional locations are simpler and
258    // just as reliable. The clear command, however, is nuget's own.
259    Probe {
260        manager: "nuget",
261        kind: "global packages",
262        query: None,
263        clear_command: "dotnet nuget locals global-packages --clear",
264        clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
265        note: Some(
266            "every .NET project on the machine restores from here; re-downloaded on the next restore",
267        ),
268    },
269    Probe {
270        manager: "vcpkg",
271        kind: "binary cache",
272        query: None,
273        clear_command: VCPKG_ARCHIVES_CLEAR,
274        clear: Clear::Directory,
275        note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
276    },
277    Probe {
278        manager: "conan",
279        kind: "package cache",
280        query: None,
281        clear_command: "conan remove \"*\" --confirm",
282        clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
283        note: Some(
284            "recipes and binaries shared by every Conan project; re-fetched on the next install",
285        ),
286    },
287    // Composer will say where its cache is, and asking is the only way to get it right:
288    // the directory moves with `COMPOSER_HOME`, with `COMPOSER_CACHE_DIR`, and with a
289    // `cache-dir` written into the global config, and the default differs on all three
290    // platforms. That is four ways to be wrong and one command that is not.
291    Probe {
292        manager: "composer",
293        kind: "cache",
294        query: Some(("composer", &["config", "--global", "cache-dir"])),
295        clear_command: "composer clear-cache",
296        clear: Clear::Command("composer", &["clear-cache"]),
297        note: Some(
298            "downloaded package archives and repository metadata; re-fetched by the next composer install",
299        ),
300    },
301    // CocoaPods ships no command that prints the cache directory — `pod cache list`
302    // prints its *contents* — so this row is the conventional location plus the
303    // relocation variable. Emptying it is still CocoaPods' own job: the cache is keyed by
304    // pod name and version and it keeps an index of what is in there.
305    Probe {
306        manager: "cocoapods",
307        kind: "cache",
308        query: None,
309        clear_command: "pod cache clean --all",
310        clear: Clear::Command("pod", &["cache", "clean", "--all"]),
311        note: Some("downloaded pod sources, re-fetched by the next pod install"),
312    },
313    Probe {
314        manager: "hex",
315        kind: "package cache",
316        query: None,
317        clear_command: HEX_CACHE_CLEAR,
318        clear: Clear::Directory,
319        note: Some(
320            "package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
321        ),
322    },
323];
324
325/// Run the `caches` command.
326pub fn run(json_output: bool) -> Result<()> {
327    let reports = collect(!json_output);
328
329    if json_output {
330        return json::emit(&json::caches_document(&reports));
331    }
332
333    print_report(&reports);
334    Ok(())
335}
336
337/// Find and size every cache on this machine, largest first.
338fn collect(spinner: bool) -> Vec<CacheReport> {
339    let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
340    let from = query_dir();
341
342    let mut seen: HashSet<PathBuf> = HashSet::new();
343    let mut reports = Vec::new();
344
345    for probe in PROBES {
346        let Some(path) = locate(probe, &from) else {
347            continue;
348        };
349        // Canonical, because two probes can land on the same directory — `GOCACHE` and
350        // `GOMODCACHE` are both under `~/.cache` on Linux, and a machine can be
351        // configured to share them. Counting one twice would inflate the total, which is
352        // the one number this command exists to get right. It also settles the spelling:
353        // a manager answers in whatever case and separators it likes, and two rows
354        // disagreeing about how to write `C:\Users` reads like a bug.
355        let path = path.canonicalize().unwrap_or(path);
356        if !seen.insert(path.clone()) {
357            continue;
358        }
359        reports.push(CacheReport {
360            manager: probe.manager,
361            kind: probe.kind,
362            bytes: adapters::dir_size(&path),
363            path,
364            clear_command: probe.clear_command,
365            clear: probe.clear,
366            note: probe.note,
367        });
368    }
369
370    if let Some(pb) = pb {
371        pb.finish_and_clear();
372    }
373
374    reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
375    reports
376}
377
378/// Where to run the "where is your cache?" queries from.
379///
380/// The home directory, not the current one. A project's `.npmrc` or `.cargo/config.toml`
381/// can move the cache for that project alone, and answering with it would report a
382/// directory that is not the machine's actual cache. Falling back to the current
383/// directory is only for the case where there is no home directory at all.
384fn query_dir() -> PathBuf {
385    dirs::home_dir()
386        .or_else(|| std::env::current_dir().ok())
387        .unwrap_or_else(|| PathBuf::from("."))
388}
389
390/// Resolve one probe to a directory that exists, or nothing.
391fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
392    if let Some((program, args)) = probe.query
393        && adapters::binary_available(program)
394    {
395        let answered = adapters::capture_command_with_timeout(
396            program,
397            args,
398            from,
399            std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
400        )
401        .ok()
402        .and_then(|raw| path_from_output(&raw))
403        .filter(|p| p.is_dir());
404        if answered.is_some() {
405            return answered;
406        }
407    }
408
409    // Either the manager is not installed, or it is and its cache has never been
410    // populated. The conventional location is still worth checking: an uninstalled
411    // manager leaves its cache behind, and that is exactly the multi-gigabyte directory
412    // nobody remembers.
413    fallbacks(probe.manager, probe.kind)
414        .into_iter()
415        .find(|p| p.is_dir())
416}
417
418/// Read a path out of a manager's answer.
419///
420/// The last non-empty line, because some managers print a notice first, and quotes are
421/// stripped because `go env` quotes paths containing spaces on Windows.
422fn path_from_output(raw: &str) -> Option<PathBuf> {
423    let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
424    let line = line.trim_matches('"');
425    // npm answers `undefined` for a config key it does not have, and a manager that
426    // errored can print anything at all. A relative path is never a machine-wide cache.
427    if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
428        return None;
429    }
430    Some(PathBuf::from(line))
431}
432
433/// Conventional locations for a cache, most likely first.
434fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
435    let home = dirs::home_dir();
436    let local = dirs::data_local_dir();
437    let cache = dirs::cache_dir();
438    // `rel` is split rather than joined whole so a Windows path never comes out as
439    // `C:\Users\dev\go\pkg/mod`. `Path::join` accepts the forward slashes, it just keeps
440    // them, and a report that spells the same drive two ways reads like a bug.
441    let under = |base: &Option<PathBuf>, rel: &str| {
442        base.as_ref()
443            .map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
444    };
445
446    let candidates = match (manager, kind) {
447        // `npm config get cache` answers `~/.npm` on Unix and `%LocalAppData%\npm-cache`
448        // on Windows; the payload lives in `_cacache` underneath either one.
449        ("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
450        ("pnpm", _) => vec![
451            under(&local, "pnpm/store"),
452            under(&home, ".local/share/pnpm/store"),
453            under(&home, "Library/pnpm/store"),
454            under(&home, ".pnpm-store"),
455        ],
456        ("yarn", _) => vec![
457            under(&home, ".yarn/berry/cache"),
458            under(&local, "Yarn/Cache"),
459            under(&cache, "yarn"),
460        ],
461        ("bun", _) => vec![under(&home, ".bun/install/cache")],
462        ("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
463        ("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
464        ("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
465        ("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
466        ("go", "module cache") => vec![
467            std::env::var_os("GOMODCACHE").map(PathBuf::from),
468            std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
469            under(&home, "go/pkg/mod"),
470        ],
471        ("go", _) => vec![
472            std::env::var_os("GOCACHE").map(PathBuf::from),
473            under(&cache, "go-build"),
474            under(&local, "go-build"),
475        ],
476        ("maven", _) => vec![under(&home, ".m2/repository")],
477        // GRADLE_USER_HOME relocates the whole ~/.gradle tree, caches and wrapper both.
478        ("gradle", "caches") => vec![
479            std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
480            under(&home, ".gradle/caches"),
481        ],
482        ("gradle", _) => vec![
483            std::env::var_os("GRADLE_USER_HOME")
484                .map(|p| PathBuf::from(p).join("wrapper").join("dists")),
485            under(&home, ".gradle/wrapper/dists"),
486        ],
487        ("nuget", _) => vec![
488            std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
489            under(&home, ".nuget/packages"),
490        ],
491        ("vcpkg", _) => vec![
492            std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
493            under(&local, "vcpkg/archives"),
494            under(&cache, "vcpkg/archives"),
495        ],
496        // Conan 2 keeps packages under <CONAN_HOME>/p; pointing at `p` rather than the
497        // whole home keeps profiles and remotes out of the size (and out of harm's way).
498        ("conan", _) => vec![
499            std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
500            under(&home, ".conan2/p"),
501        ],
502        // Only reached when `composer` is not installed, which is the case worth
503        // covering: the cache a PHP toolchain left behind is the one nobody remembers.
504        ("composer", _) => vec![
505            std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
506            std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
507            under(&local, "Composer"),
508            under(&cache, "composer"),
509            under(&home, ".composer/cache"),
510        ],
511        // CocoaPods puts the cache under `~/Library/Caches` by name rather than through
512        // the platform's cache directory, so this is `home` and not `cache` even on the
513        // one platform where the two would agree.
514        ("cocoapods", _) => vec![
515            std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
516            under(&home, "Library/Caches/CocoaPods"),
517        ],
518        // HEX_HOME moves the whole `.hex` tree; MIX_XDG puts it under the platform cache
519        // directory instead. Both are checked because either can be set alone.
520        ("hex", _) => vec![
521            std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
522            under(&home, ".hex/packages"),
523            under(&cache, "hex/packages"),
524        ],
525        _ => vec![],
526    };
527
528    candidates.into_iter().flatten().collect()
529}
530
531/// `CARGO_HOME`, or the default cargo puts it in.
532fn cargo_home() -> PathBuf {
533    std::env::var_os("CARGO_HOME")
534        .map(PathBuf::from)
535        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
536        .unwrap_or_else(|| PathBuf::from(".cargo"))
537}
538
539fn print_report(reports: &[CacheReport]) {
540    output::print_header("Package manager caches");
541
542    if reports.is_empty() {
543        println!();
544        output::print_info("No package manager caches found on this machine.");
545        return;
546    }
547
548    println!();
549    for r in reports {
550        let label = format!("{} {}", r.manager, r.kind);
551        println!(
552            "  {:<30} {:>10}  {}",
553            label,
554            output::format_bytes(r.bytes),
555            output::clean_path(&r.path)
556        );
557        println!("  {:<30} {:>10}  clear: {}", "", "", r.clear_command);
558        if let Some(note) = r.note {
559            println!("  {:<30} {:>10}  {}", "", "", note);
560        }
561        println!();
562    }
563
564    let total: u64 = reports.iter().map(|r| r.bytes).sum();
565    println!(
566        "  {:<30} {:>10}  across {} {}",
567        "Total",
568        output::format_bytes(total),
569        reports.len(),
570        output::plural(reports.len(), "cache", "caches")
571    );
572
573    println!();
574    output::print_info(
575        "Nothing above was deleted. A cache is shared by every project on the machine, so \
576         no single repository's lockfile can prove it is recoverable — and it is what \
577         makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
578         will ever touch one. When you want the space more than the speed, run a clear \
579         command yourself, or `devp caches clear <manager>`.",
580    );
581}
582
583/// What happened to one cache.
584pub struct ClearOutcome {
585    /// The package manager that owned it.
586    pub manager: &'static str,
587    /// Which of that manager's caches this was.
588    pub kind: &'static str,
589    /// Where it is.
590    pub path: PathBuf,
591    /// Size before, as this command measured it.
592    pub before: u64,
593    /// Size after, measured again rather than assumed. `pnpm store prune` and `uv cache
594    /// prune` deliberately keep what is still referenced, so subtracting is the only
595    /// honest way to say what actually went.
596    pub after: u64,
597    /// `None` when it worked; otherwise why it did not, phrased for a human.
598    pub problem: Option<String>,
599}
600
601impl ClearOutcome {
602    /// Bytes given back to the disk.
603    pub fn freed(&self) -> u64 {
604        self.before.saturating_sub(self.after)
605    }
606}
607
608/// Run `dev-prune caches clear <target>`.
609///
610/// `target` is a manager name or `all`. Everything about to be emptied is named and
611/// sized first, and unless `--yes` answers for the user, it asks.
612pub fn run_clear(target: &str, yes: bool, dry_run: bool, json_output: bool) -> Result<()> {
613    let all = target.eq_ignore_ascii_case("all");
614    if !all
615        && !PROBES
616            .iter()
617            .any(|p| p.manager.eq_ignore_ascii_case(target))
618    {
619        return Err(anyhow::Error::new(crate::UsageError(format!(
620            "`{target}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
621            known_managers().join(", ")
622        ))));
623    }
624    // A prompt nobody can answer is a hang, and the "pass --yes" line printed in its
625    // place would land in the middle of the JSON document and break the parse.
626    if json_output && !yes && !dry_run {
627        return Err(anyhow::Error::new(crate::UsageError(
628            "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
629             to see what would go."
630                .to_string(),
631        )));
632    }
633
634    let reports: Vec<CacheReport> = collect(!json_output)
635        .into_iter()
636        .filter(|r| all || r.manager.eq_ignore_ascii_case(target))
637        .collect();
638
639    if reports.is_empty() {
640        if json_output {
641            return json::emit(&json::caches_clear_plan_document(&reports));
642        }
643        output::print_info(&format!(
644            "No {} cache on this machine — nothing to clear.",
645            if all { "package manager" } else { target }
646        ));
647        return Ok(());
648    }
649
650    if dry_run {
651        if json_output {
652            return json::emit(&json::caches_clear_plan_document(&reports));
653        }
654        print_clear_plan(&reports, true);
655        return Ok(());
656    }
657
658    if !json_output {
659        print_clear_plan(&reports, false);
660        if !confirm_clear(yes) {
661            output::print_info("Nothing was cleared.");
662            return Ok(());
663        }
664    }
665
666    let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
667
668    if json_output {
669        json::emit(&json::caches_clear_document(&outcomes))?;
670    } else {
671        print_clear_result(&outcomes);
672    }
673
674    // Reported first, then failed: the rows above are the useful part, and a caller
675    // reading only the exit code still learns that something did not go.
676    let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
677    if failed > 0 {
678        anyhow::bail!(
679            "{failed} {} could not be cleared.",
680            output::plural(failed, "cache", "caches")
681        );
682    }
683    Ok(())
684}
685
686/// Every manager name `clear` accepts, in report order, without repeats.
687fn known_managers() -> Vec<&'static str> {
688    let mut names: Vec<&'static str> = Vec::new();
689    for probe in PROBES {
690        if !names.contains(&probe.manager) {
691            names.push(probe.manager);
692        }
693    }
694    names
695}
696
697/// Empty one cache, and measure what that actually gave back.
698fn clear_one(report: &CacheReport) -> ClearOutcome {
699    let problem = match report.clear {
700        Clear::Command(program, args) => run_clear_command(program, args),
701        Clear::Directory => remove_cache_dir(&report.path),
702    };
703    ClearOutcome {
704        manager: report.manager,
705        kind: report.kind,
706        path: report.path.clone(),
707        before: report.bytes,
708        // Re-measured even after a failure: a clear that died half-way still freed
709        // something, and calling that zero sends someone looking for space already back.
710        after: adapters::dir_size(&report.path),
711        problem,
712    }
713}
714
715/// Hand the cache to the manager that owns it.
716fn run_clear_command(program: &str, args: &[&str]) -> Option<String> {
717    if !adapters::binary_available(program) {
718        return Some(format!(
719            "`{program}` is not on PATH — only it knows what in this cache is still \
720             referenced, so dev-prune will not delete the directory in its place."
721        ));
722    }
723    adapters::run_command_with_timeout(
724        program,
725        args,
726        &query_dir(),
727        std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
728    )
729    .err()
730    .map(|e| format!("{e:#}"))
731}
732
733/// Delete the directory, for the managers that ship no way to ask.
734fn remove_cache_dir(path: &Path) -> Option<String> {
735    // `remove_dir_all` is not atomic, and a machine-wide cache is exactly where an
736    // antivirus scan or a background build is most likely to be holding a file open.
737    // The same one retry as the prune pass, for the same reason.
738    std::fs::remove_dir_all(path)
739        .or_else(|_| {
740            std::thread::sleep(std::time::Duration::from_millis(250));
741            std::fs::remove_dir_all(path)
742        })
743        .err()
744        // "Not found" on the retry means the first attempt did finish after all.
745        .filter(|e| e.kind() != std::io::ErrorKind::NotFound)
746        .map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
747}
748
749/// Name everything that is about to go, and what it costs, before any of it goes.
750fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
751    output::print_header(if dry_run {
752        "Would clear"
753    } else {
754        "About to clear"
755    });
756
757    println!();
758    for r in reports {
759        println!(
760            "  {:<30} {:>10}  {}",
761            format!("{} {}", r.manager, r.kind),
762            output::format_bytes(r.bytes),
763            output::clean_path(&r.path)
764        );
765        println!("  {:<30} {:>10}  via: {}", "", "", r.clear_command);
766    }
767
768    println!();
769    let total: u64 = reports.iter().map(|r| r.bytes).sum();
770    println!(
771        "  {:<30} {:>10}  across {} {}",
772        "Total",
773        output::format_bytes(total),
774        reports.len(),
775        output::plural(reports.len(), "cache", "caches")
776    );
777
778    println!();
779    output::print_info(
780        "Nothing in a cache is lost — every manager above re-downloads what it needs. \
781         The cost is time: the next install, and the next `devp restore`, in every \
782         project on this machine.",
783    );
784}
785
786/// What actually went.
787fn print_clear_result(outcomes: &[ClearOutcome]) {
788    println!();
789    for o in outcomes {
790        let label = format!("{} {}", o.manager, o.kind);
791        println!(
792            "  {:<30} {:>10}  {}",
793            label,
794            output::format_bytes(o.freed()),
795            if o.problem.is_some() {
796                "not cleared"
797            } else {
798                "cleared"
799            }
800        );
801        if let Some(why) = &o.problem {
802            println!("  {:<30} {:>10}  {why}", "", "");
803        }
804    }
805
806    println!();
807    let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
808    output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
809}
810
811/// Ask before anything is emptied. `--yes` answers for the user; a pipe or a script
812/// without it gets a "no" plus the flag to pass next time.
813fn confirm_clear(yes: bool) -> bool {
814    use std::io::{IsTerminal, Write};
815    if yes {
816        return true;
817    }
818    if !std::io::stdin().is_terminal() {
819        output::print_info("Not running in a terminal — pass `--yes` to clear these.");
820        return false;
821    }
822    // Default no. Nothing here is unrecoverable, but it is every other project's time
823    // being spent, and a reflexive Enter should not be what spends it. The question goes
824    // to stderr so a piped stdout cannot eat it.
825    eprint!("Clear them? [y/N]: ");
826    if std::io::stderr().flush().is_err() {
827        return false;
828    }
829    let mut input = String::new();
830    if std::io::stdin().read_line(&mut input).is_err() {
831        return false;
832    }
833    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[test]
841    fn every_probe_can_be_found_without_its_manager_installed() {
842        // A probe with no query and no fallbacks is a row that can never appear, which
843        // is a silent hole in the report rather than a test failure anywhere else.
844        for probe in PROBES {
845            assert!(
846                !fallbacks(probe.manager, probe.kind).is_empty(),
847                "{} {} has no conventional location",
848                probe.manager,
849                probe.kind
850            );
851        }
852    }
853
854    #[test]
855    fn every_probe_names_the_command_that_clears_it() {
856        for probe in PROBES {
857            assert!(
858                !probe.clear_command.trim().is_empty(),
859                "{} {} reports a size with no way to act on it",
860                probe.manager,
861                probe.kind
862            );
863        }
864    }
865
866    #[test]
867    fn no_two_probes_describe_the_same_cache() {
868        let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
869        let count = keys.len();
870        keys.sort_unstable();
871        keys.dedup();
872        assert_eq!(keys.len(), count, "two probes share a manager and kind");
873    }
874
875    #[test]
876    fn a_managers_answer_is_read_off_the_last_line() {
877        // npm prints notices before the value it was asked for.
878        let raw = if cfg!(windows) {
879            "npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
880        } else {
881            "npm warn config global deprecated\n/home/dev/.npm\n"
882        };
883        assert!(path_from_output(raw).is_some());
884    }
885
886    #[test]
887    fn quoted_paths_lose_their_quotes() {
888        let raw = if cfg!(windows) {
889            "\"C:\\Program Files\\go\\pkg\\mod\"\n"
890        } else {
891            "\"/opt/go path/pkg/mod\"\n"
892        };
893        let path = path_from_output(raw).expect("a quoted path is still a path");
894        assert!(!path.to_string_lossy().contains('"'));
895    }
896
897    #[test]
898    fn a_non_answer_is_not_mistaken_for_a_path() {
899        // Each of these has been an actual answer from a package manager at some point,
900        // and treating any of them as a directory would size the wrong thing.
901        for raw in [
902            "",
903            "\n \n",
904            "undefined\n",
905            "not a command\n",
906            "./relative\n",
907        ] {
908            assert!(
909                path_from_output(raw).is_none(),
910                "{raw:?} was accepted as a cache path"
911            );
912        }
913    }
914
915    #[test]
916    fn the_cargo_rows_point_inside_the_registry() {
917        // Both cargo rows are fallback-only — cargo has no "where is your cache" query —
918        // so a wrong path here is a row that silently reports 0 B forever.
919        for kind in ["registry cache", "registry sources"] {
920            let path = fallbacks("cargo", kind).remove(0);
921            assert!(
922                path.starts_with(cargo_home().join("registry")),
923                "{kind} resolved outside the cargo registry: {}",
924                path.display()
925            );
926        }
927    }
928
929    #[test]
930    fn the_report_is_ordered_by_what_is_worth_clearing() {
931        let mut reports = [
932            CacheReport {
933                manager: "npm",
934                kind: "cache",
935                path: PathBuf::from("/a"),
936                bytes: 10,
937                clear_command: "x",
938                clear: Clear::Command("npm", &["cache"]),
939                note: None,
940            },
941            CacheReport {
942                manager: "go",
943                kind: "module cache",
944                path: PathBuf::from("/b"),
945                bytes: 4_000,
946                clear_command: "y",
947                clear: Clear::Directory,
948                note: None,
949            },
950        ];
951        reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
952        assert_eq!(reports[0].manager, "go");
953    }
954
955    #[test]
956    fn every_probe_clears_with_the_command_it_prints() {
957        // The table tells you what to type and `clear` types it for you. If those two
958        // ever name different programs, one of them is lying to the user.
959        for probe in PROBES {
960            let printed = probe.clear_command;
961            match probe.clear {
962                Clear::Command(program, args) => {
963                    assert!(
964                        printed.starts_with(program),
965                        "{} {} prints `{printed}` but runs `{program}`",
966                        probe.manager,
967                        probe.kind
968                    );
969                    for arg in args {
970                        // `conan remove "*"` is quoted for a shell and unquoted for a
971                        // spawn, which is exactly the kind of drift worth catching.
972                        assert!(
973                            printed.contains(arg.trim_matches('"')),
974                            "{} {} prints `{printed}` but passes `{arg}`",
975                            probe.manager,
976                            probe.kind
977                        );
978                    }
979                }
980                Clear::Directory => assert!(
981                    printed.contains("rm -rf") || printed.contains("Remove-Item"),
982                    "{} {} deletes a directory but prints `{printed}`",
983                    probe.manager,
984                    probe.kind
985                ),
986            }
987        }
988    }
989
990    #[test]
991    fn every_manager_in_the_report_can_be_named_to_clear() {
992        let names = known_managers();
993        for probe in PROBES {
994            assert!(
995                names.contains(&probe.manager),
996                "{} is reported but `devp caches clear {}` would not find it",
997                probe.manager,
998                probe.manager
999            );
1000        }
1001        // cargo, go and gradle each have two rows; naming one clears both, and offering
1002        // the name twice in the error message reads like a bug.
1003        let mut sorted = names.clone();
1004        sorted.sort_unstable();
1005        sorted.dedup();
1006        assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
1007    }
1008
1009    #[test]
1010    fn an_unknown_manager_is_a_usage_error() {
1011        // Returns before anything is measured, so this touches nothing.
1012        let err = run_clear("nonesuch", true, true, false).unwrap_err();
1013        assert!(err.downcast_ref::<crate::UsageError>().is_some());
1014    }
1015
1016    #[test]
1017    fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
1018        let err = run_clear("npm", false, false, true).unwrap_err();
1019        assert!(err.downcast_ref::<crate::UsageError>().is_some());
1020    }
1021
1022    #[test]
1023    fn removing_a_directory_reports_nothing_when_it_worked() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let cache = dir.path().join("cache");
1026        std::fs::create_dir(&cache).unwrap();
1027        std::fs::write(cache.join("blob"), b"x").unwrap();
1028
1029        assert!(remove_cache_dir(&cache).is_none());
1030        assert!(!cache.exists());
1031        // Already gone is not a failure: the retry can win the race the first attempt
1032        // lost, and reporting that as an error would fail a clear that succeeded.
1033        assert!(remove_cache_dir(&cache).is_none());
1034    }
1035
1036    #[test]
1037    fn clearing_a_directory_reports_what_actually_went() {
1038        let dir = tempfile::tempdir().unwrap();
1039        let cache = dir.path().join("store");
1040        std::fs::create_dir(&cache).unwrap();
1041        std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
1042        let before = adapters::dir_size(&cache);
1043
1044        let outcome = clear_one(&CacheReport {
1045            manager: "cargo",
1046            kind: "registry cache",
1047            path: cache.clone(),
1048            bytes: before,
1049            clear_command: "rm -rf",
1050            clear: Clear::Directory,
1051            note: None,
1052        });
1053
1054        assert!(outcome.problem.is_none());
1055        assert_eq!(outcome.after, 0);
1056        // Measured, not assumed: `before - after`, so a partial clear reports a partial
1057        // number instead of the whole directory.
1058        assert_eq!(outcome.freed(), before);
1059        assert!(!cache.exists());
1060    }
1061
1062    #[test]
1063    fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
1064        // The one case where dev-prune declines to fall back to deleting the directory:
1065        // only the manager knows what in its store is still referenced.
1066        let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"]);
1067        assert!(problem.is_some_and(|p| p.contains("not on PATH")));
1068    }
1069}