use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::Result;
use colored::Colorize as _;
use crate::adapters;
use crate::constants;
use crate::i18n;
use crate::json;
use crate::output;
pub struct CacheReport {
pub manager: &'static str,
pub kind: &'static str,
pub path: PathBuf,
pub bytes: u64,
pub clear_command: String,
pub clear: Clear,
pub note: Option<&'static str>,
pub cap_gb: Option<u64>,
pub over_cap: bool,
pub dependents: Option<usize>,
pub extra_args: Vec<String>,
}
#[derive(Clone, Copy)]
pub enum Clear {
Command(&'static str, &'static [&'static str]),
Directory,
Manual { why: &'static str },
}
struct Probe {
manager: &'static str,
kind: &'static str,
query: Option<(&'static str, &'static [&'static str])>,
query_suffix: Option<&'static str>,
clear_command: &'static str,
clear: Clear,
note: Option<&'static str>,
}
#[cfg(windows)]
const CARGO_CACHE_CLEAR: &str =
r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\cache";
#[cfg(not(windows))]
const CARGO_CACHE_CLEAR: &str = "rm -rf ~/.cargo/registry/cache";
#[cfg(windows)]
const CARGO_SRC_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cargo\registry\src";
#[cfg(not(windows))]
const CARGO_SRC_CLEAR: &str = "rm -rf ~/.cargo/registry/src";
#[cfg(windows)]
const MAVEN_REPO_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository";
#[cfg(not(windows))]
const MAVEN_REPO_CLEAR: &str = "rm -rf ~/.m2/repository";
const MAVEN_MANUAL: &str = "`~/.m2/repository` is Maven's local repository, not a \
download cache: `mvn install` and `install:install-file` write artifacts there \
that exist nowhere else, and nothing in the tree tells them apart from the \
downloaded ones reliably enough to delete around. dev-prune sizes it and prints \
the command; running it is yours to decide.";
#[cfg(windows)]
const GRADLE_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\caches";
#[cfg(not(windows))]
const GRADLE_CACHE_CLEAR: &str = "rm -rf ~/.gradle/caches";
#[cfg(windows)]
const GRADLE_DISTS_CLEAR: &str =
r"Remove-Item -Recurse -Force $env:USERPROFILE\.gradle\wrapper\dists";
#[cfg(not(windows))]
const GRADLE_DISTS_CLEAR: &str = "rm -rf ~/.gradle/wrapper/dists";
#[cfg(windows)]
const VCPKG_ARCHIVES_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\vcpkg\archives";
#[cfg(not(windows))]
const VCPKG_ARCHIVES_CLEAR: &str = "rm -rf ~/.cache/vcpkg/archives";
#[cfg(windows)]
const HEX_CACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.hex\packages";
#[cfg(not(windows))]
const HEX_CACHE_CLEAR: &str = "rm -rf ~/.hex/packages";
#[cfg(windows)]
const BUNDLER_CACHE_CLEAR: &str = "Remove-Item -Recurse -Force \"$(gem env gemdir)\\cache\"";
#[cfg(not(windows))]
const BUNDLER_CACHE_CLEAR: &str = "rm -rf \"$(gem env gemdir)/cache\"";
#[cfg(windows)]
const DART_HOSTED_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Pub\Cache\hosted";
#[cfg(not(windows))]
const DART_HOSTED_CLEAR: &str = "rm -rf ~/.pub-cache/hosted";
#[cfg(windows)]
const SWIFTPM_CACHE_CLEAR: &str =
r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\org.swift.swiftpm";
#[cfg(target_os = "macos")]
const SWIFTPM_CACHE_CLEAR: &str = "rm -rf ~/Library/Caches/org.swift.swiftpm";
#[cfg(not(any(windows, target_os = "macos")))]
const SWIFTPM_CACHE_CLEAR: &str = "rm -rf ~/.cache/org.swift.swiftpm";
#[cfg(windows)]
const TERRAFORM_PLUGIN_CLEAR: &str =
r"Remove-Item -Recurse -Force $env:APPDATA\terraform.d\plugin-cache";
#[cfg(not(windows))]
const TERRAFORM_PLUGIN_CLEAR: &str = "rm -rf ~/.terraform.d/plugin-cache";
#[cfg(windows)]
const POETRY_ARTIFACTS_CLEAR: &str =
"Remove-Item -Recurse -Force \"$(poetry config cache-dir)\\artifacts\"";
#[cfg(not(windows))]
const POETRY_ARTIFACTS_CLEAR: &str = "rm -rf \"$(poetry config cache-dir)/artifacts\"";
#[cfg(windows)]
const SCCACHE_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Mozilla\sccache";
#[cfg(target_os = "macos")]
const SCCACHE_CLEAR: &str = "rm -rf ~/Library/Caches/Mozilla.sccache";
#[cfg(not(any(windows, target_os = "macos")))]
const SCCACHE_CLEAR: &str = "rm -rf ~/.cache/sccache";
#[cfg(windows)]
const PLAYWRIGHT_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\ms-playwright";
#[cfg(target_os = "macos")]
const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/Library/Caches/ms-playwright";
#[cfg(not(any(windows, target_os = "macos")))]
const PLAYWRIGHT_CLEAR: &str = "rm -rf ~/.cache/ms-playwright";
#[cfg(windows)]
const PUPPETEER_CLEAR: &str = r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\puppeteer";
#[cfg(not(windows))]
const PUPPETEER_CLEAR: &str = "rm -rf ~/.cache/puppeteer";
#[cfg(windows)]
const HUGGINGFACE_CLEAR: &str =
r"Remove-Item -Recurse -Force $env:USERPROFILE\.cache\huggingface\hub";
#[cfg(not(windows))]
const HUGGINGFACE_CLEAR: &str = "rm -rf ~/.cache/huggingface/hub";
#[cfg(windows)]
const CYPRESS_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\Cypress\Cache";
#[cfg(target_os = "macos")]
const CYPRESS_CLEAR: &str = "rm -rf ~/Library/Caches/Cypress";
#[cfg(not(any(windows, target_os = "macos")))]
const CYPRESS_CLEAR: &str = "rm -rf ~/.cache/Cypress";
#[cfg(windows)]
const ELECTRON_CLEAR: &str = r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron\Cache";
#[cfg(target_os = "macos")]
const ELECTRON_CLEAR: &str = "rm -rf ~/Library/Caches/electron";
#[cfg(not(any(windows, target_os = "macos")))]
const ELECTRON_CLEAR: &str = "rm -rf ~/.cache/electron";
#[cfg(windows)]
const ELECTRON_BUILDER_CLEAR: &str =
r"Remove-Item -Recurse -Force $env:LOCALAPPDATA\electron-builder\Cache";
#[cfg(target_os = "macos")]
const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/Library/Caches/electron-builder";
#[cfg(not(any(windows, target_os = "macos")))]
const ELECTRON_BUILDER_CLEAR: &str = "rm -rf ~/.cache/electron-builder";
const PROBES: &[Probe] = &[
Probe {
manager: "npm",
kind: "cache",
query: Some(("npm", &["config", "get", "cache"])),
query_suffix: None,
clear_command: "npm cache clean --force",
clear: Clear::Command("npm", &["cache", "clean", "--force"]),
note: None,
},
Probe {
manager: "pnpm",
kind: "store",
query: Some(("pnpm", &["store", "path"])),
query_suffix: None,
clear_command: "pnpm store prune",
clear: Clear::Command("pnpm", &["store", "prune"]),
note: Some(
"hardlinked into every node_modules it filled; emptying it is what makes the \
next pnpm install a download",
),
},
Probe {
manager: "yarn",
kind: "cache",
query: Some(("yarn", &["cache", "dir"])),
query_suffix: None,
clear_command: "yarn cache clean",
clear: Clear::Command("yarn", &["cache", "clean"]),
note: None,
},
Probe {
manager: "bun",
kind: "cache",
query: Some(("bun", &["pm", "cache"])),
query_suffix: None,
clear_command: "bun pm cache rm",
clear: Clear::Command("bun", &["pm", "cache", "rm"]),
note: None,
},
Probe {
manager: "uv",
kind: "cache",
query: Some(("uv", &["cache", "dir"])),
query_suffix: None,
clear_command: "uv cache prune",
clear: Clear::Command("uv", &["cache", "prune"]),
note: None,
},
Probe {
manager: "pip",
kind: "cache",
query: Some(("pip", &["cache", "dir"])),
query_suffix: None,
clear_command: "pip cache purge",
clear: Clear::Command("pip", &["cache", "purge"]),
note: None,
},
Probe {
manager: "conda",
kind: "package cache",
query: None,
query_suffix: None,
clear_command: "conda clean --packages --tarballs --yes",
clear: Clear::Command("conda", &["clean", "--packages", "--tarballs", "--yes"]),
note: Some(
"unpacked packages and downloaded archives; conda keeps what its \
environments use, except any it linked by symlink rather than hardlink",
),
},
Probe {
manager: "cargo",
kind: "registry cache",
query: None,
query_suffix: None,
clear_command: CARGO_CACHE_CLEAR,
clear: Clear::Directory,
note: Some("the downloaded .crate archives; clearing them means downloading again"),
},
Probe {
manager: "cargo",
kind: "registry sources",
query: None,
query_suffix: None,
clear_command: CARGO_SRC_CLEAR,
clear: Clear::Directory,
note: Some("unpacked copies of the archives above; cargo re-extracts these offline"),
},
Probe {
manager: "go",
kind: "module cache",
query: Some(("go", &["env", "GOMODCACHE"])),
query_suffix: None,
clear_command: "go clean -modcache",
clear: Clear::Command("go", &["clean", "-modcache"]),
note: None,
},
Probe {
manager: "go",
kind: "build cache",
query: Some(("go", &["env", "GOCACHE"])),
query_suffix: None,
clear_command: "go clean -cache",
clear: Clear::Command("go", &["clean", "-cache"]),
note: Some("compiled build artifacts; clearing them means the next build is a cold one"),
},
Probe {
manager: "maven",
kind: "local repository",
query: None,
query_suffix: None,
clear_command: MAVEN_REPO_CLEAR,
clear: Clear::Manual { why: MAVEN_MANUAL },
note: Some(
"every Maven build on the machine resolves from here, and `mvn install` writes here too — dev-prune will not delete it for you",
),
},
Probe {
manager: "gradle",
kind: "caches",
query: None,
query_suffix: None,
clear_command: GRADLE_CACHE_CLEAR,
clear: Clear::Directory,
note: Some(
"downloaded dependencies and build caches shared by every Gradle project; rebuilt on demand",
),
},
Probe {
manager: "gradle",
kind: "wrapper distributions",
query: None,
query_suffix: None,
clear_command: GRADLE_DISTS_CLEAR,
clear: Clear::Directory,
note: Some(
"one full Gradle per version any wrapper ever asked for; re-downloaded on demand",
),
},
Probe {
manager: "nuget",
kind: "global packages",
query: None,
query_suffix: None,
clear_command: "dotnet nuget locals global-packages --clear",
clear: Clear::Command("dotnet", &["nuget", "locals", "global-packages", "--clear"]),
note: Some(
"every .NET project on the machine restores from here; re-downloaded on the next restore",
),
},
Probe {
manager: "vcpkg",
kind: "binary cache",
query: None,
query_suffix: None,
clear_command: VCPKG_ARCHIVES_CLEAR,
clear: Clear::Directory,
note: Some("prebuilt package archives; vcpkg rebuilds from source what it cannot re-fetch"),
},
Probe {
manager: "conan",
kind: "package cache",
query: None,
query_suffix: None,
clear_command: "conan remove \"*\" --confirm",
clear: Clear::Command("conan", &["remove", "*", "--confirm"]),
note: Some(
"recipes and binaries shared by every Conan project; re-fetched on the next install",
),
},
Probe {
manager: "ccache",
kind: "compiler cache",
query: Some(("ccache", &["--get-config", "cache_dir"])),
query_suffix: None,
clear_command: "ccache --clear",
clear: Clear::Command("ccache", &["--clear"]),
note: Some(
"compiled objects keyed by source and flags; the next build repopulates it one cache miss at a time",
),
},
Probe {
manager: "sccache",
kind: "compiler cache",
query: None,
query_suffix: None,
clear_command: SCCACHE_CLEAR,
clear: Clear::Directory,
note: Some(
"compiled objects keyed by source and flags; the next build repopulates it. If a clear is refused, `sccache --stop-server` first — the daemon holds the directory open",
),
},
Probe {
manager: "composer",
kind: "cache",
query: Some(("composer", &["config", "--global", "cache-dir"])),
query_suffix: None,
clear_command: "composer clear-cache",
clear: Clear::Command("composer", &["clear-cache"]),
note: Some(
"downloaded package archives and repository metadata; re-fetched by the next composer install",
),
},
Probe {
manager: "cocoapods",
kind: "cache",
query: None,
query_suffix: None,
clear_command: "pod cache clean --all",
clear: Clear::Command("pod", &["cache", "clean", "--all"]),
note: Some("downloaded pod sources, re-fetched by the next pod install"),
},
Probe {
manager: "hex",
kind: "package cache",
query: None,
query_suffix: None,
clear_command: HEX_CACHE_CLEAR,
clear: Clear::Directory,
note: Some(
"package tarballs shared by every Mix project on the machine; re-fetched by the next mix deps.get",
),
},
Probe {
manager: "bundler",
kind: "gem cache",
query: Some(("gem", &["env", "gemdir"])),
query_suffix: Some("cache"),
clear_command: BUNDLER_CACHE_CLEAR,
clear: Clear::Directory,
note: Some(
"only the `cache/` subdirectory of the gem home, which holds the downloaded \
`.gem` archives. The installed gems beside it, and the binstubs in `bin/`, \
are untouched — including anything put there by `gem install ./local.gem`, \
which no remote could hand back.",
),
},
Probe {
manager: "dart",
kind: "pub cache",
query: None,
query_suffix: None,
clear_command: DART_HOSTED_CLEAR,
clear: Clear::Directory,
note: Some(
"only `hosted/`, the packages pub downloaded from a registry. The `git/` \
checkouts and the executables `dart pub global activate` installed live \
beside it and are left alone; `dart pub cache clean` would take all three.",
),
},
Probe {
manager: "swift",
kind: "swiftpm cache",
query: None,
query_suffix: None,
clear_command: SWIFTPM_CACHE_CLEAR,
clear: Clear::Directory,
note: Some(
"the shared repository and manifest cache SwiftPM fills for every package on \
the machine. `swift build` re-clones what it needs, so the first build after \
this one is a network build.",
),
},
Probe {
manager: "terraform",
kind: "plugin cache",
query: None,
query_suffix: None,
clear_command: TERRAFORM_PLUGIN_CLEAR,
clear: Clear::Directory,
note: Some(
"this exists only if you switched it on with `TF_PLUGIN_CACHE_DIR` or \
`plugin_cache_dir` in `.terraformrc`. Without it Terraform downloads a \
private copy of every provider into each project's `.terraform/` — which is \
what `devp run` prunes — and there is no shared store to report.",
),
},
Probe {
manager: "poetry",
kind: "artifact cache",
query: Some(("poetry", &["config", "cache-dir"])),
query_suffix: Some("artifacts"),
clear_command: POETRY_ARTIFACTS_CLEAR,
clear: Clear::Directory,
note: Some(
"only `artifacts/`, the wheels and sdists Poetry downloaded. `virtualenvs/` \
sits in the same cache directory and holds the environments themselves — \
deleting that would not be clearing a cache.",
),
},
Probe {
manager: "pdm",
kind: "cache",
query: Some(("pdm", &["config", "cache_dir"])),
query_suffix: None,
clear_command: "pdm cache clear",
clear: Clear::Command("pdm", &["cache", "clear"]),
note: None,
},
Probe {
manager: "playwright",
kind: "browser bundles",
query: None,
query_suffix: None,
clear_command: PLAYWRIGHT_CLEAR,
clear: Clear::Directory,
note: Some(
"a full Chromium, Firefox and WebKit build per Playwright version, and the \
old ones are never removed when the dependency moves. `npx playwright \
install` re-downloads only the versions the installed Playwright asks for, \
which is usually why this comes back smaller than it was.",
),
},
Probe {
manager: "puppeteer",
kind: "browser bundles",
query: None,
query_suffix: None,
clear_command: PUPPETEER_CLEAR,
clear: Clear::Directory,
note: Some(
"one Chrome build per Puppeteer version. `npx puppeteer browsers install` \
re-downloads the current one.",
),
},
Probe {
manager: "huggingface",
kind: "hub cache",
query: None,
query_suffix: None,
clear_command: HUGGINGFACE_CLEAR,
clear: Clear::Directory,
note: Some(
"model weights, not packages. This is the one row here where re-downloading \
is measured in tens of gigabytes and can fail outright: a gated or \
now-private repository will not hand the file back, and a checkpoint from a \
revision that has since been deleted is gone. Worth looking at what is in it \
before emptying it.",
),
},
Probe {
manager: "cypress",
kind: "binary cache",
query: None,
query_suffix: None,
clear_command: CYPRESS_CLEAR,
clear: Clear::Directory,
note: Some(
"one unpacked Electron application per Cypress version, around half a \
gigabyte each, and upgrading leaves the previous one in place. `npx cypress \
install` re-downloads the version the project pins.",
),
},
Probe {
manager: "electron",
kind: "download cache",
query: None,
query_suffix: None,
clear_command: ELECTRON_CLEAR,
clear: Clear::Directory,
note: Some(
"the prebuilt Electron archives every `electron` dependency downloads, kept \
per version and per architecture. Re-downloaded on the next install.",
),
},
Probe {
manager: "electron",
kind: "builder cache",
query: None,
query_suffix: None,
clear_command: ELECTRON_BUILDER_CLEAR,
clear: Clear::Directory,
note: Some(
"electron-builder's own store of signing tools, `winCodeSign`, `nsis` and the \
runtimes it packages with. Re-downloaded on the next build, which makes that \
build slow rather than broken.",
),
},
Probe {
manager: "deno",
kind: "cache",
query: None,
query_suffix: None,
clear_command: "deno clean",
clear: Clear::Command("deno", &["clean"]),
note: None,
},
];
pub fn run(json_output: bool, volume: Option<&str>) -> Result<()> {
let volume = volume.map(resolve_volume).transpose()?;
let reg = registered();
let mut reports = collect(!json_output, reg.as_ref());
apply_caps(&mut reports, &caps());
let deps = reg.as_ref().map(|r| dependents(r, !json_output));
apply_dependents(&mut reports, deps.as_ref());
if let Some(root) = &volume {
reports = on_volume(reports, root);
}
let engines = if volume.is_some() {
Vec::new()
} else {
container_summary(!json_output)
};
if json_output {
return json::emit(&json::caches_document(
&reports,
deps.as_ref().map(|d| d.repositories),
&engines,
volume.as_deref(),
));
}
print_report(&reports, deps.as_ref(), volume.as_deref());
crate::commands::containers::print_summary(&engines);
Ok(())
}
fn resolve_volume(arg: &str) -> Result<PathBuf> {
let path = absolutize(normalize_volume_arg(arg));
volume_root(&path).ok_or_else(|| {
anyhow::Error::new(crate::UsageError(format!(
"`{arg}` is not a drive or a path on one. Name a drive (`--volume V:`), a \
mount point (`--volume /mnt/data`), or any path that sits on the one you \
mean."
)))
})
}
fn absolutize(path: PathBuf) -> PathBuf {
if path.is_absolute() || !path.exists() {
return path;
}
std::path::absolute(&path).unwrap_or(path)
}
#[cfg(windows)]
fn normalize_volume_arg(arg: &str) -> PathBuf {
let trimmed = arg.trim();
let letter = match trimmed.as_bytes() {
[c] if c.is_ascii_alphabetic() => Some(*c),
[c, b':'] if c.is_ascii_alphabetic() => Some(*c),
_ => None,
};
match letter {
Some(c) => PathBuf::from(format!("{}:\\", c.to_ascii_uppercase() as char)),
None => PathBuf::from(trimmed),
}
}
#[cfg(unix)]
fn normalize_volume_arg(arg: &str) -> PathBuf {
PathBuf::from(arg.trim())
}
#[cfg(windows)]
fn same_volume(a: &Path, b: &Path) -> bool {
match (volume_key(a), volume_key(b)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
#[cfg(windows)]
fn volume_key(path: &Path) -> Option<String> {
use std::path::{Component, Prefix};
let Some(Component::Prefix(prefix)) = path.components().next() else {
return None;
};
Some(match prefix.kind() {
Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
(letter.to_ascii_uppercase() as char).to_string()
}
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => format!(
"{}\\{}",
server.to_string_lossy().to_uppercase(),
share.to_string_lossy().to_uppercase()
),
other => format!("{other:?}").to_uppercase(),
})
}
#[cfg(unix)]
fn same_volume(a: &Path, b: &Path) -> bool {
a == b
}
fn on_volume(reports: Vec<CacheReport>, root: &Path) -> Vec<CacheReport> {
reports
.into_iter()
.filter(|r| volume_root(&r.path).is_some_and(|v| same_volume(&v, root)))
.collect()
}
fn volume_totals(reports: &[CacheReport]) -> Vec<(String, u64)> {
let mut totals: Vec<(String, u64)> = Vec::new();
for r in reports {
let Some(root) = volume_root(&r.path) else {
continue;
};
let label = output::clean_path(&root);
match totals.iter_mut().find(|(l, _)| *l == label) {
Some((_, bytes)) => *bytes += r.bytes,
None => totals.push((label, r.bytes)),
}
}
totals.sort_by_key(|(_, bytes)| std::cmp::Reverse(*bytes));
totals
}
fn container_summary(spinner: bool) -> Vec<crate::commands::containers::EngineReport> {
let pb = spinner.then(|| output::create_spinner("Asking the container engines..."));
let engines = crate::commands::containers::collect(None);
if let Some(pb) = pb {
pb.finish_and_clear();
}
engines
}
fn caps() -> BTreeMap<String, u64> {
crate::config::Registry::load()
.map(|r| r.settings.cache_max_gb)
.unwrap_or_default()
}
fn apply_caps(reports: &mut [CacheReport], caps: &BTreeMap<String, u64>) {
let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
for r in reports.iter() {
*totals.entry(r.manager).or_default() += r.bytes;
}
for r in reports.iter_mut() {
let Some(&gb) = caps
.get(r.manager)
.or_else(|| caps.get(constants::CACHE_CAP_DEFAULT_KEY))
else {
continue;
};
r.cap_gb = Some(gb);
r.over_cap = totals.get(r.manager).copied().unwrap_or(0)
> gb.saturating_mul(crate::constants::BYTES_PER_GIB);
}
}
struct Registered {
paths: Vec<PathBuf>,
scan_depth: usize,
}
fn registered() -> Option<Registered> {
let registry = crate::config::Registry::load().ok()?;
let paths: Vec<PathBuf> = registry
.repositories
.keys()
.filter(|p| p.exists())
.cloned()
.collect();
if paths.is_empty() {
return None;
}
Some(Registered {
paths,
scan_depth: registry.settings.scan_depth,
})
}
struct Dependents {
repositories: usize,
by_manager: BTreeMap<&'static str, usize>,
}
fn dependents(reg: &Registered, spinner: bool) -> Dependents {
let pb = spinner.then(|| output::create_spinner("Checking which caches are still in use..."));
let mut by_manager: BTreeMap<&'static str, usize> = PROBES
.iter()
.map(|p| p.manager)
.filter(|m| adapters::is_adapter_name(m))
.map(|m| (m, 0))
.collect();
for path in ®.paths {
let depth = crate::workspace::clamp_depth(
crate::config::PerRepoConfig::load_with_diagnostics(path)
.ok()
.flatten()
.and_then(|c| c.scan_depth)
.unwrap_or(reg.scan_depth),
);
let mut here: HashSet<&'static str> = HashSet::new();
for project in crate::workspace::discover_all_to_depth(path, depth) {
for adapter in &project.adapters {
here.insert(adapter.name());
}
}
for (manager, count) in by_manager.iter_mut() {
if here.contains(manager) {
*count += 1;
}
}
}
if let Some(pb) = pb {
pb.finish_and_clear();
}
Dependents {
repositories: reg.paths.len(),
by_manager,
}
}
fn apply_dependents(reports: &mut [CacheReport], deps: Option<&Dependents>) {
let Some(deps) = deps else {
return;
};
for r in reports.iter_mut() {
r.dependents = deps.by_manager.get(r.manager).copied();
}
}
fn manager_totals(reports: &[CacheReport]) -> BTreeMap<&'static str, (u64, usize)> {
let mut totals: BTreeMap<&'static str, (u64, usize)> = BTreeMap::new();
for r in reports {
let entry = totals.entry(r.manager).or_default();
entry.0 += r.bytes;
entry.1 += 1;
}
totals
}
fn collect(spinner: bool, reg: Option<&Registered>) -> Vec<CacheReport> {
let pb = spinner.then(|| output::create_spinner("Measuring package manager caches..."));
let from = query_dir();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut reports = Vec::new();
for probe in PROBES {
let Some(path) = locate(probe, &from) else {
continue;
};
let path = path.canonicalize().unwrap_or(path);
if !seen.insert(path.clone()) {
continue;
}
reports.push(CacheReport {
manager: probe.manager,
kind: probe.kind,
bytes: adapters::dir_size(&path),
path,
clear_command: probe.clear_command.to_string(),
clear: probe.clear,
note: probe.note,
cap_gb: None,
over_cap: false,
dependents: None,
extra_args: Vec::new(),
});
}
for store in reg.map(|r| volume_stores(&r.paths)).unwrap_or_default() {
if !seen.insert(store.canonicalize().unwrap_or_else(|_| store.clone())) {
continue;
}
reports.push(volume_store_report(store));
}
if let Some(pb) = pb {
pb.finish_and_clear();
}
reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
reports
}
const PNPM_VOLUME_NOTE: &str = "one store per filesystem, because a hardlink into node_modules cannot cross one; \
this is the store for the projects on this volume";
fn volume_store_report(store: PathBuf) -> CacheReport {
let named = output::clean_path(&store);
CacheReport {
manager: "pnpm",
kind: "store",
bytes: adapters::dir_size(&store),
clear_command: format!("pnpm store prune --store-dir {}", shell_arg(&named)),
extra_args: vec!["--store-dir".to_string(), named],
path: store,
clear: Clear::Command("pnpm", &["store", "prune"]),
note: Some(PNPM_VOLUME_NOTE),
cap_gb: None,
over_cap: false,
dependents: None,
}
}
fn shell_arg(named: &str) -> String {
if named.contains(' ') {
format!("\"{named}\"")
} else {
named.to_string()
}
}
fn volume_stores(repos: &[PathBuf]) -> Vec<PathBuf> {
let mut roots = volume_roots(repos);
if let Ok(here) = std::env::current_dir()
&& let Some(root) = volume_root(&here)
&& !roots.contains(&root)
{
roots.push(root);
}
roots
.into_iter()
.map(|root| root.join(constants::PNPM_VOLUME_STORE_DIR))
.filter(|store| store.is_dir())
.collect()
}
fn volume_roots(repos: &[PathBuf]) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
for repo in repos {
if let Some(root) = volume_root(repo)
&& !roots.contains(&root)
{
roots.push(root);
}
}
roots
}
#[cfg(unix)]
pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
use std::os::unix::fs::MetadataExt;
let dev = std::fs::metadata(path).ok()?.dev();
let mut root = path.to_path_buf();
for ancestor in path.ancestors().skip(1) {
match std::fs::metadata(ancestor) {
Ok(m) if m.dev() == dev => root = ancestor.to_path_buf(),
_ => break,
}
}
Some(root)
}
#[cfg(windows)]
pub(crate) fn volume_root(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut components = path.components();
let Some(Component::Prefix(prefix)) = components.next() else {
return None;
};
if components.next() != Some(Component::RootDir) {
return None;
}
let mut root = PathBuf::from(prefix.as_os_str());
root.push(Component::RootDir.as_os_str());
Some(root)
}
fn query_dir() -> PathBuf {
dirs::home_dir()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn locate(probe: &Probe, from: &Path) -> Option<PathBuf> {
if let Some((program, args)) = probe.query
&& adapters::binary_available(program)
{
let answered = adapters::capture_command_with_timeout(
program,
args,
from,
std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
)
.ok()
.and_then(|raw| path_from_output(&raw))
.map(|p| match probe.query_suffix {
Some(suffix) => suffix.split('/').fold(p, |acc, seg| acc.join(seg)),
None => p,
})
.filter(|p| p.is_dir());
if answered.is_some() {
return answered;
}
}
fallbacks(probe.manager, probe.kind)
.into_iter()
.find(|p| p.is_dir())
}
fn path_from_output(raw: &str) -> Option<PathBuf> {
let line = raw.lines().map(str::trim).rfind(|l| !l.is_empty())?;
let line = line.trim_matches('"');
if line.is_empty() || line == "undefined" || !Path::new(line).is_absolute() {
return None;
}
Some(PathBuf::from(line))
}
fn fallbacks(manager: &str, kind: &str) -> Vec<PathBuf> {
let home = dirs::home_dir();
let local = dirs::data_local_dir();
let cache = dirs::cache_dir();
let under = |base: &Option<PathBuf>, rel: &str| {
base.as_ref()
.map(|b| rel.split('/').fold(b.clone(), |p, seg| p.join(seg)))
};
let candidates = match (manager, kind) {
("npm", _) => vec![under(&local, "npm-cache"), under(&home, ".npm")],
("pnpm", _) => vec![
under(&local, "pnpm/store"),
under(&home, ".local/share/pnpm/store"),
under(&home, "Library/pnpm/store"),
under(&home, ".pnpm-store"),
],
("yarn", _) => vec![
under(&home, ".yarn/berry/cache"),
under(&local, "Yarn/Cache"),
under(&cache, "yarn"),
],
("bun", _) => vec![under(&home, ".bun/install/cache")],
("uv", _) => vec![under(&cache, "uv"), under(&local, "uv/cache")],
("pip", _) => vec![under(&cache, "pip"), under(&local, "pip/Cache")],
("conda", _) => vec![
std::env::var_os("CONDA_PKGS_DIRS").map(PathBuf::from),
std::env::var_os("CONDA_EXE")
.map(PathBuf::from)
.and_then(|p| p.parent().and_then(Path::parent).map(Path::to_path_buf))
.map(|root| root.join("pkgs")),
under(&home, "miniconda3/pkgs"),
under(&home, "anaconda3/pkgs"),
under(&home, "miniforge3/pkgs"),
under(&home, "mambaforge/pkgs"),
under(&home, ".conda/pkgs"),
],
("cargo", "registry cache") => vec![Some(cargo_home().join("registry").join("cache"))],
("cargo", _) => vec![Some(cargo_home().join("registry").join("src"))],
("go", "module cache") => vec![
std::env::var_os("GOMODCACHE").map(PathBuf::from),
std::env::var_os("GOPATH").map(|p| PathBuf::from(p).join("pkg").join("mod")),
under(&home, "go/pkg/mod"),
],
("go", _) => vec![
std::env::var_os("GOCACHE").map(PathBuf::from),
under(&cache, "go-build"),
under(&local, "go-build"),
],
("maven", _) => vec![under(&home, ".m2/repository")],
("gradle", "caches") => vec![
std::env::var_os("GRADLE_USER_HOME").map(|p| PathBuf::from(p).join("caches")),
under(&home, ".gradle/caches"),
],
("gradle", _) => vec![
std::env::var_os("GRADLE_USER_HOME")
.map(|p| PathBuf::from(p).join("wrapper").join("dists")),
under(&home, ".gradle/wrapper/dists"),
],
("nuget", _) => vec![
std::env::var_os("NUGET_PACKAGES").map(PathBuf::from),
under(&home, ".nuget/packages"),
],
("vcpkg", _) => vec![
std::env::var_os("VCPKG_DEFAULT_BINARY_CACHE").map(PathBuf::from),
under(&local, "vcpkg/archives"),
under(&cache, "vcpkg/archives"),
],
("conan", _) => vec![
std::env::var_os("CONAN_HOME").map(|p| PathBuf::from(p).join("p")),
under(&home, ".conan2/p"),
],
("ccache", _) => vec![
std::env::var_os("CCACHE_DIR").map(PathBuf::from),
under(&home, ".ccache"),
under(&cache, "ccache"),
under(&local, "ccache"),
],
("sccache", _) => vec![
std::env::var_os("SCCACHE_DIR").map(PathBuf::from),
under(&local, "Mozilla/sccache/cache"),
under(&local, "Mozilla/sccache"),
under(&cache, "Mozilla.sccache"),
under(&cache, "sccache"),
],
("composer", _) => vec![
std::env::var_os("COMPOSER_CACHE_DIR").map(PathBuf::from),
std::env::var_os("COMPOSER_HOME").map(|p| PathBuf::from(p).join("cache")),
under(&local, "Composer"),
under(&cache, "composer"),
under(&home, ".composer/cache"),
],
("cocoapods", _) => vec![
std::env::var_os("CP_CACHE_DIR").map(PathBuf::from),
under(&home, "Library/Caches/CocoaPods"),
],
("hex", _) => vec![
std::env::var_os("HEX_HOME").map(|p| PathBuf::from(p).join("packages")),
under(&home, ".hex/packages"),
under(&cache, "hex/packages"),
],
("bundler", _) => gem_cache_dirs(),
("dart", _) => vec![
std::env::var_os("PUB_CACHE").map(|p| PathBuf::from(p).join("hosted")),
under(&local, "Pub/Cache/hosted"),
under(&home, ".pub-cache/hosted"),
],
("swift", _) => vec![
under(&cache, "org.swift.swiftpm"),
under(&home, ".swiftpm/cache"),
],
("terraform", _) => vec![
std::env::var_os("TF_PLUGIN_CACHE_DIR").map(PathBuf::from),
under(&home, ".terraform.d/plugin-cache"),
under(&dirs::data_dir(), "terraform.d/plugin-cache"),
],
("poetry", _) => vec![
std::env::var_os("POETRY_CACHE_DIR").map(|p| PathBuf::from(p).join("artifacts")),
under(&cache, "pypoetry/Cache/artifacts"),
under(&cache, "pypoetry/artifacts"),
],
("pdm", _) => vec![
std::env::var_os("PDM_CACHE_DIR").map(PathBuf::from),
under(&cache, "pdm/Cache"),
under(&cache, "pdm"),
],
("playwright", _) => vec![
std::env::var_os("PLAYWRIGHT_BROWSERS_PATH").map(PathBuf::from),
under(&cache, "ms-playwright"),
],
("puppeteer", _) => vec![
std::env::var_os("PUPPETEER_CACHE_DIR").map(PathBuf::from),
under(&home, ".cache/puppeteer"),
],
("huggingface", _) => vec![
std::env::var_os("HF_HUB_CACHE").map(PathBuf::from),
std::env::var_os("HF_HOME").map(|p| PathBuf::from(p).join("hub")),
under(&home, ".cache/huggingface/hub"),
],
("cypress", _) => vec![
std::env::var_os("CYPRESS_CACHE_FOLDER").map(PathBuf::from),
under(&cache, "Cypress/Cache"),
under(&cache, "Cypress"),
],
("electron", "download cache") => vec![
std::env::var_os("electron_config_cache").map(PathBuf::from),
under(&cache, "electron/Cache"),
under(&cache, "electron"),
],
("electron", _) => vec![
under(&cache, "electron-builder/Cache"),
under(&cache, "electron-builder"),
],
("deno", _) => vec![
std::env::var_os("DENO_DIR").map(PathBuf::from),
under(&cache, "deno"),
],
_ => vec![],
};
candidates.into_iter().flatten().collect()
}
fn gem_cache_dirs() -> Vec<Option<PathBuf>> {
let mut out = vec![std::env::var_os("GEM_HOME").map(|p| PathBuf::from(p).join("cache"))];
let home = dirs::home_dir();
for parent in [".gem/ruby", ".local/share/gem/ruby"] {
let Some(base) = home
.as_ref()
.map(|h| parent.split('/').fold(h.clone(), |p, seg| p.join(seg)))
else {
continue;
};
if let Ok(entries) = std::fs::read_dir(&base) {
out.extend(entries.flatten().map(|e| Some(e.path().join("cache"))));
}
}
out.push(home.map(|h| h.join(".gem").join("cache")));
out
}
fn cargo_home() -> PathBuf {
std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
.unwrap_or_else(|| PathBuf::from(".cargo"))
}
fn print_report(reports: &[CacheReport], deps: Option<&Dependents>, volume: Option<&Path>) {
output::print_header(i18n::t("caches.header"));
if reports.is_empty() {
println!();
match volume {
Some(root) => output::print_info(&format!(
"No package manager cache on this machine sits on {}. They are somewhere \
else — run `devp caches` without `--volume` to see where.",
output::clean_path(root)
)),
None => output::print_info(i18n::t("caches.nothing")),
}
return;
}
println!();
let totals = manager_totals(reports);
let mut counted: HashSet<&'static str> = HashSet::new();
for r in reports {
let label = format!("{} {}", r.manager, r.kind);
println!(
" {:<30} {:>10} {}",
label,
output::format_bytes(r.bytes),
output::clean_path(&r.path)
);
let clearable = !matches!(r.clear, Clear::Manual { .. });
if clearable {
println!(
" {:<30} {:>10} clear: devp caches clear {}",
"", "", r.manager
);
}
let verb = if clearable { "runs: " } else { "clear:" };
println!(" {:<30} {:>10} {verb} {}", "", "", r.clear_command);
if let Some(note) = r.note {
println!(" {:<30} {:>10} {}", "", "", note);
}
if r.over_cap
&& let Some(gb) = r.cap_gb
{
println!(
" {:<30} {:>10} over the {gb} GiB cap you set for {}",
"", "", r.manager
);
}
if let Some(n) = r.dependents
&& counted.insert(r.manager)
{
println!(" {:<30} {:>10} {}", "", "", used_by(r, n, deps, &totals));
}
println!();
}
let total: u64 = reports.iter().map(|r| r.bytes).sum();
println!(
" {:<30} {:>10} across {} {}",
"Total",
output::format_bytes(total),
reports.len(),
output::plural(reports.len(), "cache", "caches")
);
let by_volume = volume_totals(reports);
if by_volume.len() > 1 {
let named = by_volume
.iter()
.map(|(label, bytes)| format!("{label} {}", output::format_bytes(*bytes)))
.collect::<Vec<_>>()
.join(" · ");
println!(" {:<30} {:>10} {named}", "By drive", "");
}
let ranked = costliest_per_repository(reports);
if ranked.len() > 1 {
let named = ranked
.iter()
.take(3)
.map(|(m, b)| format!("{m} {}", output::format_bytes_weighted(*b)))
.collect::<Vec<_>>()
.join(" · ");
println!(" {:<30} {:>10} {named}", "Costliest per repository", "");
}
if reports.iter().any(|r| r.over_cap) {
println!();
output::print_info(
"The caches marked above have outgrown the cap you set for them. `devp caches clear \
--over-cap all` empties exactly those and leaves the rest alone.",
);
}
if reports.iter().any(|r| r.dependents == Some(0)) {
println!();
output::print_info(
"The caches above that no registered repository uses were filled for projects that \
are not here any more. `devp caches clear --unused all` empties exactly those. It \
counts only repositories dev-prune knows about, so `devp link` anything you keep \
outside the registry before trusting the number.",
);
}
if let Some(root) = volume {
println!();
output::print_info(&format!(
"Only the caches on {} are above, and the container engines are not among \
them: an engine reports its own disk from inside a VM image with no path on \
this filesystem, so there is no drive to file it under — `devp caches \
docker` has that number. The clear commands above are not drive-specific \
either. They empty that manager's cache wherever it is, which for every \
manager but pnpm is one place.",
output::clean_path(root)
));
}
println!();
output::print_info(
"Nothing above was deleted. A cache is shared by every project on the machine, so \
no single repository's lockfile can prove it is recoverable — and it is what \
makes `devp restore` fast, which is why nothing dev-prune runs on a schedule \
will ever touch one. When you want the space more than the speed, run a clear \
command yourself, or `devp caches clear <manager>` — only the second is counted \
in `devp stats`, because dev-prune never sees the first.",
);
}
fn used_by(
r: &CacheReport,
dependents: usize,
deps: Option<&Dependents>,
totals: &BTreeMap<&'static str, (u64, usize)>,
) -> String {
if dependents == 0 {
return format!("no registered repository uses {}", r.manager)
.bold()
.to_string();
}
let registered = deps.map_or(dependents, |d| d.repositories);
let (total, rows) = totals.get(r.manager).copied().unwrap_or((r.bytes, 1));
let share = output::format_bytes(total / dependents as u64);
let each = if rows > 1 {
format!("{share} each across its {rows} caches")
} else {
format!("{share} each")
};
format!(
"{} is used by {dependents} of {registered} registered {} · {each}",
r.manager,
output::plural(registered, "repository", "repositories"),
)
.bold()
.to_string()
}
fn costliest_per_repository(reports: &[CacheReport]) -> Vec<(&'static str, u64)> {
let totals = manager_totals(reports);
let mut per: BTreeMap<&'static str, u64> = BTreeMap::new();
for r in reports {
if let Some(n) = r.dependents.filter(|n| *n > 0) {
let total = totals.get(r.manager).map_or(r.bytes, |t| t.0);
per.insert(r.manager, total / n as u64);
}
}
let mut ranked: Vec<(&'static str, u64)> = per.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
ranked
}
pub struct ClearOutcome {
pub manager: &'static str,
pub kind: &'static str,
pub path: PathBuf,
pub before: u64,
pub after: u64,
pub problem: Option<String>,
}
impl ClearOutcome {
pub fn freed(&self) -> u64 {
self.before.saturating_sub(self.after)
}
}
pub fn run_clear(
target: &str,
except: Option<&str>,
over_cap: bool,
unused: bool,
include_volumes: bool,
yes: bool,
dry_run: bool,
json_output: bool,
) -> Result<()> {
let targets: Vec<&str> = target
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.collect();
if targets.is_empty() {
return Err(anyhow::Error::new(crate::UsageError(format!(
"`{target}` names no cache. Name a manager, a comma-separated list of them, \
or `all`."
))));
}
let all = targets.len() == 1 && targets[0].eq_ignore_ascii_case("all");
if include_volumes
&& !(targets.len() == 1 && crate::commands::containers::is_engine(targets[0]))
{
return Err(anyhow::Error::new(crate::UsageError(
"`--include-volumes` lists a container engine's unused volumes, so it needs \
one engine named alone: `devp caches clear docker --include-volumes`."
.to_string(),
)));
}
if !all && targets.iter().any(|t| t.eq_ignore_ascii_case("all")) {
return Err(anyhow::Error::new(crate::UsageError(
"`all` already includes every manager, so a list containing it decides \
nothing. Name just the managers to clear, or run `devp caches clear all \
--except <managers>` to leave some out."
.to_string(),
)));
}
let excepts: Vec<&str> = match except {
Some(_) if !all => {
return Err(anyhow::Error::new(crate::UsageError(
"`--except` leaves managers out of `all`. You already named exactly \
what to clear — drop the unwanted names from the list instead."
.to_string(),
)));
}
Some(e) => e
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.collect(),
None => Vec::new(),
};
let target = targets[0];
if !all && targets.len() == 1 && crate::commands::containers::is_engine(target) {
if over_cap || unused {
return Err(anyhow::Error::new(crate::UsageError(format!(
"`--over-cap` and `--unused` pick package manager caches by size cap and by \
which repositories still need them. Neither applies to {target}: an image \
belongs to no repository and `cache_max_gb` does not cover one. Run `devp \
caches clear {target}` on its own."
))));
}
return crate::commands::containers::run_clear(
target,
include_volumes,
yes,
dry_run,
json_output,
);
}
if targets.len() > 1
&& let Some(engine) = targets
.iter()
.find(|t| crate::commands::containers::is_engine(t))
{
return Err(anyhow::Error::new(crate::UsageError(format!(
"`{engine}` is a container engine, and an engine is cleared by naming it \
alone: `devp caches clear {engine}`. Run that separately from the \
package-manager list."
))));
}
for t in &targets {
if !all && !PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(t)) {
return Err(anyhow::Error::new(crate::UsageError(format!(
"`{t}` is not a manager dev-prune knows a cache for. Try one of: {}, or `all`.",
known_managers().join(", ")
))));
}
}
for e in &excepts {
if crate::commands::containers::is_engine(e) {
return Err(anyhow::Error::new(crate::UsageError(format!(
"`all` never reaches a container engine, so there is no `{e}` to leave \
out. Drop it from `--except`."
))));
}
if !PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(e)) {
return Err(anyhow::Error::new(crate::UsageError(format!(
"`{e}` is not a manager dev-prune knows a cache for, so `--except` has \
nothing to leave out. Known managers: {}.",
known_managers().join(", ")
))));
}
}
for t in &targets {
if !all
&& let Some(probe) = manual_only(t)
&& let Clear::Manual { why } = probe.clear
{
return Err(anyhow::Error::new(crate::UsageError(format!(
"{why} The command is: {}",
probe.clear_command
))));
}
}
if json_output && !yes && !dry_run {
return Err(anyhow::Error::new(crate::UsageError(
"`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
to see what would go."
.to_string(),
)));
}
let reg = registered();
let mut measured = collect(!json_output, reg.as_ref());
apply_caps(&mut measured, &caps());
let deps = if unused {
let Some(reg) = reg.as_ref() else {
return Err(anyhow::Error::new(crate::UsageError(
"`--unused` empties the caches no registered repository needs, and there are no \
registered repositories on this disk to check against — every cache would look \
unused. Register what you keep with `devp link` first."
.to_string(),
)));
};
Some(dependents(reg, !json_output))
} else {
None
};
apply_dependents(&mut measured, deps.as_ref());
let (reports, kept): (Vec<CacheReport>, Vec<CacheReport>) = measured
.into_iter()
.filter(|r| {
if all {
!excepts.iter().any(|e| r.manager.eq_ignore_ascii_case(e))
} else {
targets.iter().any(|t| r.manager.eq_ignore_ascii_case(t))
}
})
.filter(|r| !over_cap || r.over_cap)
.filter(|r| !unused || r.dependents == Some(0))
.partition(|r| !matches!(r.clear, Clear::Manual { .. }));
if reports.is_empty() {
if json_output {
return json::emit(&json::caches_clear_plan_document(&reports, &kept));
}
if unused {
output::print_info(
"Every cache on this machine is used by at least one registered repository, or \
is one dev-prune cannot attribute to any — nothing to clear.",
);
return Ok(());
}
if over_cap {
output::print_info(if caps().is_empty() {
"No cache size caps are set, so nothing is over one. Set one for every manager \
with `devp config set cache_max_gb default=10`, or pick them out one at a time \
in `devp config wizard`."
} else {
"Every capped cache is under its cap — nothing to clear."
});
return Ok(());
}
output::print_info(&format!(
"No {} cache on this machine — nothing to clear.",
if all {
"package manager".to_string()
} else {
targets.join(", ")
}
));
return Ok(());
}
if dry_run {
if json_output {
return json::emit(&json::caches_clear_plan_document(&reports, &kept));
}
print_kept(&kept);
print_clear_plan(&reports, true);
return Ok(());
}
if !json_output {
print_kept(&kept);
print_clear_plan(&reports, false);
if !confirm_clear(yes) {
output::print_info("Nothing was cleared.");
return Ok(());
}
}
let outcomes: Vec<ClearOutcome> = reports.iter().map(clear_one).collect();
record_cache_clear(outcomes.iter().map(ClearOutcome::freed).sum());
if json_output {
json::emit(&json::caches_clear_document(&outcomes, &kept))?;
} else {
print_clear_result(&outcomes);
}
let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
if failed > 0 {
anyhow::bail!(
"{failed} {} could not be cleared.",
output::plural(failed, "cache", "caches")
);
}
Ok(())
}
fn manual_only(target: &str) -> Option<&'static Probe> {
let matching: Vec<&Probe> = PROBES
.iter()
.filter(|p| p.manager.eq_ignore_ascii_case(target))
.collect();
if matching.is_empty()
|| matching
.iter()
.any(|p| !matches!(p.clear, Clear::Manual { .. }))
{
return None;
}
matching.first().copied()
}
pub fn is_cache_manager(name: &str) -> bool {
PROBES.iter().any(|p| p.manager.eq_ignore_ascii_case(name))
}
pub fn known_managers() -> Vec<&'static str> {
let mut names: Vec<&'static str> = Vec::new();
for probe in PROBES {
if !names.contains(&probe.manager) {
names.push(probe.manager);
}
}
names
}
fn clear_one(report: &CacheReport) -> ClearOutcome {
let problem = match report.clear {
Clear::Command(program, args) => run_clear_command(program, args, &report.extra_args),
Clear::Directory => remove_cache_dir(&report.path),
Clear::Manual { why } => Some(why.to_string()),
};
ClearOutcome {
manager: report.manager,
kind: report.kind,
path: report.path.clone(),
before: report.bytes,
after: adapters::dir_size(&report.path),
problem,
}
}
fn run_clear_command(program: &str, args: &[&str], extra: &[String]) -> Option<String> {
if !adapters::binary_available(program) {
return Some(format!(
"`{program}` is not on PATH — only it knows what in this cache is still \
referenced, so dev-prune will not delete the directory in its place."
));
}
let mut all: Vec<&str> = args.to_vec();
all.extend(extra.iter().map(String::as_str));
adapters::run_command_with_timeout(
program,
&all,
&query_dir(),
std::time::Duration::from_secs(constants::CACHE_CLEAR_TIMEOUT_SECS),
)
.err()
.map(|e| format!("{e:#}"))
}
fn remove_cache_dir(path: &Path) -> Option<String> {
std::fs::remove_dir_all(path)
.or_else(|_| {
std::thread::sleep(std::time::Duration::from_millis(250));
std::fs::remove_dir_all(path)
})
.err()
.filter(|e| e.kind() != std::io::ErrorKind::NotFound)
.map(|e| format!("{} could not be removed: {e}", output::clean_path(path)))
}
fn print_kept(kept: &[CacheReport]) {
for r in kept {
let Clear::Manual { why } = r.clear else {
continue;
};
println!();
output::print_info(&format!(
"Keeping {} {} ({} at {}). {why}",
r.manager,
r.kind,
output::format_bytes(r.bytes),
output::clean_path(&r.path)
));
}
}
fn print_clear_plan(reports: &[CacheReport], dry_run: bool) {
output::print_header(if dry_run {
i18n::t("caches.header.would_clear")
} else {
i18n::t("caches.header.about_to_clear")
});
println!();
for r in reports {
println!(
" {:<30} {:>10} {}",
format!("{} {}", r.manager, r.kind),
output::format_bytes(r.bytes),
output::clean_path(&r.path)
);
println!(" {:<30} {:>10} via: {}", "", "", r.clear_command);
}
println!();
let total: u64 = reports.iter().map(|r| r.bytes).sum();
println!(
" {:<30} {:>10} across {} {}",
"Total",
output::format_bytes(total),
reports.len(),
output::plural(reports.len(), "cache", "caches")
);
println!();
output::print_info(
"Nothing in a cache is lost — every manager above re-downloads what it needs. \
The cost is time: the next install, and the next `devp restore`, in every \
project on this machine.",
);
}
fn record_cache_clear(bytes: u64) {
if bytes == 0 {
return;
}
if let Ok(mut registry) = crate::config::Registry::load() {
registry.record_cache_clear(bytes);
let _ = registry.save();
}
}
const REINSTALL_HINTS: &[(&str, &str)] = &[
("playwright", "npx playwright install"),
("puppeteer", "npx puppeteer browsers install"),
("cypress", "npx cypress install"),
];
fn print_clear_result(outcomes: &[ClearOutcome]) {
println!();
for o in outcomes {
let label = format!("{} {}", o.manager, o.kind);
println!(
" {:<30} {:>10} {}",
label,
output::format_bytes(o.freed()),
if o.problem.is_some() {
"not cleared"
} else {
"cleared"
}
);
if let Some(why) = &o.problem {
println!(" {:<30} {:>10} {why}", "", "");
}
}
println!();
let freed: u64 = outcomes.iter().map(ClearOutcome::freed).sum();
output::print_success(&format!("Freed {}.", output::format_bytes(freed)));
for (manager, command) in REINSTALL_HINTS {
if outcomes
.iter()
.any(|o| o.manager == *manager && o.problem.is_none())
{
output::print_info(&format!("{manager} comes back with `{command}`."));
}
}
}
pub fn confirm_clear(yes: bool) -> bool {
use std::io::{IsTerminal, Write};
if yes {
return true;
}
if !std::io::stdin().is_terminal() {
output::print_info("Not running in a terminal — pass `--yes` to clear these.");
return false;
}
eprint!("Clear them? [y/N]: ");
if std::io::stderr().flush().is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_probe_can_be_found_without_its_manager_installed() {
for probe in PROBES {
assert!(
!fallbacks(probe.manager, probe.kind).is_empty(),
"{} {} has no conventional location",
probe.manager,
probe.kind
);
}
}
#[test]
fn every_reinstall_hint_names_a_probed_manager() {
for (manager, command) in REINSTALL_HINTS {
assert!(
PROBES.iter().any(|p| p.manager == *manager),
"reinstall hint for {manager} ({command}) matches no probe"
);
}
}
#[test]
fn every_probe_names_the_command_that_clears_it() {
for probe in PROBES {
assert!(
!probe.clear_command.trim().is_empty(),
"{} {} reports a size with no way to act on it",
probe.manager,
probe.kind
);
}
}
#[test]
fn the_probed_managers_with_no_adapter_of_the_same_name_are_pinned() {
let orphans: Vec<&str> = PROBES
.iter()
.map(|p| p.manager)
.filter(|m| !adapters::is_adapter_name(m))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
assert_eq!(
orphans,
[
"ccache",
"conan",
"conda",
"cypress",
"electron",
"hex",
"huggingface",
"nuget",
"pip",
"playwright",
"puppeteer",
"sccache",
]
);
}
#[test]
fn no_two_probes_describe_the_same_cache() {
let mut keys: Vec<(&str, &str)> = PROBES.iter().map(|p| (p.manager, p.kind)).collect();
let count = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), count, "two probes share a manager and kind");
}
#[test]
fn a_managers_answer_is_read_off_the_last_line() {
let raw = if cfg!(windows) {
"npm warn config global deprecated\nC:\\Users\\dev\\AppData\\Local\\npm-cache\n"
} else {
"npm warn config global deprecated\n/home/dev/.npm\n"
};
assert!(path_from_output(raw).is_some());
}
#[test]
fn quoted_paths_lose_their_quotes() {
let raw = if cfg!(windows) {
"\"C:\\Program Files\\go\\pkg\\mod\"\n"
} else {
"\"/opt/go path/pkg/mod\"\n"
};
let path = path_from_output(raw).expect("a quoted path is still a path");
assert!(!path.to_string_lossy().contains('"'));
}
#[test]
fn a_non_answer_is_not_mistaken_for_a_path() {
for raw in [
"",
"\n \n",
"undefined\n",
"not a command\n",
"./relative\n",
] {
assert!(
path_from_output(raw).is_none(),
"{raw:?} was accepted as a cache path"
);
}
}
#[test]
fn the_cargo_rows_point_inside_the_registry() {
for kind in ["registry cache", "registry sources"] {
let path = fallbacks("cargo", kind).remove(0);
assert!(
path.starts_with(cargo_home().join("registry")),
"{kind} resolved outside the cargo registry: {}",
path.display()
);
}
}
#[test]
fn the_conda_row_points_at_the_package_cache_and_not_the_installation() {
let home = dirs::home_dir().expect("a home directory");
let found = fallbacks("conda", "package cache");
for install in [
"miniconda3",
"anaconda3",
"miniforge3",
"mambaforge",
".conda",
] {
let want = home.join(install).join("pkgs");
assert!(
found.contains(&want),
"{} is not among conda's conventional locations",
want.display()
);
assert!(
!found.contains(&home.join(install)),
"{} is the installation, not its package cache",
home.join(install).display()
);
}
}
#[test]
fn the_report_is_ordered_by_what_is_worth_clearing() {
let mut reports = [
CacheReport {
manager: "npm",
kind: "cache",
path: PathBuf::from("/a"),
bytes: 10,
clear_command: "x".to_string(),
clear: Clear::Command("npm", &["cache"]),
note: None,
cap_gb: None,
over_cap: false,
dependents: None,
extra_args: Vec::new(),
},
CacheReport {
manager: "go",
kind: "module cache",
path: PathBuf::from("/b"),
bytes: 4_000,
clear_command: "y".to_string(),
clear: Clear::Directory,
note: None,
cap_gb: None,
over_cap: false,
dependents: None,
extra_args: Vec::new(),
},
];
reports.sort_by_key(|r| std::cmp::Reverse(r.bytes));
assert_eq!(reports[0].manager, "go");
}
fn sized(manager: &'static str, bytes: u64, dependents: Option<usize>) -> CacheReport {
CacheReport {
manager,
kind: "cache",
path: PathBuf::from("/x"),
bytes,
clear_command: "x".to_string(),
clear: Clear::Directory,
note: None,
cap_gb: None,
over_cap: false,
dependents,
extra_args: Vec::new(),
}
}
#[test]
fn the_costliest_cache_per_repository_is_not_the_biggest_one() {
let reports = [
sized("npm", 10_240, Some(18)),
sized("pnpm", 2_048, Some(1)),
sized("cargo", 300, Some(2)),
sized("cargo", 100, Some(2)),
sized("bun", 512, Some(0)),
sized("nuget", 900, None),
];
assert_eq!(
costliest_per_repository(&reports),
vec![("pnpm", 2_048), ("npm", 568), ("cargo", 200)],
);
}
#[test]
fn every_probe_clears_with_the_command_it_prints() {
for probe in PROBES {
let printed = probe.clear_command;
match probe.clear {
Clear::Command(program, args) => {
assert!(
printed.starts_with(program),
"{} {} prints `{printed}` but runs `{program}`",
probe.manager,
probe.kind
);
for arg in args {
assert!(
printed.contains(arg.trim_matches('"')),
"{} {} prints `{printed}` but passes `{arg}`",
probe.manager,
probe.kind
);
}
}
Clear::Directory | Clear::Manual { .. } => assert!(
printed.contains("rm -rf") || printed.contains("Remove-Item"),
"{} {} deletes a directory but prints `{printed}`",
probe.manager,
probe.kind
),
}
}
}
#[test]
fn the_maven_local_repository_is_never_emptied_by_dev_prune() {
let maven: Vec<&Probe> = PROBES.iter().filter(|p| p.manager == "maven").collect();
assert!(!maven.is_empty(), "maven is no longer reported at all");
for probe in maven {
assert!(
matches!(probe.clear, Clear::Manual { .. }),
"maven {} would be emptied by dev-prune",
probe.kind
);
}
}
#[test]
fn a_manual_report_that_reaches_the_clear_deletes_nothing() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("app-1.0-SNAPSHOT.jar");
std::fs::write(&artifact, b"nowhere else").unwrap();
let outcome = clear_one(&CacheReport {
manager: "maven",
kind: "local repository",
path: dir.path().to_path_buf(),
bytes: 12,
clear_command: MAVEN_REPO_CLEAR.to_string(),
clear: Clear::Manual { why: MAVEN_MANUAL },
note: None,
cap_gb: None,
over_cap: false,
dependents: None,
extra_args: Vec::new(),
});
assert!(artifact.exists(), "the store was emptied after all");
assert!(
outcome.problem.is_some(),
"it reported success without doing anything"
);
}
#[test]
fn clearing_a_manual_only_manager_explains_itself_instead_of_reporting_nothing() {
let err = run_clear("maven", None, false, false, false, true, true, false).unwrap_err();
assert!(
err.downcast_ref::<crate::UsageError>().is_some(),
"expected a usage error, got: {err:#}"
);
let text = format!("{err}");
assert!(
text.contains("local repository") && text.contains(MAVEN_REPO_CLEAR),
"the refusal names neither the reason nor the command: {text}"
);
}
#[test]
fn every_manager_in_the_report_can_be_named_to_clear() {
let names = known_managers();
for probe in PROBES {
assert!(
names.contains(&probe.manager),
"{} is reported but `devp caches clear {}` would not find it",
probe.manager,
probe.manager
);
}
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len(), "repeated manager in {names:?}");
}
#[test]
fn an_unknown_manager_is_a_usage_error() {
let err = run_clear("nonesuch", None, false, false, false, true, true, false).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn a_manual_only_manager_in_a_list_gets_the_same_refusal_as_alone() {
let err = run_clear("npm,maven", None, false, false, false, true, true, false).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
let text = format!("{err}");
assert!(
text.contains(MAVEN_REPO_CLEAR),
"the refusal does not name the command: {text}"
);
}
#[test]
fn all_inside_a_list_is_a_usage_error() {
let err = run_clear("npm,all", None, false, false, false, true, true, false).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
let text = format!("{err}");
assert!(
text.contains("--except"),
"the error should point at the fix: {text}"
);
}
#[test]
fn except_with_a_named_target_is_a_usage_error() {
let err = run_clear("npm", Some("uv"), false, false, false, true, true, false).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn an_unknown_name_in_except_is_a_usage_error() {
let err = run_clear(
"all",
Some("nonesuch"),
false,
false,
false,
true,
true,
false,
)
.unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn an_engine_in_a_list_is_refused_with_the_standalone_command() {
let err =
run_clear("npm,docker", None, false, false, false, true, true, false).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
let text = format!("{err}");
assert!(
text.contains("devp caches clear docker"),
"the error should name the command that does work: {text}"
);
}
#[test]
fn an_engine_in_except_is_a_usage_error() {
let err = run_clear(
"all",
Some("docker"),
false,
false,
false,
true,
true,
false,
)
.unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn a_target_of_only_commas_names_nothing() {
let err = run_clear(",", None, false, false, false, true, true, false).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn json_without_yes_is_a_usage_error_rather_than_a_prompt() {
let err = run_clear("npm", None, false, false, false, false, false, true).unwrap_err();
assert!(err.downcast_ref::<crate::UsageError>().is_some());
}
#[test]
fn removing_a_directory_reports_nothing_when_it_worked() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("cache");
std::fs::create_dir(&cache).unwrap();
std::fs::write(cache.join("blob"), b"x").unwrap();
assert!(remove_cache_dir(&cache).is_none());
assert!(!cache.exists());
assert!(remove_cache_dir(&cache).is_none());
}
#[test]
fn clearing_a_directory_reports_what_actually_went() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("store");
std::fs::create_dir(&cache).unwrap();
std::fs::write(cache.join("blob"), vec![0u8; 4096]).unwrap();
let before = adapters::dir_size(&cache);
let outcome = clear_one(&CacheReport {
manager: "cargo",
kind: "registry cache",
path: cache.clone(),
bytes: before,
clear_command: "rm -rf".to_string(),
clear: Clear::Directory,
note: None,
cap_gb: None,
over_cap: false,
dependents: None,
extra_args: Vec::new(),
});
assert!(outcome.problem.is_none());
assert_eq!(outcome.after, 0);
assert_eq!(outcome.freed(), before);
assert!(!cache.exists());
}
#[test]
fn a_manager_that_is_not_installed_is_reported_rather_than_deleted_around() {
let problem = run_clear_command("dev-prune-no-such-manager", &["cache", "clean"], &[]);
assert!(problem.is_some_and(|p| p.contains("not on PATH")));
}
fn row(manager: &'static str, kind: &'static str, gib: u64) -> CacheReport {
CacheReport {
manager,
kind,
path: PathBuf::from("/cache").join(manager).join(kind),
bytes: gib * crate::constants::BYTES_PER_GIB,
clear_command: "x".to_string(),
clear: Clear::Directory,
note: None,
cap_gb: None,
over_cap: false,
dependents: None,
extra_args: Vec::new(),
}
}
fn counted(repositories: usize, counts: &[(&'static str, usize)]) -> Dependents {
Dependents {
repositories,
by_manager: counts.iter().copied().collect(),
}
}
#[test]
fn a_cache_no_adapter_is_named_after_is_left_unanswered_rather_than_zeroed() {
let mut reports = vec![row("npm", "cache", 1), row("pip", "cache", 1)];
apply_dependents(&mut reports, Some(&counted(4, &[("npm", 2)])));
assert_eq!(reports[0].dependents, Some(2));
assert_eq!(
reports[1].dependents, None,
"pip has no adapter of its name, so there is nothing to count"
);
}
#[test]
fn no_registry_leaves_every_count_unanswered() {
let mut reports = vec![row("npm", "cache", 1), row("go", "module cache", 1)];
apply_dependents(&mut reports, None);
assert!(reports.iter().all(|r| r.dependents.is_none()));
}
#[test]
fn a_manager_nothing_uses_is_a_counted_zero() {
let mut reports = vec![row("go", "module cache", 3)];
apply_dependents(&mut reports, Some(&counted(9, &[("go", 0)])));
assert_eq!(reports[0].dependents, Some(0));
assert!(
used_by(&reports[0], 0, None, &manager_totals(&reports))
.contains("no registered repository uses go")
);
}
#[test]
fn the_per_repository_share_is_the_managers_whole_footprint() {
let reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
let line = used_by(
&reports[0],
2,
Some(&counted(2, &[("cargo", 2)])),
&manager_totals(&reports),
);
assert!(
line.contains("cargo is used by 2 of 2 registered repositories")
&& line.contains("6 GiB each across its 2 caches"),
"{line}"
);
}
#[test]
fn one_cache_does_not_say_how_many_it_summed() {
let reports = vec![row("bun", "cache", 4)];
let line = used_by(
&reports[0],
2,
Some(&counted(2, &[("bun", 2)])),
&manager_totals(&reports),
);
assert!(line.ends_with("2 GiB each"), "{line}");
}
#[test]
fn a_volume_root_is_an_ancestor_of_what_sits_on_it() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let root = volume_root(&nested).expect("a real directory sits on some filesystem");
assert!(
nested.starts_with(&root),
"{} is not under {}",
nested.display(),
root.display()
);
assert!(root.is_dir(), "{} is not a directory", root.display());
}
#[cfg(windows)]
#[test]
fn a_windows_volume_root_is_the_drive_and_nothing_more() {
let root = volume_root(Path::new(r"V:\Code\ProjectCode")).unwrap();
assert_eq!(root, PathBuf::from("V:\\"));
assert_eq!(volume_root(Path::new(r"Code\ProjectCode")), None);
}
#[cfg(windows)]
#[test]
fn the_drive_someone_types_and_the_drive_on_disk_are_the_same_drive() {
assert!(same_volume(Path::new(r"\\?\C:\"), Path::new(r"C:\")));
assert!(same_volume(Path::new(r"c:\"), Path::new(r"C:\")));
assert!(!same_volume(Path::new(r"C:\"), Path::new(r"V:\")));
}
#[cfg(windows)]
#[test]
fn a_bare_drive_letter_is_what_people_type_and_has_to_resolve() {
for typed in ["V", "v", "V:", "v:", r"V:\", r"V:\Code\ProjectCode"] {
assert_eq!(
resolve_volume(typed).unwrap(),
PathBuf::from(r"V:\"),
"{typed}"
);
}
assert!(resolve_volume("not-a-path-at-all").is_err());
}
#[test]
fn the_drive_you_are_standing_on_is_a_dot() {
let here = std::env::current_dir().unwrap();
assert_eq!(resolve_volume(".").unwrap(), volume_root(&here).unwrap());
assert!(resolve_volume("definitely-not-a-drive").is_err());
}
#[test]
fn narrowing_to_one_drive_keeps_only_what_is_on_it() {
let dir = tempfile::tempdir().unwrap();
let here = dir.path().join("cache");
std::fs::create_dir_all(&here).unwrap();
let root = volume_root(&here).unwrap();
let mut on_this_one = sized("pnpm", 2_048, Some(1));
on_this_one.path = here;
let mut nowhere = sized("npm", 10_240, Some(18));
nowhere.path = PathBuf::from("relative/and/unresolvable");
let kept = on_volume(vec![on_this_one, nowhere], &root);
assert_eq!(
kept.len(),
1,
"a row whose drive is unknown is not on this one"
);
assert_eq!(kept[0].manager, "pnpm");
}
#[test]
fn the_per_drive_line_adds_up_and_leads_with_the_biggest() {
let dir = tempfile::tempdir().unwrap();
let here = dir.path().join("cache");
std::fs::create_dir_all(&here).unwrap();
let mut small = sized("pnpm", 100, None);
small.path = here.clone();
let mut large = sized("npm", 900, None);
large.path = here;
let mut unknown = sized("uv", 500, None);
unknown.path = PathBuf::from("relative/and/unresolvable");
let totals = volume_totals(&[small, large, unknown]);
assert_eq!(totals.len(), 1);
assert_eq!(totals[0].1, 1_000);
}
#[test]
fn one_volume_is_listed_once_however_many_repositories_are_on_it() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("one");
let b = dir.path().join("two");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
assert_eq!(volume_roots(&[a.clone(), b, a]).len(), 1);
assert!(volume_roots(&[]).is_empty());
}
#[test]
fn a_volume_stores_printed_command_is_the_one_that_runs() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join(".pnpm-store");
std::fs::create_dir_all(&store).unwrap();
let report = volume_store_report(store.clone());
let named = output::clean_path(&store);
assert_eq!(
report.extra_args,
vec!["--store-dir".to_string(), named.clone()]
);
assert!(
report.clear_command.contains(&named),
"the printed command does not name the store: {}",
report.clear_command
);
assert!(matches!(
report.clear,
Clear::Command("pnpm", ["store", "prune"])
));
}
#[test]
fn only_a_path_with_a_space_in_it_is_quoted() {
assert_eq!(shell_arg("/mnt/data/.pnpm-store"), "/mnt/data/.pnpm-store");
assert_eq!(
shell_arg("/mnt/my data/.pnpm-store"),
"\"/mnt/my data/.pnpm-store\""
);
}
#[test]
fn a_cap_is_measured_against_the_managers_whole_footprint() {
let mut reports = vec![row("cargo", "registry", 6), row("cargo", "sources", 6)];
apply_caps(&mut reports, &BTreeMap::from([("cargo".to_string(), 10)]));
assert!(
reports.iter().all(|r| r.over_cap),
"both rows belong to the manager that went over"
);
assert!(reports.iter().all(|r| r.cap_gb == Some(10)));
}
#[test]
fn a_manager_under_its_cap_is_marked_with_the_cap_and_nothing_else() {
let mut reports = vec![row("npm", "cache", 3)];
apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
assert_eq!(reports[0].cap_gb, Some(10));
assert!(!reports[0].over_cap);
}
#[test]
fn the_default_cap_covers_a_manager_nobody_named() {
let caps = BTreeMap::from([(constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10)]);
let mut reports = vec![row("uv", "cache", 40), row("npm", "cache", 3)];
apply_caps(&mut reports, &caps);
assert!(reports[0].over_cap);
assert_eq!(reports[1].cap_gb, Some(10));
assert!(!reports[1].over_cap);
}
#[test]
fn a_manager_named_outright_is_not_also_held_to_the_default() {
let caps = BTreeMap::from([
(constants::CACHE_CAP_DEFAULT_KEY.to_string(), 10),
("npm".to_string(), 4),
]);
let mut reports = vec![row("npm", "cache", 5)];
apply_caps(&mut reports, &caps);
assert_eq!(reports[0].cap_gb, Some(4));
assert!(reports[0].over_cap);
}
#[test]
fn a_manager_with_no_cap_is_never_called_too_big() {
let mut reports = vec![row("uv", "cache", 40)];
apply_caps(&mut reports, &BTreeMap::new());
assert_eq!(reports[0].cap_gb, None);
assert!(!reports[0].over_cap);
}
#[test]
fn one_managers_cap_says_nothing_about_another() {
let mut reports = vec![row("npm", "cache", 12), row("go", "module cache", 12)];
apply_caps(&mut reports, &BTreeMap::from([("npm".to_string(), 10)]));
assert!(reports[0].over_cap);
assert!(
!reports[1].over_cap,
"go has no cap and did not acquire npm's"
);
}
#[test]
fn exactly_at_the_cap_is_not_over_it() {
let mut reports = vec![row("pnpm", "store", 10)];
apply_caps(&mut reports, &BTreeMap::from([("pnpm".to_string(), 10)]));
assert!(!reports[0].over_cap);
}
#[test]
fn every_cache_manager_answers_to_its_own_name() {
for probe in PROBES {
assert!(
is_cache_manager(probe.manager),
"{} is reported but cannot be capped",
probe.manager
);
}
assert!(!is_cache_manager("dev-prune-no-such-manager"));
}
}