use std::{
collections::HashMap,
env, fs, io,
io::IsTerminal,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
time::{Duration, SystemTime},
};
use anyhow::{Result, bail};
use dialoguer::{Confirm, MultiSelect, theme::ColorfulTheme};
use rayon::prelude::*;
use walkdir::WalkDir;
use crate::{
demo,
fmt::{self, Paint},
progress::{self, Progress},
};
pub struct Rule {
pub dir: &'static str,
pub markers: &'static [&'static str],
pub kind: &'static str,
}
const NODE: &[&str] = &["package.json"];
const GRADLE: &[&str] = &[
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
];
const PYTHON: &[&str] = &["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"];
pub const RULES: &[Rule] = &[
Rule {
dir: "node_modules",
markers: NODE,
kind: "Node",
},
Rule {
dir: ".next",
markers: NODE,
kind: "Next.js",
},
Rule {
dir: ".nuxt",
markers: NODE,
kind: "Nuxt",
},
Rule {
dir: ".svelte-kit",
markers: NODE,
kind: "SvelteKit",
},
Rule {
dir: ".turbo",
markers: NODE,
kind: "Turborepo",
},
Rule {
dir: ".parcel-cache",
markers: NODE,
kind: "Parcel",
},
Rule {
dir: ".angular",
markers: NODE,
kind: "Angular",
},
Rule {
dir: "target",
markers: &["Cargo.toml"],
kind: "Rust",
},
Rule {
dir: "target",
markers: &["pom.xml"],
kind: "Maven",
},
Rule {
dir: "build",
markers: GRADLE,
kind: "Gradle",
},
Rule {
dir: ".gradle",
markers: GRADLE,
kind: "Gradle",
},
Rule {
dir: ".venv",
markers: PYTHON,
kind: "Python",
},
Rule {
dir: "venv",
markers: PYTHON,
kind: "Python",
},
Rule {
dir: "Pods",
markers: &["Podfile"],
kind: "CocoaPods",
},
Rule {
dir: ".dart_tool",
markers: &["pubspec.yaml"],
kind: "Dart",
},
Rule {
dir: "build",
markers: &["pubspec.yaml"],
kind: "Flutter",
},
Rule {
dir: "vendor",
markers: &["composer.json"],
kind: "Composer",
},
Rule {
dir: "_build",
markers: &["mix.exs"],
kind: "Elixir",
},
Rule {
dir: "deps",
markers: &["mix.exs"],
kind: "Elixir",
},
Rule {
dir: ".zig-cache",
markers: &["build.zig"],
kind: "Zig",
},
Rule {
dir: "zig-out",
markers: &["build.zig"],
kind: "Zig",
},
];
const HOME_SKIP: &[&str] = &["Library", "Applications", "Movies", "Music", "Pictures"];
#[derive(Debug, Clone)]
pub struct Junk {
pub path: PathBuf,
pub kind: &'static str,
pub size: u64,
pub last_used: SystemTime,
}
fn rule_for(dir: &Path) -> Option<&'static Rule> {
let name = dir.file_name()?.to_str()?;
let parent = dir.parent()?;
RULES
.iter()
.find(|rule| rule.dir == name && rule.markers.iter().any(|m| parent.join(m).is_file()))
}
fn should_skip(dir: &Path, home: Option<&Path>) -> bool {
let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
return true;
};
name.starts_with('.')
|| name.ends_with(".app")
|| (dir.parent() == home && HOME_SKIP.contains(&name))
}
fn find(root: &Path) -> Vec<(PathBuf, &'static Rule)> {
let home = env::var_os("HOME").map(PathBuf::from);
let mut found = Vec::new();
let mut walk = WalkDir::new(root).into_iter();
while let Some(entry) = walk.next() {
let Ok(entry) = entry else { continue };
if entry.depth() == 0 || !entry.file_type().is_dir() {
continue;
}
if let Some(rule) = rule_for(entry.path()) {
found.push((entry.into_path(), rule));
walk.skip_current_dir();
} else if should_skip(entry.path(), home.as_deref()) {
walk.skip_current_dir();
}
}
found
}
fn measure(path: PathBuf, rule: &'static Rule) -> Junk {
Junk {
size: disk_usage(&path),
last_used: last_used(&path),
kind: rule.kind,
path,
}
}
fn disk_usage(path: &Path) -> u64 {
let mut total = 0;
let mut linked: HashMap<(u64, u64), (u64, u64)> = HashMap::new();
for meta in WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.filter_map(|entry| entry.metadata().ok())
{
let size = meta.blocks() * 512;
if meta.is_dir() || meta.nlink() == 1 {
total += size;
continue;
}
let (seen, _) = linked.entry((meta.dev(), meta.ino())).or_insert((0, size));
*seen += 1;
if *seen == meta.nlink() {
total += size;
}
}
total
}
fn last_used(junk: &Path) -> SystemTime {
let project = junk.parent().unwrap_or(junk);
fs::read_dir(project)
.into_iter()
.flatten()
.flatten()
.filter(|entry| entry.path() != junk && entry.file_name() != ".DS_Store")
.filter_map(|entry| entry.metadata().ok()?.modified().ok())
.max()
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub fn scan(root: &Path, on_found: impl Fn(Junk) + Sync) {
if demo::enabled() {
return demo::scan(root, on_found);
}
find(root)
.into_par_iter()
.for_each(|(path, rule)| on_found(measure(path, rule)));
}
pub fn delete(path: &Path) -> io::Result<()> {
delete_counting(path, &AtomicU64::new(0))
}
pub fn delete_counting(path: &Path, freed: &AtomicU64) -> io::Result<()> {
if demo::enabled() {
demo::delete(path, freed);
return Ok(());
}
remove_tree(path, freed)
}
fn remove_tree(dir: &Path, freed: &AtomicU64) -> io::Result<()> {
let entries: Vec<fs::DirEntry> = fs::read_dir(dir)?.collect::<io::Result<_>>()?;
entries.into_par_iter().try_for_each(|entry| {
let meta = entry.metadata()?;
if meta.is_dir() {
return remove_tree(&entry.path(), freed);
}
fs::remove_file(entry.path())?;
if meta.nlink() == 1 {
freed.fetch_add(meta.blocks() * 512, Ordering::Relaxed);
}
Ok(())
})?;
let size = fs::symlink_metadata(dir).map_or(0, |meta| meta.blocks() * 512);
fs::remove_dir(dir)?;
freed.fetch_add(size, Ordering::Relaxed);
Ok(())
}
#[derive(Default)]
struct Tally {
count: AtomicUsize,
bytes: AtomicU64,
}
pub fn display(path: &Path, root: &Path) -> String {
match path.strip_prefix(root) {
Ok(relative) => relative.display().to_string(),
Err(_) => fmt::path(path),
}
}
pub struct Options {
pub root: PathBuf,
pub older_than: Option<u64>,
pub dry_run: bool,
pub yes: bool,
}
pub fn run(options: Options) -> Result<()> {
let Options {
root,
older_than,
dry_run,
yes,
} = options;
if !dry_run && !yes && !std::io::stdin().is_terminal() {
bail!("not running in a terminal: pass --dry-run to list or --yes to delete everything");
}
let scanned = Arc::new(Tally::default());
let progress = Progress::start({
let (scanned, shown) = (scanned.clone(), fmt::path(&root));
move |spinner| {
format!(
"{spinner} Scanning {shown}… {} found, {}",
scanned.count.load(Ordering::Relaxed),
fmt::size(scanned.bytes.load(Ordering::Relaxed))
)
}
});
let found = Mutex::new(Vec::new());
scan(&root, |junk| {
scanned.count.fetch_add(1, Ordering::Relaxed);
scanned.bytes.fetch_add(junk.size, Ordering::Relaxed);
found.lock().unwrap().push(junk);
});
drop(progress);
let mut junk = found.into_inner().unwrap();
if let Some(days) = older_than {
let cutoff = SystemTime::now() - Duration::from_secs(days * 86_400);
junk.retain(|j| j.last_used < cutoff);
}
junk.retain(|j| j.size > 0);
junk.sort_by_key(|j| std::cmp::Reverse(j.size));
if junk.is_empty() {
println!("Nothing to clean.");
return Ok(());
}
let total: u64 = junk.iter().map(|j| j.size).sum();
let lines: Vec<String> = junk
.iter()
.map(|j| {
format!(
"{:>8} {:>5} {:<10} {}",
fmt::size(j.size),
fmt::age(j.last_used),
j.kind,
display(&j.path, &root)
)
})
.collect();
let summary = format!(
"{} folder{}, {}",
junk.len(),
plural(junk.len()),
fmt::size(total)
);
let chosen: Vec<&Junk> = if dry_run || yes {
println!(
"{}",
format!("{:>8} {:>5} {:<10} {}", "SIZE", "AGE", "KIND", "PATH").dim()
);
for line in &lines {
println!("{line}");
}
println!("{}", summary.as_str().bold());
if dry_run {
return Ok(());
}
junk.iter().collect()
} else {
let picked = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Found {summary}. Space to pick, a for all, enter to continue"
))
.items(&lines)
.max_length(20)
.report(false)
.interact_opt()?;
let Some(picked) = picked else { return Ok(()) };
picked.into_iter().map(|i| &junk[i]).collect()
};
if chosen.is_empty() {
println!("Nothing picked.");
return Ok(());
}
let chosen_size: u64 = chosen.iter().map(|j| j.size).sum();
if !yes
&& !Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Delete {} folder{} ({})?",
chosen.len(),
plural(chosen.len()),
fmt::size(chosen_size)
))
.default(false)
.interact()?
{
return Ok(());
}
let deleted = Arc::new(Tally::default());
let progress = Progress::start({
let (deleted, folders) = (deleted.clone(), chosen.len());
move |spinner| {
let bytes = deleted.bytes.load(Ordering::Relaxed);
format!(
"{spinner} Deleting {} {} of {} · {}/{folders} folders",
progress::bar(bytes as f64 / chosen_size.max(1) as f64, 24),
fmt::size(bytes),
fmt::size(chosen_size),
deleted.count.load(Ordering::Relaxed),
)
}
});
let results: Vec<(&Junk, io::Result<()>)> = chosen
.into_par_iter()
.map(|j| {
let result = delete_counting(&j.path, &deleted.bytes);
deleted.count.fetch_add(1, Ordering::Relaxed);
(j, result)
})
.collect();
drop(progress);
let mut freed = 0;
for (junk, result) in results {
match result {
Ok(()) => freed += junk.size,
Err(err) => eprintln!("{} {}: {err}", "✘".red(), fmt::path(&junk.path)),
}
}
println!("{} Freed {}", "✔".green(), fmt::size(freed).bold());
Ok(())
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("tidyup-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn touch(path: &Path) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, "x").unwrap();
}
#[test]
fn finds_only_folders_next_to_their_project_file() {
let root = temp_dir("find");
touch(&root.join("web/package.json"));
touch(&root.join("web/node_modules/react/index.js"));
touch(&root.join("web/node_modules/nested/node_modules/x.js"));
touch(&root.join("cli/Cargo.toml"));
touch(&root.join("cli/target/debug/cli"));
touch(&root.join("notes/target/keep-me.txt"));
touch(&root.join(".hidden/package.json"));
touch(&root.join(".hidden/node_modules/x.js"));
let mut found: Vec<_> = find(&root)
.into_iter()
.map(|(path, rule)| (display(&path, &root), rule.kind))
.collect();
found.sort();
assert_eq!(
found,
[
("cli/target".to_string(), "Rust"),
("web/node_modules".to_string(), "Node")
]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn measures_and_deletes() {
let root = temp_dir("measure");
touch(&root.join("package.json"));
fs::create_dir_all(root.join("node_modules")).unwrap();
fs::write(root.join("node_modules/big.bin"), vec![0u8; 100_000]).unwrap();
let junk = std::sync::Mutex::new(Vec::new());
scan(&root, |j| junk.lock().unwrap().push(j));
let junk = junk.into_inner().unwrap();
assert_eq!(junk.len(), 1);
assert!(junk[0].size >= 100_000);
delete(&junk[0].path).unwrap();
assert!(!root.join("node_modules").exists());
assert!(root.join("package.json").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn counts_hard_links_only_when_all_are_inside() {
let root = temp_dir("links");
let inside = root.join("target");
fs::create_dir_all(&inside).unwrap();
fs::write(inside.join("a"), vec![1u8; 200_000]).unwrap();
fs::hard_link(inside.join("a"), inside.join("a-link")).unwrap();
fs::write(inside.join("b"), vec![2u8; 300_000]).unwrap();
fs::hard_link(inside.join("b"), root.join("b-outside")).unwrap();
let size = disk_usage(&inside);
assert!((200_000..300_000).contains(&size), "got {size}");
fs::remove_dir_all(root).unwrap();
}
}