mod cli;
use anyhow::{Ok, Result, bail};
use clap::Parser;
use clean_dev_dirs::{
cleaner::{Cleaner, RemovalStrategy},
config::FileConfig,
filtering::{filter_projects, sort_projects},
output::JsonOutput,
project::{Project, Projects},
scanner::Scanner,
};
use cli::{Cli, Commands, ConfigCommand};
use colored::Colorize;
use humansize::{DECIMAL, format_size};
use inquire::Confirm;
use std::process::exit;
fn main() {
if let Err(err) = inner_main() {
eprintln!("Error: {err}");
exit(1);
}
}
fn inner_main() -> Result<()> {
let args = Cli::parse();
if let Some(Commands::Config { command }) = &args.subcommand {
return handle_config_command(command);
}
let json_mode = args.json();
let file_config = load_config(json_mode);
let dirs = args.directories(&file_config);
let project_filter = args.project_filter(&file_config);
let execution_options = args.execution_options(&file_config);
let scan_options = args.scan_options(&file_config);
let filter_options = args.filter_options(&file_config);
if json_mode && execution_options.interactive {
bail!("--json and --interactive cannot be used together");
}
if scan_options.threads > 0 {
rayon::ThreadPoolBuilder::new()
.num_threads(scan_options.threads)
.build_global()?;
}
let scanner = Scanner::new(scan_options, project_filter).with_quiet(json_mode);
let projects = scanner.scan_directories(&dirs);
if !json_mode {
println!("Found {} projects", projects.len());
}
if projects.is_empty() {
return print_empty_result(json_mode, "No development directories found!");
}
let sort_opts = args.sort_options(&file_config);
let mut filtered_projects = filter_projects(projects, &filter_options)?;
sort_projects(&mut filtered_projects, &sort_opts);
if filtered_projects.is_empty() {
return print_empty_result(json_mode, "No directories match the specified criteria!");
}
let total_size: u64 = filtered_projects.iter().map(Project::total_size).sum();
let projects: Projects = filtered_projects.into();
if !json_mode {
println!("\n{}", "Found projects:".bold());
projects.print_summary(total_size);
}
let Some((projects, keep_executables)) =
resolve_keep_executables(projects, &execution_options)?
else {
return Ok(());
};
if execution_options.dry_run {
return print_dry_run(&projects, json_mode);
}
let confirm_size = projects.get_total_size();
if !confirm_cleanup(
projects.len(),
confirm_size,
execution_options.yes,
json_mode,
)? {
return Ok(());
}
run_cleanup(
projects,
keep_executables,
json_mode,
execution_options.use_trash,
)
}
const CONFIG_TEMPLATE: &str = r#"# clean-dev-dirs configuration
# All values shown are their defaults. Uncomment and change as needed.
# Default project type to scan (all, rust, node, python, go, java, cpp, swift, dotnet, ruby, elixir, deno)
# project_type = "all"
# Default directory to scan (defaults to current directory when not set)
# dir = "."
[filtering]
# Ignore projects whose build directory is smaller than this (e.g. "50MB", "1GiB")
# keep_size = "0"
# Ignore projects compiled within the last N days (0 = no age filter)
# keep_days = 0
# Sort output by: size, age, name, type
# sort = "size"
# Reverse the sort order
# reverse = false
[scanning]
# Number of threads to use for scanning (0 = all CPU cores)
# threads = 0
# Show access errors encountered during scanning
# verbose = false
# Directories to skip during scanning
# skip = []
# Directories to ignore entirely during scanning
# ignore = []
[execution]
# Copy compiled executables to <project>/bin/ before cleaning
# keep_executables = false
# Use interactive project selection
# interactive = false
# Preview what would be cleaned without deleting anything
# dry_run = false
# Move build dirs to system trash instead of permanently deleting (default: true)
# use_trash = true
"#;
fn handle_config_command(cmd: &ConfigCommand) -> Result<()> {
match cmd {
ConfigCommand::Path => match FileConfig::config_path() {
Some(path) => println!("{}", path.display()),
None => bail!("Could not determine the config directory on this platform"),
},
ConfigCommand::Show => show_config()?,
ConfigCommand::Init => init_config()?,
}
Ok(())
}
fn show_config() -> Result<()> {
let path = FileConfig::config_path();
let (file_exists, config) = match &path {
Some(p) if p.exists() => (true, FileConfig::load()?),
_ => (false, FileConfig::default()),
};
match &path {
Some(p) if file_exists => println!("Config file: {} (found)", p.display()),
Some(p) => println!(
"Config file: {} (not found - showing defaults)",
p.display()
),
None => println!("Config file: (cannot determine path on this platform)"),
}
println!();
println!("{}", format_config(&config));
Ok(())
}
fn format_config(config: &clean_dev_dirs::config::file::FileConfig) -> String {
fn show_str(val: Option<&str>, default: &str) -> String {
val.map_or_else(
|| format!("\"{default}\" (default)"),
|v| format!("\"{v}\""),
)
}
fn show_bool(val: Option<bool>, default: bool) -> String {
val.map_or_else(|| format!("{default} (default)"), |v| v.to_string())
}
fn show_u32(val: Option<u32>, default: u32) -> String {
val.map_or_else(|| format!("{default} (default)"), |v| v.to_string())
}
fn show_usize(val: Option<usize>, default: &str) -> String {
val.map_or_else(|| format!("{default} (default)"), |v| v.to_string())
}
fn show_paths(val: Option<&[std::path::PathBuf]>) -> String {
match val {
Some(v) if !v.is_empty() => {
let items: Vec<String> = v.iter().map(|p| format!("\"{}\"", p.display())).collect();
format!("[{}]", items.join(", "))
}
_ => "[] (default)".to_string(),
}
}
let dir_str = config.dir.as_ref().map_or_else(
|| "\".\" (default)".to_string(),
|p| format!("\"{}\"", p.display()),
);
format!(
"\
project_type = {project_type}
dir = {dir}
[filtering]
keep_size = {keep_size}
keep_days = {keep_days}
sort = {sort}
reverse = {reverse}
[scanning]
threads = {threads}
verbose = {verbose}
skip = {skip}
ignore = {ignore}
[execution]
keep_executables = {keep_executables}
interactive = {interactive}
dry_run = {dry_run}
use_trash = {use_trash}",
project_type = show_str(config.project_type.as_deref(), "all"),
dir = dir_str,
keep_size = show_str(config.filtering.keep_size.as_deref(), "0"),
keep_days = show_u32(config.filtering.keep_days, 0),
sort = config
.filtering
.sort
.as_deref()
.map_or_else(|| "(none) (default)".to_string(), |v| format!("\"{v}\""),),
reverse = show_bool(config.filtering.reverse, false),
threads = show_usize(config.scanning.threads, "0 (all cores)"),
verbose = show_bool(config.scanning.verbose, false),
skip = show_paths(config.scanning.skip.as_deref()),
ignore = show_paths(config.scanning.ignore.as_deref()),
keep_executables = show_bool(config.execution.keep_executables, false),
interactive = show_bool(config.execution.interactive, false),
dry_run = show_bool(config.execution.dry_run, false),
use_trash = show_bool(config.execution.use_trash, true),
)
}
fn init_config() -> Result<()> {
let Some(path) = FileConfig::config_path() else {
bail!("Could not determine the config directory on this platform");
};
if path.exists() {
println!("Config file already exists at: {}", path.display());
println!("Remove it first if you want to regenerate it.");
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
anyhow::anyhow!(
"Failed to create config directory {}: {e}",
parent.display()
)
})?;
}
std::fs::write(&path, CONFIG_TEMPLATE)
.map_err(|e| anyhow::anyhow!("Failed to write config file {}: {e}", path.display()))?;
println!("Config file written to: {}", path.display());
Ok(())
}
fn load_config(json_mode: bool) -> FileConfig {
match FileConfig::load() {
std::result::Result::Ok(config) => config,
Err(e) => {
if !json_mode {
eprintln!("{} {e}", "Warning: Failed to load config file:".yellow());
}
FileConfig::default()
}
}
}
fn print_empty_result(json_mode: bool, message: &str) -> Result<()> {
if json_mode {
let output = JsonOutput::from_projects_dry_run(&[]);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{}", message.green());
}
Ok(())
}
fn resolve_keep_executables(
projects: Projects,
opts: &clean_dev_dirs::ExecutionOptions,
) -> Result<Option<(Projects, bool)>> {
let mut keep = opts.keep_executables;
if opts.interactive {
let selected = projects.interactive_selection()?;
if selected.is_empty() {
println!("{}", "No projects selected for cleaning!".green());
return Ok(None);
}
if !keep {
keep = Confirm::new("Keep compiled executables before cleaning?")
.with_default(false)
.prompt()?;
}
return Ok(Some((Projects::from(selected), keep)));
}
Ok(Some((projects, keep)))
}
fn confirm_cleanup(count: usize, total_size: u64, yes: bool, json_mode: bool) -> Result<bool> {
if yes || json_mode {
return Ok(true);
}
let size_str = format_size(total_size, DECIMAL);
let plural = if count == 1 { "" } else { "s" };
let confirmed = Confirm::new(&format!("Clean {count} project{plural} ({size_str})?"))
.with_default(false)
.prompt()?;
Ok(confirmed)
}
fn print_dry_run(projects: &Projects, json_mode: bool) -> Result<()> {
if json_mode {
let output = JsonOutput::from_projects_dry_run(projects.as_slice());
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
let size = projects.get_total_size();
println!(
"\n{} {}",
"[dry-run] Complete.".yellow(),
format!("Would free up {}", format_size(size, DECIMAL)).bright_white()
);
}
Ok(())
}
fn run_cleanup(
projects: Projects,
keep_executables: bool,
json_mode: bool,
use_trash: bool,
) -> Result<()> {
let removal_strategy = RemovalStrategy::from_use_trash(use_trash);
let snapshot: Vec<_> = projects.as_slice().to_vec();
let result = Cleaner::clean_projects(projects, keep_executables, json_mode, removal_strategy);
if json_mode {
let output = JsonOutput::from_projects_cleanup(&snapshot, &result);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
Cleaner::print_summary(&result);
}
Ok(())
}