use anyhow::{Context, Result};
use clap::Parser;
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
use rayon::prelude::*;
use std::collections::HashSet;
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use walkdir::WalkDir;
#[derive(Parser, Debug)]
#[command(version, about = "Efficiently delete node_modules directories")]
struct Args {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(long)]
dry_run: bool,
#[arg(long)]
verbose: bool,
#[arg(long)]
depth: Option<usize>,
#[arg(long, value_delimiter = ',')]
exclude: Vec<PathBuf>,
}
#[derive(Default, Clone)]
struct Stats {
#[allow(dead_code)]
folders_found: usize,
#[allow(dead_code)]
total_size: u64,
folders_processed: usize,
size_freed: u64,
}
fn main() -> Result<()> {
let args = Args::parse();
let exclude: HashSet<PathBuf> = args.exclude.into_iter().collect();
let scan_start = Instant::now();
let node_modules = scan_node_modules(&args.dir, args.depth, &exclude, args.verbose)?;
let scan_duration = scan_start.elapsed();
if node_modules.is_empty() {
println!("🎉 No node_modules directories found! Your disk is already clean! ✨");
return Ok(());
}
let pb = ProgressBar::new(node_modules.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{msg} {bar:40.cyan/blue} {pos}/{len} [{elapsed_precise}]")?
.progress_chars("##-"),
);
pb.set_message("📊 Calculating sizes...");
let total_size: u64 = node_modules
.par_iter()
.progress_with(pb)
.map(|path| calculate_dir_size(path).unwrap_or(0))
.sum();
let size_str = format_size(total_size);
let duration_val = format!("{:.2}s", scan_duration.as_secs_f64());
let folders_val = node_modules.len().to_string();
let size_val = &size_str;
let savings_val = &size_str;
println!("\n┌─────────────────────────────────────────────────────────────────┐");
println!("│ 📊 SCAN RESULTS │");
println!("├─────────────────────────────────────────────────────────────────┤");
println!("│ ⏱️ Scan Duration │{:>38}│", duration_val);
println!("│ 📦 Folders Found │{:>38}│", folders_val);
println!("│ 💾 Total Size │{:>38}│", size_val);
println!("│ 🎯 Estimated Savings │{:>38}│", savings_val);
println!("└─────────────────────────────────────────────────────────────────┘");
if args.dry_run {
println!("\n🔮 DRY RUN: No deletions performed. This was just a preview! 👀");
return Ok(());
}
print!("\n🚨 NUCLEAR WARNING! Proceed with deletion? (yes/no): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().lock().read_line(&mut input)?;
if input.trim().to_lowercase() != "yes" {
println!("🛡️ Deletion cancelled. Your node_modules live to see another day! 😅");
return Ok(());
}
let deletion_start = Instant::now();
let node_modules_len = node_modules.len();
let stats = Arc::new(Mutex::new(Stats {
folders_found: node_modules_len,
total_size,
..Default::default()
}));
let pb = ProgressBar::new(node_modules_len as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(
"💥 {msg} {bar:40.cyan/blue} {pos}/{len} ⏱️ {eta} [{elapsed_precise}] 💾 Freed: {wide_msg}",
)?
.progress_chars("🚀🌟⭐"),
);
let avg_time_per_folder = Arc::new(Mutex::new(Duration::ZERO));
let _start_time = Instant::now();
node_modules
.into_par_iter()
.progress_with(pb.clone())
.try_for_each(|path: PathBuf| -> Result<()> {
let folder_start = Instant::now();
if args.verbose {
println!("🗑️ Processing: {:?}", path);
}
let size = calculate_dir_size(&path)?;
fs::remove_dir_all(&path).with_context(|| format!("Failed to delete {:?}", path))?;
let duration = folder_start.elapsed();
{
let mut stats = stats.lock().unwrap();
stats.folders_processed += 1;
stats.size_freed += size;
let mut avg = avg_time_per_folder.lock().unwrap();
*avg = (*avg * (stats.folders_processed as u32 - 1) + duration)
/ stats.folders_processed as u32;
let remaining = node_modules_len - stats.folders_processed;
let eta = *avg * remaining as u32;
pb.set_message(format!("💣 Deleting... ETA: {:.2}s", eta.as_secs_f64()));
pb.set_message(format!("{}", format_size(stats.size_freed)));
}
Ok(())
})?;
let deletion_duration = deletion_start.elapsed();
let stats = stats.lock().unwrap();
let size_freed_str = format_size(stats.size_freed);
let del_duration_val = format!("{:.2}s", deletion_duration.as_secs_f64());
let folders_nuked_val = stats.folders_processed.to_string();
let space_freed_val = &size_freed_str;
let efficiency_val = "⭐⭐⭐⭐⭐ NUCLEAR!";
println!("\n┌─────────────────────────────────────────────────────────────────┐");
println!("│ 🎉 MISSION ACCOMPLISHED! 🎉 │");
println!("├─────────────────────────────────────────────────────────────────┤");
println!("│ ⏱️ Deletion Duration │{:>38}│", del_duration_val);
println!("│ 💣 Folders Nuked │{:>38}│", folders_nuked_val);
println!("│ 💾 Space Liberated │{:>38}│", space_freed_val);
println!("│ 🎯 Efficiency Rating │{:>33}│", efficiency_val);
println!("└─────────────────────────────────────────────────────────────────┘");
println!("\n🤯 Your disk space has been liberated! Time to celebrate! 🎊");
Ok(())
}
fn scan_node_modules(
root: &Path,
max_depth: Option<usize>,
exclude: &HashSet<PathBuf>,
verbose: bool,
) -> Result<Vec<PathBuf>> {
let mut node_modules = Vec::new();
let mut dirs_scanned = 0;
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.unwrap()
.tick_chars("🔍🔎🔍🔎"),
);
spinner.set_message("🚀 Scanning for node_modules directories...");
let start_time = Instant::now();
for entry in WalkDir::new(root)
.max_depth(max_depth.unwrap_or(usize::MAX))
.into_iter()
.filter_entry(|e| {
let is_node_modules = e.file_type().is_dir() && e.file_name() == "node_modules";
if is_node_modules {
let path_components: Vec<_> = e.path().components().collect();
for component in &path_components[..path_components.len().saturating_sub(1)] {
if let std::path::Component::Normal(name) = component {
if *name == "node_modules" {
return false; }
}
}
return true; }
let path_components: Vec<_> = e.path().components().collect();
for component in &path_components {
if let std::path::Component::Normal(name) = component {
if *name == "node_modules" {
return false; }
}
}
true })
.filter_map(|e| e.ok())
{
dirs_scanned += 1;
if dirs_scanned % 50 == 0 {
let elapsed = start_time.elapsed();
spinner.set_message(format!(
"🔍 Scanning... {} directories searched ({:.1} dirs/sec) | 📦 Found: {} node_modules",
dirs_scanned,
dirs_scanned as f64 / elapsed.as_secs_f64(),
node_modules.len()
));
spinner.tick();
}
if entry.file_type().is_dir() && entry.file_name() == "node_modules" {
let path = entry.path().to_path_buf();
if exclude.contains(&path) {
if verbose {
println!("🚫 Excluding: {:?}", path);
}
continue;
}
node_modules.push(path);
}
}
let elapsed = start_time.elapsed();
spinner.finish_with_message(format!(
"✅ Scan complete! {} directories searched in {:.2}s | 📦 Found: {} node_modules",
dirs_scanned,
elapsed.as_secs_f64(),
node_modules.len()
));
Ok(node_modules)
}
fn calculate_dir_size(path: &Path) -> Result<u64> {
let mut total_size = 0u64;
for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
total_size += entry.metadata()?.len();
}
}
Ok(total_size)
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if bytes >= TB {
format!("{:.2} TB", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}