use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use std::io::{self, Write};
use std::path::PathBuf;
use purger_core::{
CleanStrategy, DirectDeleteBackend, ProjectCleaner, ProjectFilter, ProjectScanner,
cleaner::CleanConfig, scanner::ScanConfig,
};
#[derive(Debug)]
struct ScanCommandArgs {
path: PathBuf,
max_depth: Option<usize>,
target_only: bool,
sort_by_size: bool,
keep_days: Option<u32>,
keep_size: Option<String>,
ignore_paths: Vec<PathBuf>,
no_parallel: bool,
follow_symlinks: bool,
include_hidden: bool,
no_gitignore: bool,
}
#[derive(Debug)]
struct CleanCommandArgs {
path: PathBuf,
max_depth: Option<usize>,
strategy: CleanStrategyArg,
direct_delete_backend: DirectDeleteBackendArg,
dry_run: bool,
keep_days: Option<u32>,
keep_size: Option<String>,
ignore_paths: Vec<PathBuf>,
no_parallel: bool,
follow_symlinks: bool,
include_hidden: bool,
no_gitignore: bool,
yes: bool,
keep_executable: bool,
executable_backup_dir: Option<PathBuf>,
timeout: u64,
}
#[derive(Debug)]
struct ScanConfigArgs {
max_depth: Option<usize>,
keep_days: Option<u32>,
keep_size: Option<String>,
ignore_paths: Vec<PathBuf>,
no_parallel: bool,
follow_symlinks: bool,
include_hidden: bool,
no_gitignore: bool,
}
#[derive(Parser)]
#[command(name = "purger")]
#[command(about = "A tool for cleaning Rust project build directories")]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short, long, global = true)]
pub debug: bool,
}
#[derive(Subcommand)]
pub enum Commands {
Scan {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long)]
max_depth: Option<usize>,
#[arg(short, long)]
target_only: bool,
#[arg(short = 'S', long)]
sort_by_size: bool,
#[arg(short = 'k', long)]
keep_days: Option<u32>,
#[arg(short = 's', long)]
keep_size: Option<String>,
#[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
ignore_paths: Vec<PathBuf>,
#[arg(long)]
no_parallel: bool,
#[arg(long)]
follow_symlinks: bool,
#[arg(long)]
include_hidden: bool,
#[arg(long)]
no_gitignore: bool,
},
Clean {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long)]
max_depth: Option<usize>,
#[arg(short = 'S', long, value_enum, default_value = "cargo-clean")]
strategy: CleanStrategyArg,
#[arg(long, value_enum, default_value = "native")]
direct_delete_backend: DirectDeleteBackendArg,
#[arg(short = 'n', long)]
dry_run: bool,
#[arg(short = 'k', long)]
keep_days: Option<u32>,
#[arg(short = 's', long)]
keep_size: Option<String>,
#[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
ignore_paths: Vec<PathBuf>,
#[arg(long)]
no_parallel: bool,
#[arg(long)]
follow_symlinks: bool,
#[arg(long)]
include_hidden: bool,
#[arg(long)]
no_gitignore: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
keep_executable: bool,
#[arg(long)]
executable_backup_dir: Option<PathBuf>,
#[arg(long, default_value = "0")]
timeout: u64,
},
}
#[derive(Debug, Clone, ValueEnum)]
pub enum CleanStrategyArg {
#[value(name = "cargo-clean")]
CargoClean,
#[value(name = "direct-delete")]
DirectDelete,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum DirectDeleteBackendArg {
#[value(name = "native")]
Native,
#[value(name = "cmd-rmdir")]
CmdRmdir,
}
impl From<CleanStrategyArg> for CleanStrategy {
fn from(arg: CleanStrategyArg) -> Self {
match arg {
CleanStrategyArg::CargoClean => CleanStrategy::CargoClean,
CleanStrategyArg::DirectDelete => CleanStrategy::DirectDelete,
}
}
}
impl From<DirectDeleteBackendArg> for DirectDeleteBackend {
fn from(arg: DirectDeleteBackendArg) -> Self {
match arg {
DirectDeleteBackendArg::Native => DirectDeleteBackend::Native,
DirectDeleteBackendArg::CmdRmdir => DirectDeleteBackend::CmdRmdir,
}
}
}
pub fn run_cli() -> Result<()> {
let cli = Cli::parse();
let log_level = if cli.debug {
"debug"
} else if cli.verbose {
"info"
} else {
"warn"
};
tracing_subscriber::fmt()
.with_env_filter(format!("purger={log_level}"))
.init();
match cli.command {
Commands::Scan {
path,
max_depth,
target_only,
sort_by_size,
keep_days,
keep_size,
ignore_paths,
no_parallel,
follow_symlinks,
include_hidden,
no_gitignore,
} => handle_scan_command(ScanCommandArgs {
path,
max_depth,
target_only,
sort_by_size,
keep_days,
keep_size,
ignore_paths,
no_parallel,
follow_symlinks,
include_hidden,
no_gitignore,
}),
Commands::Clean {
path,
max_depth,
strategy,
direct_delete_backend,
dry_run,
keep_days,
keep_size,
ignore_paths,
no_parallel,
follow_symlinks,
include_hidden,
no_gitignore,
yes,
keep_executable,
executable_backup_dir,
timeout,
} => handle_clean_command(CleanCommandArgs {
path,
max_depth,
strategy,
direct_delete_backend,
dry_run,
keep_days,
keep_size,
ignore_paths,
no_parallel,
follow_symlinks,
include_hidden,
no_gitignore,
yes,
keep_executable,
executable_backup_dir,
timeout,
}),
}
}
fn handle_scan_command(args: ScanCommandArgs) -> Result<()> {
let config = create_scan_config(ScanConfigArgs {
max_depth: args.max_depth,
keep_days: args.keep_days,
keep_size: args.keep_size,
ignore_paths: args.ignore_paths,
no_parallel: args.no_parallel,
follow_symlinks: args.follow_symlinks,
include_hidden: args.include_hidden,
no_gitignore: args.no_gitignore,
})?;
let scanner = ProjectScanner::new(config.clone());
let mut projects = scanner.scan(&args.path)?;
if args.target_only {
projects = ProjectScanner::filter_with_target(projects);
}
if args.sort_by_size {
projects = ProjectScanner::sort_by_size(projects);
}
if config.keep_days.is_some() || config.keep_size.is_some() || !config.ignore_paths.is_empty() {
let filter = ProjectFilter::new(config);
projects = filter.filter_projects(projects);
}
display_projects(&projects, &args.path)?;
Ok(())
}
fn handle_clean_command(args: CleanCommandArgs) -> Result<()> {
let scan_config = create_scan_config(ScanConfigArgs {
max_depth: args.max_depth,
keep_days: args.keep_days,
keep_size: args.keep_size.clone(),
ignore_paths: args.ignore_paths,
no_parallel: args.no_parallel,
follow_symlinks: args.follow_symlinks,
include_hidden: args.include_hidden,
no_gitignore: args.no_gitignore,
})?;
let scanner = ProjectScanner::new(scan_config.clone());
let mut projects = scanner.scan(&args.path)?;
projects = ProjectScanner::filter_with_target(projects);
if scan_config.keep_days.is_some()
|| scan_config.keep_size.is_some()
|| !scan_config.ignore_paths.is_empty()
{
let filter = ProjectFilter::new(scan_config);
projects = filter.filter_projects(projects);
}
if projects.is_empty() {
println!("No projects found to clean.");
return Ok(());
}
println!("Found {} projects to clean:", projects.len());
display_projects(&projects, &args.path)?;
if !args.yes && !args.dry_run && !confirm_clean(&projects)? {
println!("Cleaning cancelled.");
return Ok(());
}
let clean_config = CleanConfig {
strategy: args.strategy.into(),
dry_run: args.dry_run,
parallel: !args.no_parallel,
timeout_seconds: args.timeout,
direct_delete_backend: args.direct_delete_backend.into(),
keep_executable: args.keep_executable,
executable_backup_dir: args.executable_backup_dir,
};
let cleaner = ProjectCleaner::new(clean_config);
let result = cleaner.clean_projects(&projects);
display_clean_result(&result);
Ok(())
}
fn create_scan_config(args: ScanConfigArgs) -> Result<ScanConfig> {
let keep_size_bytes = if let Some(size_str) = args.keep_size {
Some(purger_core::ProjectFilter::parse_size_string(&size_str)?)
} else {
None
};
Ok(ScanConfig {
max_depth: args.max_depth,
parallel: !args.no_parallel,
follow_links: args.follow_symlinks,
ignore_hidden: !args.include_hidden,
respect_gitignore: !args.no_gitignore,
lazy_size_calculation: false, keep_days: args.keep_days,
keep_size: keep_size_bytes,
ignore_paths: args.ignore_paths,
})
}
fn display_projects(
projects: &[purger_core::RustProject],
base_path: &std::path::Path,
) -> Result<()> {
if projects.is_empty() {
println!("No projects found.");
return Ok(());
}
let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
println!("\nFound {} projects:", projects.len());
println!("{:<40} {:<15} {:<20}", "Project", "Size", "Path");
println!("{}", "-".repeat(75));
for project in projects {
let relative_path = project.relative_path(base_path);
println!(
"{:<40} {:<15} {:<20}",
project.name,
project.formatted_size(),
relative_path.display()
);
}
println!("{}", "-".repeat(75));
println!("Total size: {}", purger_core::format_bytes(total_size));
Ok(())
}
fn confirm_clean(projects: &[purger_core::RustProject]) -> Result<bool> {
let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
print!(
"\nThis will clean {} projects and free up {}. Continue? [y/N]: ",
projects.len(),
purger_core::format_bytes(total_size)
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes")
}
fn display_clean_result(result: &purger_core::CleanResult) {
println!("\nCleaning completed!");
println!("Projects cleaned: {}", result.cleaned_projects);
println!("Size freed: {}", result.format_size());
if !result.failures.is_empty() {
println!("\nFailed to clean {} projects:", result.failures.len());
for failure in &result.failures {
println!(
" - {} ({}): {}",
failure.project_name,
failure.project_path.display(),
failure.error
);
}
} else if !result.failed_projects.is_empty() {
println!(
"\nFailed to clean {} projects:",
result.failed_projects.len()
);
for project in &result.failed_projects {
println!(" - {project}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_cli_parse_scan_command() {
let args = vec![
"purger",
"scan",
"/tmp",
"--max-depth",
"3",
"--target-only",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Scan {
path,
max_depth,
target_only,
..
} => {
assert_eq!(path, PathBuf::from("/tmp"));
assert_eq!(max_depth, Some(3));
assert!(target_only);
}
_ => panic!("Expected Scan command"),
}
}
#[test]
fn test_cli_parse_clean_command() {
let args = vec![
"purger",
"clean",
"/tmp",
"--strategy",
"direct-delete",
"--dry-run",
"--yes",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Clean {
path,
strategy,
dry_run,
yes,
..
} => {
assert_eq!(path, PathBuf::from("/tmp"));
assert!(matches!(strategy, CleanStrategyArg::DirectDelete));
assert!(dry_run);
assert!(yes);
}
_ => panic!("Expected Clean command"),
}
}
#[test]
fn test_create_scan_config() {
let config = create_scan_config(ScanConfigArgs {
max_depth: Some(5),
keep_days: Some(7),
keep_size: Some("1MB".to_string()),
ignore_paths: vec![PathBuf::from("/ignore")],
no_parallel: false,
follow_symlinks: true,
include_hidden: false,
no_gitignore: true,
})
.unwrap();
assert_eq!(config.max_depth, Some(5));
assert_eq!(config.keep_days, Some(7));
assert_eq!(config.keep_size, Some(1_000_000));
assert_eq!(config.ignore_paths, vec![PathBuf::from("/ignore")]);
assert!(config.parallel);
assert!(config.follow_links);
assert!(config.ignore_hidden);
assert!(!config.respect_gitignore);
}
#[test]
fn test_clean_strategy_conversion() {
assert!(matches!(
CleanStrategy::from(CleanStrategyArg::CargoClean),
CleanStrategy::CargoClean
));
assert!(matches!(
CleanStrategy::from(CleanStrategyArg::DirectDelete),
CleanStrategy::DirectDelete
));
}
#[test]
fn test_display_projects_empty() {
let projects = vec![];
let temp_dir = TempDir::new().unwrap();
let result = display_projects(&projects, temp_dir.path());
assert!(result.is_ok());
}
#[test]
fn test_confirm_clean_calculation() {
use purger_core::RustProject;
use std::time::SystemTime;
let projects = [
RustProject {
path: PathBuf::from("/test1"),
name: "test1".to_string(),
target_size: 1000,
last_modified: SystemTime::now(),
is_workspace: false,
has_target: true,
},
RustProject {
path: PathBuf::from("/test2"),
name: "test2".to_string(),
target_size: 2000,
last_modified: SystemTime::now(),
is_workspace: false,
has_target: true,
},
];
let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
assert_eq!(total_size, 3000);
}
}