use std::path::PathBuf;
use clap::{ArgAction, Parser, ValueEnum};
use crate::core::profiles::{DEFAULT_PROFILE, profile_names};
use crate::core::types::SortBy;
#[derive(Debug, Parser)]
#[command(
name = "cft",
version,
about = "Polyglot dev-cache reaper — kill node_modules, .venv, target, DerivedData, and the rest of your build cruft",
long_about = None,
)]
pub struct CliArgs {
pub root: Option<PathBuf>,
#[arg(short, long = "profile", action = ArgAction::Append)]
pub profile: Vec<String>,
#[arg(short, long = "target", action = ArgAction::Append)]
pub target: Vec<String>,
#[arg(short, long = "exclude", action = ArgAction::Append)]
pub exclude: Vec<String>,
#[arg(short = 's', long, value_enum, default_value_t = SortArg::Size)]
pub sort: SortArg,
#[arg(long = "no-risk-analysis", default_value_t = false)]
pub no_risk: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long = "no-tui")]
pub no_tui: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum SortArg {
Path,
Size,
Age,
}
impl From<SortArg> for SortBy {
fn from(s: SortArg) -> Self {
match s {
SortArg::Path => SortBy::Path,
SortArg::Size => SortBy::Size,
SortArg::Age => SortBy::Age,
}
}
}
impl CliArgs {
pub fn root_path(&self) -> std::io::Result<PathBuf> {
match &self.root {
Some(p) => Ok(p.clone()),
None => std::env::current_dir(),
}
}
pub fn resolved_targets(&self) -> Vec<String> {
let profile_strs: Vec<&str> = if self.profile.is_empty() {
vec![DEFAULT_PROFILE]
} else {
self.profile.iter().map(String::as_str).collect()
};
let mut targets = crate::core::profiles::resolve_targets(&profile_strs);
for t in &self.target {
targets.push(t.clone());
}
targets.sort();
targets.dedup();
targets
}
pub fn unknown_profile(&self) -> Option<&str> {
let known = profile_names();
self.profile
.iter()
.find(|p| p.as_str() != "all" && !known.contains(&p.as_str()))
.map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_with_no_args() {
let args = CliArgs::try_parse_from(["cft"]).unwrap();
assert!(args.root.is_none());
assert!(!args.dry_run);
assert!(!args.no_tui);
assert!(matches!(args.sort, SortArg::Size));
}
#[test]
fn parses_root_positional() {
let args = CliArgs::try_parse_from(["cft", "/tmp/scan"]).unwrap();
assert_eq!(args.root, Some(PathBuf::from("/tmp/scan")));
}
#[test]
fn parses_dry_run_flag() {
let args = CliArgs::try_parse_from(["cft", "--dry-run"]).unwrap();
assert!(args.dry_run);
}
#[test]
fn parses_no_tui_flag() {
let args = CliArgs::try_parse_from(["cft", "--no-tui"]).unwrap();
assert!(args.no_tui);
}
#[test]
fn parses_multiple_profiles() {
let args = CliArgs::try_parse_from(["cft", "-p", "node", "-p", "python"]).unwrap();
assert_eq!(args.profile, vec!["node", "python"]);
}
#[test]
fn parses_sort_age() {
let args = CliArgs::try_parse_from(["cft", "-s", "age"]).unwrap();
assert!(matches!(args.sort, SortArg::Age));
}
#[test]
fn default_profile_when_none_passed() {
let args = CliArgs::try_parse_from(["cft"]).unwrap();
let t = args.resolved_targets();
assert!(t.contains(&"node_modules".to_string()));
}
#[test]
fn extra_targets_merged() {
let args = CliArgs::try_parse_from(["cft", "-p", "node", "-t", "extra_dir"]).unwrap();
let t = args.resolved_targets();
assert!(t.contains(&"extra_dir".to_string()));
assert!(t.contains(&"node_modules".to_string()));
}
#[test]
fn unknown_profile_detected() {
let args = CliArgs::try_parse_from(["cft", "-p", "node", "-p", "this-is-fake"]).unwrap();
assert_eq!(args.unknown_profile(), Some("this-is-fake"));
}
#[test]
fn all_profile_is_recognised() {
let args = CliArgs::try_parse_from(["cft", "-p", "all"]).unwrap();
assert!(args.unknown_profile().is_none());
}
#[test]
fn root_path_falls_back_to_cwd() {
let args = CliArgs {
root: None,
profile: vec![],
target: vec![],
exclude: vec![],
sort: SortArg::Size,
no_risk: false,
dry_run: false,
no_tui: false,
};
let cwd = args.root_path().unwrap();
assert!(cwd.exists());
}
}