use crate::config::BastaConfig;
use crate::run::OutputOptions;
use clap::Parser;
use cpd_core::deadcode::Category;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "basta",
version,
about = "Find dead code: unused files, exports, declarations and imports",
long_about = "basta finds code nothing runs.\n\nIt builds the import graph from the project's entry points, walks it, and \
reports what it never reaches. Every finding carries a confidence score and \
the reasons it might be wrong, because static analysis of JavaScript and \
Python cannot be certain and a tool that pretends otherwise gets ignored."
)]
pub struct Cli {
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(long, value_name = "LIST", value_delimiter = ',')]
pub categories: Vec<String>,
#[arg(long, value_name = "N")]
pub min_confidence: Option<u8>,
#[arg(long, value_name = "N")]
pub min_lines: Option<u32>,
#[arg(long, value_name = "GLOB")]
pub entry: Vec<String>,
#[arg(long, value_name = "GLOB")]
pub ignore: Vec<String>,
#[arg(long)]
pub include_tests: bool,
#[arg(long)]
pub include_entry_exports: bool,
#[arg(long = "format", value_name = "FORMAT")]
pub formats: Vec<String>,
#[arg(long, value_name = "MAP")]
pub formats_exts: Option<String>,
#[arg(short = 'r', long, value_name = "LIST", value_delimiter = ',')]
pub reporters: Vec<String>,
#[arg(short = 'o', long, value_name = "DIR", default_value = "report")]
pub output: PathBuf,
#[arg(long, value_name = "PERCENT")]
pub threshold: Option<f64>,
#[arg(long, value_name = "CODE")]
pub exit_code: Option<i32>,
#[arg(long)]
pub no_gitignore: bool,
#[arg(long)]
pub follow_symlinks: bool,
#[arg(long, value_name = "SIZE")]
pub max_size: Option<String>,
#[arg(long, value_name = "N")]
pub workers: Option<usize>,
#[arg(long)]
pub no_colors: bool,
#[arg(short = 's', long)]
pub silent: bool,
#[arg(long)]
pub list: bool,
#[arg(long)]
pub debug: bool,
}
#[derive(Debug, PartialEq)]
pub enum Diagnostic {
Warning(String),
Error(String),
}
impl Diagnostic {
pub fn is_error(&self) -> bool {
matches!(self, Self::Error(_))
}
pub fn print(&self) {
match self {
Self::Warning(message) => eprintln!("Warning: {message}"),
Self::Error(message) => eprintln!("Error: {message}"),
}
}
}
pub fn resolve(cli: &Cli) -> (BastaConfig, OutputOptions, Vec<Diagnostic>) {
let mut diagnostics = Vec::new();
let defaults = BastaConfig::default();
let categories = resolve_categories(&cli.categories, &mut diagnostics);
let min_confidence = match cli.min_confidence {
Some(value) if value > 100 => {
diagnostics.push(Diagnostic::Warning(format!(
"--min-confidence: {value} is above 100, which would hide every finding; using 100"
)));
100
}
Some(value) => value,
None => defaults.min_confidence,
};
if let Some(threshold) = cli.threshold
&& !(0.0..=100.0).contains(&threshold)
{
diagnostics.push(Diagnostic::Warning(format!(
"--threshold: {threshold} is outside 0-100"
)));
}
let max_size = match &cli.max_size {
Some(raw) => match parse_size(raw) {
Some(bytes) => Some(bytes),
None => {
diagnostics.push(Diagnostic::Warning(format!(
"--max-size: '{raw}' is not a size like 500kb or 2mb; ignoring it"
)));
None
}
},
None => None,
};
let formats_exts = cli
.formats_exts
.as_deref()
.map(parse_format_mappings)
.unwrap_or_default();
let known = crate::lang::supported_formats();
for format in &cli.formats {
if !known.contains(&format.as_str()) && !formats_exts.contains_key(format) {
diagnostics.push(Diagnostic::Error(format!(
"--format: '{format}' is not a format basta analyzes (run --list to see them)"
)));
}
}
let reporters = if cli.reporters.is_empty() {
vec!["console".to_string()]
} else {
cli.reporters.clone()
};
for name in &reporters {
let normalized = crate::run::normalize_reporter_name(name);
if !cpd_reporter::deadcode::dead_code_reporter_names().contains(&normalized) {
diagnostics.push(Diagnostic::Warning(format!(
"unknown reporter '{name}'; it will be skipped"
)));
}
}
for path in &cli.paths {
if !path.exists() {
diagnostics.push(Diagnostic::Error(format!(
"path does not exist: {}",
path.display()
)));
}
}
let config = BastaConfig {
paths: cli.paths.clone(),
categories,
min_confidence,
entry: cli.entry.clone(),
ignore: cli.ignore.clone(),
include_tests: cli.include_tests,
include_entry_exports: cli.include_entry_exports,
min_lines: cli.min_lines.unwrap_or(defaults.min_lines),
no_gitignore: cli.no_gitignore,
follow_symlinks: cli.follow_symlinks,
max_size,
workers: cli.workers,
formats: cli.formats.clone(),
formats_exts,
};
let output = OutputOptions {
reporters,
output_dir: cli.output.clone(),
no_colors: cli.no_colors || std::env::var_os("NO_COLOR").is_some(),
silent: cli.silent,
threshold: cli.threshold,
exit_code: cli.exit_code,
tool_version: env!("CARGO_PKG_VERSION").to_string(),
};
(config, output, diagnostics)
}
fn resolve_categories(raw: &[String], diagnostics: &mut Vec<Diagnostic>) -> Vec<Category> {
if raw.is_empty() {
return Category::DEFAULT.to_vec();
}
let mut categories = Vec::new();
for entry in raw {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if entry.eq_ignore_ascii_case("all") {
categories.extend_from_slice(Category::ALL);
continue;
}
match entry.parse::<Category>() {
Ok(category) => categories.push(category),
Err(message) => diagnostics.push(Diagnostic::Error(format!("--categories: {message}"))),
}
}
categories.sort();
categories.dedup();
if categories.is_empty() {
return Category::DEFAULT.to_vec();
}
categories
}
pub fn parse_size(input: &str) -> Option<u64> {
let text = input.trim().to_lowercase();
if text.is_empty() {
return None;
}
let (digits, multiplier) = if let Some(rest) = text.strip_suffix("gb") {
(rest, 1024 * 1024 * 1024)
} else if let Some(rest) = text.strip_suffix("mb") {
(rest, 1024 * 1024)
} else if let Some(rest) = text.strip_suffix("kb") {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix('g') {
(rest, 1024 * 1024 * 1024)
} else if let Some(rest) = text.strip_suffix('m') {
(rest, 1024 * 1024)
} else if let Some(rest) = text.strip_suffix('k') {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix('b') {
(rest, 1)
} else {
(text.as_str(), 1)
};
let value: f64 = digits.trim().parse().ok()?;
(value >= 0.0).then(|| (value * multiplier as f64).round() as u64)
}
pub fn parse_format_mappings(input: &str) -> HashMap<String, Vec<String>> {
let mut mappings = HashMap::new();
for group in input.split(';') {
let Some((format, extensions)) = group.trim().split_once(':') else {
continue;
};
let format = format.trim().to_string();
let extensions: Vec<String> = extensions
.split(',')
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.collect();
if !format.is_empty() && !extensions.is_empty() {
mappings.insert(format, extensions);
}
}
mappings
}
pub fn describe(config: &BastaConfig, output: &OutputOptions) -> String {
let value = serde_json::json!({
"paths": config.paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"categories": config.active_categories().iter().map(|c| c.as_str()).collect::<Vec<_>>(),
"minConfidence": config.min_confidence,
"minLines": config.min_lines,
"entry": config.entry,
"ignore": config.ignore,
"includeTests": config.include_tests,
"includeEntryExports": config.include_entry_exports,
"formats": if config.formats.is_empty() {
crate::lang::supported_formats().iter().map(|s| s.to_string()).collect::<Vec<_>>()
} else {
config.formats.clone()
},
"formatsExts": config.formats_exts,
"noGitignore": config.no_gitignore,
"followSymlinks": config.follow_symlinks,
"maxSize": config.max_size,
"workers": config.workers,
"reporters": output.reporters,
"outputDir": output.output_dir.display().to_string(),
"threshold": output.threshold,
"exitCode": output.exit_code,
"noColors": output.no_colors,
"silent": output.silent,
});
serde_json::to_string_pretty(&value).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Cli {
Cli::parse_from(std::iter::once("basta").chain(args.iter().copied()))
}
fn resolved(args: &[&str]) -> (BastaConfig, OutputOptions, Vec<Diagnostic>) {
resolve(&parse(args))
}
#[test]
fn defaults_match_the_library_defaults() {
let (config, output, diagnostics) = resolved(&[]);
assert!(diagnostics.is_empty());
assert_eq!(config.categories, Category::DEFAULT.to_vec());
assert_eq!(config.min_confidence, 60);
assert!(!config.include_tests);
assert_eq!(output.reporters, vec!["console"]);
assert_eq!(output.output_dir, PathBuf::from("report"));
}
#[test]
fn categories_accept_a_list_and_the_word_all() {
let (config, _, diagnostics) = resolved(&["--categories", "unused-export,imports"]);
assert!(diagnostics.is_empty());
assert_eq!(
config.categories,
vec![Category::UnusedExport, Category::UnusedImport]
);
let (config, _, _) = resolved(&["--categories", "all"]);
assert_eq!(config.categories, Category::ALL.to_vec());
}
#[test]
fn an_unknown_category_is_an_error_not_a_silent_skip() {
let (_, _, diagnostics) = resolved(&["--categories", "unused-everything"]);
assert!(
diagnostics.iter().any(Diagnostic::is_error),
"{diagnostics:?}"
);
}
#[test]
fn duplicate_categories_collapse() {
let (config, _, _) = resolved(&["--categories", "exports,unused-exports,export"]);
assert_eq!(config.categories, vec![Category::UnusedExport]);
}
#[test]
fn an_unknown_format_is_an_error_because_it_would_scan_nothing() {
let (_, _, diagnostics) = resolved(&["--format", "ruby"]);
assert!(
diagnostics
.iter()
.any(|d| d.is_error() && format!("{d:?}").contains("ruby")),
"{diagnostics:?}"
);
}
#[test]
fn a_format_introduced_by_a_mapping_is_accepted() {
let (_, _, diagnostics) = resolved(&[
"--format",
"typescript",
"--formats-exts",
"typescript:mts,cts",
]);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
#[test]
fn an_unknown_reporter_warns_but_does_not_stop_the_run() {
let (_, _, diagnostics) = resolved(&["-r", "nonsense"]);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].is_error());
}
#[test]
fn reporter_aliases_are_not_reported_as_unknown() {
for alias in ["full", "consoleFull", "gitlab"] {
let (_, _, diagnostics) = resolved(&["-r", alias]);
assert!(diagnostics.is_empty(), "{alias}: {diagnostics:?}");
}
}
#[test]
fn a_missing_path_is_an_error() {
let (_, _, diagnostics) = resolved(&["/definitely/not/here"]);
assert!(diagnostics.iter().any(Diagnostic::is_error));
}
#[test]
fn an_impossible_confidence_threshold_is_clamped_with_a_warning() {
let (config, _, diagnostics) = resolved(&["--min-confidence", "200"]);
assert_eq!(config.min_confidence, 100);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].is_error());
}
#[test]
fn sizes_parse_the_way_jscpd_parses_them() {
assert_eq!(parse_size("1kb"), Some(1024));
assert_eq!(parse_size("2MB"), Some(2 * 1024 * 1024));
assert_eq!(parse_size(" 100 "), Some(100));
assert_eq!(parse_size("1.5k"), Some(1536));
assert_eq!(parse_size("huge"), None);
assert_eq!(parse_size(""), None);
assert_eq!(parse_size("-5kb"), None);
}
#[test]
fn a_bad_size_warns_and_is_ignored_rather_than_failing() {
let (config, _, diagnostics) = resolved(&["--max-size", "enormous"]);
assert!(config.max_size.is_none());
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].is_error());
}
#[test]
fn format_mappings_parse_into_a_map() {
let mappings = parse_format_mappings("typescript:mts,cts;python: pyw ");
assert_eq!(mappings["typescript"], vec!["mts", "cts"]);
assert_eq!(mappings["python"], vec!["pyw"]);
assert!(parse_format_mappings("garbage").is_empty());
}
#[test]
fn repeatable_flags_accumulate() {
let cli = parse(&[
"--entry",
"src/handlers/**",
"--entry",
"scripts/*.ts",
"--ignore",
"**/generated/**",
]);
assert_eq!(cli.entry.len(), 2);
assert_eq!(cli.ignore.len(), 1);
}
#[test]
fn no_color_in_the_environment_is_honoured() {
let (_, output, _) = resolved(&["--no-colors"]);
assert!(output.no_colors);
}
#[test]
fn every_flag_parses_together() {
let cli = parse(&[
"src",
"lib",
"--categories",
"all",
"--min-confidence",
"75",
"--min-lines",
"3",
"--entry",
"bin/*.ts",
"--ignore",
"**/dist/**",
"--include-tests",
"--include-entry-exports",
"--format",
"typescript",
"-r",
"console,json",
"-o",
"out",
"--threshold",
"5",
"--exit-code",
"2",
"--no-gitignore",
"--follow-symlinks",
"--max-size",
"1mb",
"--workers",
"4",
"--no-colors",
"--silent",
]);
assert_eq!(cli.paths.len(), 2);
assert_eq!(cli.reporters, vec!["console", "json"]);
assert_eq!(cli.workers, Some(4));
assert!(cli.include_tests && cli.include_entry_exports && cli.silent);
}
#[test]
fn debug_output_is_json_that_names_what_the_run_will_do() {
let (config, output, _) = resolved(&["--categories", "all", "--min-confidence", "80"]);
let json: serde_json::Value = serde_json::from_str(&describe(&config, &output)).unwrap();
assert_eq!(json["minConfidence"], 80);
assert_eq!(
json["categories"].as_array().unwrap().len(),
Category::ALL.len()
);
assert_eq!(json["reporters"][0], "console");
assert!(
json["formats"]
.as_array()
.unwrap()
.iter()
.any(|f| f == "python"),
"an unrestricted run names the formats it will actually analyze"
);
}
#[test]
fn the_command_definition_is_internally_consistent() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
}