use carwash_core::ArtifactKind;
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Parser)]
#[command(
name = "carwash",
version,
about = "Reclaim disk space from build outputs, dependency installs and caches across all your projects",
long_about = "Reclaim disk space from build outputs, dependency installs and caches across all your projects.\n\n\
Run without a subcommand to open the interactive UI on PATH (default: current directory).",
args_conflicts_with_subcommands = true,
arg_required_else_help = false
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
pub path: Option<PathBuf>,
#[command(flatten)]
pub global: GlobalArgs,
}
#[derive(Debug, Args)]
pub struct GlobalArgs {
#[arg(long, global = true, value_name = "FILE", env = "CARWASH_CONFIG")]
pub config: Option<PathBuf>,
#[arg(long, global = true, value_name = "N")]
pub threads: Option<usize>,
#[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
pub color: ColorChoice,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Scan(ScanArgs),
Clean(CleanArgs),
Tasks(TasksArgs),
Run(RunArgs),
Outdated(OutdatedArgs),
Caches(CachesArgs),
Ecosystems(JsonArgs),
History(JsonArgs),
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[derive(Debug, Args)]
pub struct WalkArgs {
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(long, value_name = "PATH")]
pub exclude: Vec<PathBuf>,
#[arg(long)]
pub hidden: bool,
#[arg(long, value_name = "N")]
pub max_depth: Option<usize>,
#[arg(long)]
pub cross_fs: bool,
#[arg(long)]
pub no_enclosing: bool,
}
impl WalkArgs {
pub fn for_path(path: PathBuf) -> Self {
Self {
path,
exclude: Vec::new(),
hidden: false,
max_depth: None,
cross_fs: false,
no_enclosing: false,
}
}
}
#[derive(Debug, Clone, Args)]
pub struct FilterArgs {
#[arg(long, value_name = "SIZE", value_parser = parse_size)]
pub min_size: Option<u64>,
#[arg(long, value_name = "AGE", value_parser = parse_age)]
pub older_than: Option<Duration>,
#[arg(long, value_name = "KIND", value_delimiter = ',', value_parser = parse_kind)]
pub kind: Vec<ArtifactKind>,
#[arg(
long = "ecosystem",
short = 'e',
value_name = "ID",
value_delimiter = ','
)]
pub ecosystems: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum SortKey {
#[default]
Size,
Age,
Path,
}
#[derive(Debug, Args)]
pub struct ScanArgs {
#[command(flatten)]
pub walk: WalkArgs,
#[command(flatten)]
pub filter: FilterArgs,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub no_git: bool,
#[arg(long)]
pub no_size: bool,
#[arg(long, value_enum, default_value_t)]
pub sort: SortKey,
#[arg(long, short = 'n', value_name = "N")]
pub limit: Option<usize>,
}
#[derive(Debug, Args)]
pub struct CleanArgs {
#[command(flatten)]
pub walk: WalkArgs,
#[command(flatten)]
pub filter: FilterArgs,
#[arg(long)]
pub dry_run: bool,
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub trash: bool,
#[arg(long)]
pub include_review: bool,
#[arg(long)]
pub include_recent: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct TasksArgs {
#[command(flatten)]
pub walk: WalkArgs,
#[arg(
long = "ecosystem",
short = 'e',
value_name = "ID",
value_delimiter = ','
)]
pub ecosystems: Vec<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct RunArgs {
pub task: String,
#[command(flatten)]
pub walk: WalkArgs,
#[arg(
long = "ecosystem",
short = 'e',
value_name = "ID",
value_delimiter = ','
)]
pub ecosystems: Vec<String>,
#[arg(long, value_name = "GLOB")]
pub filter: Vec<String>,
#[arg(long, short = 'j', value_name = "N", default_value_t = 1)]
pub jobs: usize,
#[arg(long)]
pub fail_fast: bool,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct OutdatedArgs {
#[command(flatten)]
pub walk: WalkArgs,
#[arg(
long = "ecosystem",
short = 'e',
value_name = "ID",
value_delimiter = ','
)]
pub ecosystems: Vec<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub no_vulns: bool,
#[arg(long)]
pub refresh: bool,
#[arg(long)]
pub exit_code: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct CachesArgs {
#[command(subcommand)]
pub action: Option<CachesAction>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Subcommand)]
pub enum CachesAction {
Clean {
#[arg(required = true, value_name = "ID")]
ids: Vec<String>,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
delete: bool,
},
}
#[derive(Debug, Args)]
pub struct JsonArgs {
#[arg(long)]
pub json: bool,
}
fn parse_size(s: &str) -> Result<u64, String> {
carwash_core::fmt::parse_bytes(s)
.ok_or_else(|| format!("invalid size `{s}` (try 100MB or 1.5GB)"))
}
fn parse_age(s: &str) -> Result<Duration, String> {
carwash_core::fmt::parse_age(s)
.ok_or_else(|| format!("invalid age `{s}` (try 30d, 6w, 3mo or 1y)"))
}
fn parse_kind(s: &str) -> Result<ArtifactKind, String> {
ArtifactKind::parse(s).ok_or_else(|| {
format!("unknown kind `{s}` (expected build, deps, cache, env, other or leftover)")
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parses_filters() {
let cli = Cli::try_parse_from([
"carwash",
"scan",
"~/work",
"--min-size",
"1GB",
"--older-than",
"30d",
"--kind",
"build,deps",
"-e",
"rust,node",
])
.unwrap();
let Some(Command::Scan(scan)) = cli.command else {
panic!("expected scan");
};
assert_eq!(scan.filter.min_size, Some(1_000_000_000));
assert_eq!(
scan.filter.kind,
vec![ArtifactKind::Build, ArtifactKind::Dependencies]
);
assert_eq!(scan.filter.ecosystems, vec!["rust", "node"]);
}
#[test]
fn bare_path_opens_the_ui() {
let cli = Cli::try_parse_from(["carwash", "/tmp"]).unwrap();
assert!(cli.command.is_none());
assert_eq!(cli.path, Some(PathBuf::from("/tmp")));
}
}