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