use crate::{apps::App, config::Config, guard};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use walkdir::WalkDir;
pub fn roots() -> Vec<PathBuf> {
dirs::home_dir()
.map(|h| vec![h.join("Library/Caches"), h.join("Library/Logs")])
.unwrap_or_default()
.into_iter()
.filter(|p| p.is_dir())
.collect()
}
fn is_logs_root(root: &Path) -> bool {
root.ends_with("Library/Logs")
}
#[derive(Debug, Clone)]
pub enum Verdict {
Clear,
Skipped(String),
}
#[derive(Debug, Clone)]
pub struct Bucket {
pub root: PathBuf,
pub name: String,
pub total: u64,
pub eligible: u64,
pub verdict: Verdict,
victims: Vec<PathBuf>,
}
#[derive(Debug, Default)]
pub struct Sweep {
pub freed: u64,
pub files: usize,
pub errors: Vec<String>,
}
pub fn scan(cfg: &Config, running: &[App]) -> Vec<Bucket> {
let skips = cfg.caches.effective_skips();
let cutoff = SystemTime::now()
.checked_sub(Duration::from_secs(cfg.caches.min_age_days * 86_400))
.unwrap_or(SystemTime::UNIX_EPOCH);
let mut buckets: Vec<Bucket> = roots()
.iter()
.flat_map(|root| {
std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| (root.clone(), e))
.collect::<Vec<_>>()
})
.filter_map(|(root, entry)| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.symlink_metadata().ok()?.file_type().is_symlink() {
return None;
}
if !path.is_dir() {
return None;
}
Some(Bucket {
verdict: verdict_for(&name, &root, &skips, running, cfg),
root,
name,
total: 0,
eligible: 0,
victims: Vec::new(),
})
})
.collect();
let workers = std::thread::available_parallelism().map_or(4, |n| n.get());
let queue = std::sync::Mutex::new(
buckets
.iter_mut()
.filter(|b| matches!(b.verdict, Verdict::Clear))
.collect::<Vec<_>>(),
);
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
loop {
let Some(b) = queue.lock().unwrap().pop() else {
break;
};
measure(b, cutoff);
}
});
}
});
buckets.sort_by_key(|b| std::cmp::Reverse(b.eligible));
buckets
}
fn verdict_for(
name: &str,
root: &Path,
skips: &[String],
running: &[App],
cfg: &Config,
) -> Verdict {
if guard::cache_is_never(name) {
return Verdict::Skipped("holds live state".into());
}
if skips.iter().any(|s| s.eq_ignore_ascii_case(name)) {
return Verdict::Skipped("skipped by default or config".into());
}
let allowed = cfg
.caches
.allow
.iter()
.any(|a| a.eq_ignore_ascii_case(name));
if !is_logs_root(root) && !allowed && !crate::apps::is_installed_app(name) {
return Verdict::Skipped("not an installed app's cache".into());
}
if cfg.caches.skip_running_apps
&& let Some(app) = belongs_to_running(name, running)
{
return Verdict::Skipped(format!("{} is running", app.name));
}
Verdict::Clear
}
fn belongs_to_running<'a>(name: &str, running: &'a [App]) -> Option<&'a App> {
running.iter().find(|a| {
guard::identity_matches(&a.identities(), name)
|| a.bundle_id
.as_deref()
.is_some_and(|id| name.len() > id.len() && name.starts_with(&format!("{id}.")))
})
}
fn measure(bucket: &mut Bucket, cutoff: SystemTime) {
let dir = bucket.root.join(&bucket.name);
let collecting = matches!(bucket.verdict, Verdict::Clear);
for entry in WalkDir::new(&dir).follow_links(false).into_iter().flatten() {
let Ok(md) = entry.path().symlink_metadata() else {
continue;
};
if !md.is_file() {
continue;
}
bucket.total += md.len();
if collecting && is_stale(&md, cutoff) {
bucket.eligible += md.len();
bucket.victims.push(entry.path().to_path_buf());
}
}
}
fn is_stale(md: &std::fs::Metadata, cutoff: SystemTime) -> bool {
let old = |t: std::io::Result<SystemTime>| t.is_ok_and(|t| t < cutoff);
old(md.modified()) && old(md.accessed())
}
pub fn sweep(buckets: &[Bucket], dry_run: bool) -> Sweep {
let mut out = Sweep::default();
for bucket in buckets
.iter()
.filter(|b| matches!(b.verdict, Verdict::Clear))
{
for victim in &bucket.victims {
match remove(&bucket.root, victim, dry_run) {
Ok(freed) => {
out.freed += freed;
out.files += 1;
}
Err(e) if is_gone(&e) => {}
Err(e) => out.errors.push(format!("{}: {e}", victim.display())),
}
}
if !dry_run {
prune_empty_dirs(&bucket.root, &bucket.root.join(&bucket.name));
}
}
out
}
fn remove(root: &Path, victim: &Path, dry_run: bool) -> Result<u64> {
let safe = guard::vet_path(root, victim)?;
let md = safe.symlink_metadata()?;
if !md.is_file() {
anyhow::bail!("no longer a regular file");
}
if !dry_run {
std::fs::remove_file(&safe)?;
}
Ok(md.len())
}
fn is_gone(e: &anyhow::Error) -> bool {
e.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
}
fn prune_empty_dirs(root: &Path, dir: &Path) {
let dirs: Vec<PathBuf> = WalkDir::new(dir)
.follow_links(false)
.contents_first(true)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_dir())
.map(|e| e.path().to_path_buf())
.collect();
for d in dirs {
if let Ok(safe) = guard::vet_path(root, &d) {
std::fs::remove_dir(&safe).ok();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::Duration;
struct Tmp(PathBuf);
impl Drop for Tmp {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).ok();
}
}
fn fixture(tag: &str) -> Tmp {
let p = std::env::temp_dir()
.canonicalize()
.unwrap()
.join(format!("amph-cache-{tag}"));
fs::remove_dir_all(&p).ok();
fs::create_dir_all(&p).unwrap();
Tmp(p)
}
fn write_aged(path: &Path, bytes: usize, days_old: u64) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, vec![b'x'; bytes]).unwrap();
let when = SystemTime::now() - Duration::from_secs(days_old * 86_400);
let ft = std::fs::FileTimes::new()
.set_modified(when)
.set_accessed(when);
fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_times(ft)
.unwrap();
}
#[test]
fn only_stale_files_are_eligible() {
let t = fixture("stale");
let bucket_dir = t.0.join("com.example.app");
write_aged(&bucket_dir.join("old.bin"), 4096, 30);
write_aged(&bucket_dir.join("fresh.bin"), 8192, 0);
let mut b = Bucket {
root: t.0.clone(),
name: "com.example.app".into(),
total: 0,
eligible: 0,
verdict: Verdict::Clear,
victims: Vec::new(),
};
measure(&mut b, SystemTime::now() - Duration::from_secs(7 * 86_400));
assert_eq!(b.total, 4096 + 8192);
assert_eq!(b.eligible, 4096, "only the 30-day-old file should qualify");
assert_eq!(b.victims.len(), 1);
assert!(b.victims[0].ends_with("old.bin"));
}
#[test]
fn sweep_deletes_only_victims_and_dry_run_deletes_nothing() {
let t = fixture("sweep");
let bucket_dir = t.0.join("com.example.app");
write_aged(&bucket_dir.join("old.bin"), 4096, 30);
write_aged(&bucket_dir.join("fresh.bin"), 8192, 0);
let mut b = Bucket {
root: t.0.clone(),
name: "com.example.app".into(),
total: 0,
eligible: 0,
verdict: Verdict::Clear,
victims: Vec::new(),
};
measure(&mut b, SystemTime::now() - Duration::from_secs(7 * 86_400));
let dry = sweep(std::slice::from_ref(&b), true);
assert_eq!(dry.freed, 4096);
assert!(
bucket_dir.join("old.bin").exists(),
"dry run must not delete"
);
let wet = sweep(std::slice::from_ref(&b), false);
assert_eq!(wet.freed, 4096);
assert!(wet.errors.is_empty());
assert!(!bucket_dir.join("old.bin").exists());
assert!(
bucket_dir.join("fresh.bin").exists(),
"fresh file must survive"
);
}
#[test]
fn sweep_cannot_delete_through_a_symlink() {
let t = fixture("symlink");
let outside = t.0.join("precious");
fs::create_dir_all(&outside).unwrap();
write_aged(&outside.join("data.db"), 1024, 90);
let root = t.0.join("root");
let bucket_dir = root.join("com.example.app");
fs::create_dir_all(&bucket_dir).unwrap();
std::os::unix::fs::symlink(&outside, bucket_dir.join("link")).unwrap();
let mut b = Bucket {
root: root.clone(),
name: "com.example.app".into(),
total: 0,
eligible: 0,
verdict: Verdict::Clear,
victims: Vec::new(),
};
measure(&mut b, SystemTime::now() - Duration::from_secs(7 * 86_400));
sweep(std::slice::from_ref(&b), false);
assert!(
outside.join("data.db").exists(),
"symlinked-to data was deleted"
);
}
fn caches_root() -> PathBuf {
dirs::home_dir().unwrap().join("Library/Caches")
}
fn logs_root() -> PathBuf {
dirs::home_dir().unwrap().join("Library/Logs")
}
fn running(bundle: &str, name: &str) -> App {
App {
pid: 90_001,
uid: 501,
bundle_id: Some(bundle.into()),
name: name.into(),
rss: 0,
foreground: true,
nested: false,
}
}
#[test]
fn live_state_buckets_are_never_cleared_even_if_allowed() {
let cfg = Config {
caches: crate::config::Caches {
allow: vec!["CloudKit".into()],
..Default::default()
},
..Default::default()
};
assert!(matches!(
verdict_for(
"CloudKit",
&caches_root(),
&cfg.caches.effective_skips(),
&[],
&cfg
),
Verdict::Skipped(_)
));
}
#[test]
fn running_apps_shield_their_own_cache_and_their_updater() {
let cfg = Config::default();
let apps = vec![running("com.apple.finder", "Finder")];
assert!(matches!(
verdict_for("com.apple.finder", &caches_root(), &[], &apps, &cfg),
Verdict::Skipped(_)
));
assert!(matches!(
verdict_for("com.apple.finder.ShipIt", &caches_root(), &[], &apps, &cfg),
Verdict::Skipped(_)
));
}
#[test]
fn build_tool_caches_are_never_touched() {
let cfg = Config::default();
let root = caches_root();
for tool in [
"go-build",
"goimports",
"gopls",
"Homebrew",
"pnpm",
"pip",
"cargo-xwin",
"org.swift.swiftpm",
"com.github.peripheryapp",
"ms-playwright",
"grype",
"trivy",
"node-gyp",
"typescript",
"electron",
"swift-build",
"mise",
"Yarn",
"CocoaPods",
] {
assert!(
matches!(
verdict_for(tool, &root, &[], &[], &cfg),
Verdict::Skipped(_)
),
"{tool} would have been cleared"
);
}
}
#[test]
fn an_installed_uninvolved_app_is_clearable() {
let cfg = Config::default();
let skips: Vec<String> = cfg
.caches
.effective_skips()
.into_iter()
.filter(|s| s != "com.apple.Safari")
.collect();
assert!(matches!(
verdict_for("com.apple.Safari", &caches_root(), &skips, &[], &cfg),
Verdict::Clear
));
}
#[test]
fn logs_are_exempt_from_the_installed_app_test() {
let cfg = Config::default();
assert!(matches!(
verdict_for("some-daemon", &logs_root(), &[], &[], &cfg),
Verdict::Clear
));
assert!(matches!(
verdict_for("some-daemon", &caches_root(), &[], &[], &cfg),
Verdict::Skipped(_)
));
}
#[test]
fn allow_opts_a_build_cache_back_in() {
let cfg = Config {
caches: crate::config::Caches {
allow: vec!["go-build".into()],
..Default::default()
},
..Default::default()
};
assert!(matches!(
verdict_for(
"go-build",
&caches_root(),
&cfg.caches.effective_skips(),
&[],
&cfg
),
Verdict::Clear
));
}
#[test]
fn roots_are_confined_to_caches_and_logs() {
for r in roots() {
let s = r.to_string_lossy().into_owned();
assert!(
s.ends_with("Library/Caches") || s.ends_with("Library/Logs"),
"unexpected cache root {s}"
);
}
}
}