use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use colored::Colorize;
use serde::Serialize;
use crate::utils::format_size;
use thiserror::Error;
use walkdir::WalkDir;
#[derive(Error, Debug)]
pub enum SweepError {
#[error("Path not found: {0}")]
PathNotFound(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Walk error: {0}")]
Walk(String),
}
const ARTIFACT_DIRS: &[(&str, &str)] = &[
("node_modules", "Node.js"),
("target", "Rust/Cargo"),
("__pycache__", "Python"),
(".cache", "Cache"),
(".gradle", "Gradle"),
(".next", "Next.js"),
(".nuxt", "Nuxt"),
("venv", "Python venv"),
(".venv", "Python venv"),
("build", "Build output"),
("dist", "Distribution"),
(".tox", "Python Tox"),
];
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactEntry {
pub path: String,
pub size_bytes: u64,
pub size_human: String,
pub artifact_type: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScanResult {
pub entries: Vec<ArtifactEntry>,
pub total_bytes: u64,
pub total_human: String,
}
#[derive(Debug, Serialize)]
struct JsonOutput {
entries: Vec<ArtifactEntry>,
total_bytes: u64,
total_human: String,
count: usize,
}
pub fn parse_min_size(s: &str) -> u64 {
let s = s.trim().to_uppercase();
if s.is_empty() {
return 0;
}
let (num_str, multiplier) = if let Some(n) = s.strip_suffix('G') {
(n, 1_073_741_824u64)
} else if let Some(n) = s.strip_suffix('M') {
(n, 1_048_576u64)
} else if let Some(n) = s.strip_suffix('K') {
(n, 1024u64)
} else {
(s.as_str(), 1u64)
};
num_str
.parse::<f64>()
.map(|n| (n * multiplier as f64) as u64)
.unwrap_or(0)
}
fn dir_size(path: &Path) -> u64 {
WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
fn is_artifact_dir(name: &str) -> Option<&'static str> {
for &(dir_name, label) in ARTIFACT_DIRS {
if name == dir_name {
return Some(label);
}
}
None
}
pub fn scan(root: &Path, min_bytes: u64) -> Result<ScanResult, SweepError> {
if !root.exists() {
return Err(SweepError::PathNotFound(root.display().to_string()));
}
let mut entries = Vec::new();
let walker = WalkDir::new(root).follow_links(false).into_iter();
let filtered = walker.filter_entry(|entry| {
if entry.file_type().is_dir() {
let name = entry.file_name().to_string_lossy();
if name.starts_with('.') && is_artifact_dir(&name).is_none() && entry.depth() > 0 {
return false;
}
}
true
});
for entry in filtered {
let entry = entry.map_err(|e| SweepError::Walk(e.to_string()))?;
if !entry.file_type().is_dir() || entry.depth() == 0 {
continue;
}
let name = entry.file_name().to_string_lossy();
if let Some(label) = is_artifact_dir(&name) {
let path = entry.path();
let size = dir_size(path);
if size >= min_bytes {
entries.push(ArtifactEntry {
path: path.display().to_string(),
size_bytes: size,
size_human: format_size(size),
artifact_type: label.to_string(),
});
}
}
}
entries.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
let total_bytes: u64 = entries.iter().map(|e| e.size_bytes).sum();
Ok(ScanResult {
total_human: format_size(total_bytes),
total_bytes,
entries,
})
}
pub fn run(
path: Option<&Path>,
yes: bool,
min_size_str: &str,
json: bool,
) -> Result<(), SweepError> {
let root = path.unwrap_or_else(|| Path::new("."));
let canonical = root
.canonicalize()
.map_err(|_| SweepError::PathNotFound(root.display().to_string()))?;
let min_bytes = parse_min_size(min_size_str);
let result = scan(&canonical, min_bytes)?;
if json {
return print_json(&result);
}
if result.entries.is_empty() {
println!();
println!(
" {} {} {} {} {}",
"devpulse".bold(),
"──".dimmed(),
"Sweep".bold(),
"──".dimmed(),
canonical.display().to_string().dimmed()
);
println!();
println!(
" No build artifacts found above {} threshold.",
min_size_str
);
println!();
return Ok(());
}
println!();
println!(
" {} {} {} {} {}",
"devpulse".bold(),
"──".dimmed(),
"Sweep".bold(),
"──".dimmed(),
canonical.display().to_string().dimmed()
);
println!();
println!(
" {:<4} {:<11} {:<13} {}",
"#".bold(),
"Size".bold(),
"Type".bold(),
"Path".bold()
);
println!(" {}", "─".repeat(60).dimmed());
for (i, entry) in result.entries.iter().enumerate() {
let size_colored = color_by_size(&entry.size_human, entry.size_bytes);
println!(
" {:<4} {:<11} {:<13} {}",
(i + 1).to_string().bold(),
size_colored,
entry.artifact_type.dimmed(),
entry.path
);
}
println!(" {}", "─".repeat(60).dimmed());
println!(
" {:<4} {:<11} {} ({} directories)",
"",
color_by_size(&result.total_human, result.total_bytes),
"total reclaimable".bold(),
result.entries.len()
);
println!();
if yes {
delete_entries(
&result.entries,
&(0..result.entries.len()).collect::<Vec<_>>(),
)?;
} else {
prompt_and_delete(&result.entries)?;
}
Ok(())
}
fn color_by_size(human: &str, bytes: u64) -> String {
if bytes >= 1_073_741_824 {
human.red().bold().to_string()
} else if bytes >= 104_857_600 {
human.yellow().bold().to_string()
} else if bytes >= 10_485_760 {
human.green().to_string()
} else {
human.white().to_string()
}
}
fn prompt_and_delete(entries: &[ArtifactEntry]) -> Result<(), SweepError> {
print!(" Delete all? [y/N/1,3,5]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input == "y" || input == "yes" {
let indices: Vec<usize> = (0..entries.len()).collect();
delete_entries(entries, &indices)?;
} else if input == "n" || input == "no" || input.is_empty() {
println!(" Cancelled.");
} else {
let indices: Vec<usize> = input
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&i| i >= 1 && i <= entries.len())
.map(|i| i - 1) .collect();
if indices.is_empty() {
println!(" No valid selections. Cancelled.");
} else {
delete_entries(entries, &indices)?;
}
}
Ok(())
}
fn delete_entries(entries: &[ArtifactEntry], indices: &[usize]) -> Result<(), SweepError> {
for &idx in indices {
if let Some(entry) = entries.get(idx) {
let path = PathBuf::from(&entry.path);
match fs::remove_dir_all(&path) {
Ok(()) => {
println!(
" {} {} ({})",
"Deleted".green().bold(),
entry.path,
entry.size_human
);
}
Err(e) => {
eprintln!(" {} {} — {}", "Failed".red().bold(), entry.path, e);
}
}
}
}
Ok(())
}
fn print_json(result: &ScanResult) -> Result<(), SweepError> {
let output = JsonOutput {
count: result.entries.len(),
total_bytes: result.total_bytes,
total_human: result.total_human.clone(),
entries: result
.entries
.iter()
.map(|e| ArtifactEntry {
path: e.path.clone(),
size_bytes: e.size_bytes,
size_human: e.size_human.clone(),
artifact_type: e.artifact_type.clone(),
})
.collect(),
};
let json_str =
serde_json::to_string_pretty(&output).map_err(|e| SweepError::Io(io::Error::other(e)))?;
println!("{json_str}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_min_size_megabytes() {
assert_eq!(parse_min_size("1M"), 1_048_576);
assert_eq!(parse_min_size("10M"), 10_485_760);
}
#[test]
fn test_parse_min_size_kilobytes() {
assert_eq!(parse_min_size("500K"), 512_000);
}
#[test]
fn test_parse_min_size_gigabytes() {
assert_eq!(parse_min_size("2G"), 2_147_483_648);
}
#[test]
fn test_parse_min_size_case_insensitive() {
assert_eq!(parse_min_size("1m"), 1_048_576);
assert_eq!(parse_min_size("1g"), 1_073_741_824);
}
#[test]
fn test_parse_min_size_invalid() {
assert_eq!(parse_min_size("abc"), 0);
assert_eq!(parse_min_size(""), 0);
}
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(500), "500 B");
}
#[test]
fn test_format_size_kb() {
assert_eq!(format_size(2048), "2.0 KB");
}
#[test]
fn test_format_size_mb() {
assert_eq!(format_size(5_242_880), "5.0 MB");
}
#[test]
fn test_format_size_gb() {
assert_eq!(format_size(2_147_483_648), "2.00 GB");
}
#[test]
fn test_is_artifact_dir_node_modules() {
assert_eq!(is_artifact_dir("node_modules"), Some("Node.js"));
}
#[test]
fn test_is_artifact_dir_target() {
assert_eq!(is_artifact_dir("target"), Some("Rust/Cargo"));
}
#[test]
fn test_is_artifact_dir_unknown() {
assert_eq!(is_artifact_dir("src"), None);
}
#[test]
fn test_scan_nonexistent_path() {
let result = scan(Path::new("/nonexistent/path/that/should/not/exist"), 0);
assert!(result.is_err());
}
#[test]
fn test_scan_with_tempdir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let nm = root.join("project").join("node_modules");
fs::create_dir_all(&nm).unwrap();
let file = nm.join("package.json");
fs::write(&file, r#"{"name":"test"}"#).unwrap();
let result = scan(root, 0).unwrap();
assert!(!result.entries.is_empty());
assert_eq!(result.entries[0].artifact_type, "Node.js");
}
}