#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI binary produces intentional terminal output"
)]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use fallow_config::FallowConfig;
mod audit;
mod baseline;
mod check;
mod codeowners;
mod combined;
mod config;
mod coverage;
mod dupes;
mod error;
mod explain;
mod fix;
mod flags;
mod health;
mod health_types;
mod init;
mod license;
mod list;
mod migrate;
mod regression;
mod report;
mod schema;
mod validate;
mod vital_signs;
mod watch;
use check::{CheckOptions, IssueFilters, TraceOptions};
use dupes::{DupesMode, DupesOptions};
use error::emit_error;
use health::{HealthOptions, SortBy};
use list::ListOptions;
#[derive(Parser)]
#[command(
name = "fallow",
about = "Codebase analyzer for TypeScript/JavaScript — unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
version,
after_help = "When no command is given, runs dead-code + dupes + health together.\nUse --only/--skip to select specific analyses."
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(short, long, global = true)]
root: Option<PathBuf>,
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[arg(
short,
long,
visible_alias = "output",
global = true,
default_value = "human"
)]
format: Format,
#[arg(short, long, global = true)]
quiet: bool,
#[arg(long, global = true)]
no_cache: bool,
#[arg(long, global = true)]
threads: Option<usize>,
#[arg(long, visible_alias = "base", global = true)]
changed_since: Option<String>,
#[arg(long, global = true)]
baseline: Option<PathBuf>,
#[arg(long, global = true)]
save_baseline: Option<PathBuf>,
#[arg(long, global = true)]
production: bool,
#[arg(short, long, global = true)]
workspace: Option<String>,
#[arg(long, global = true)]
group_by: Option<GroupBy>,
#[arg(long, global = true)]
performance: bool,
#[arg(long, global = true)]
explain: bool,
#[arg(long, global = true)]
summary: bool,
#[arg(long, global = true)]
ci: bool,
#[arg(long, global = true)]
fail_on_issues: bool,
#[arg(long, global = true, value_name = "PATH")]
sarif_file: Option<PathBuf>,
#[arg(long, global = true)]
fail_on_regression: bool,
#[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
tolerance: String,
#[arg(long, global = true, value_name = "PATH")]
regression_baseline: Option<PathBuf>,
#[expect(
clippy::option_option,
reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
)]
#[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
save_regression_baseline: Option<Option<String>>,
#[arg(long, value_delimiter = ',')]
only: Vec<AnalysisKind>,
#[arg(long, value_delimiter = ',')]
skip: Vec<AnalysisKind>,
#[arg(long)]
score: bool,
#[arg(long)]
trend: bool,
#[expect(
clippy::option_option,
reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
)]
#[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
save_snapshot: Option<Option<String>>,
}
#[derive(Subcommand)]
enum Command {
#[command(name = "dead-code", alias = "check")]
Check {
#[arg(long)]
unused_files: bool,
#[arg(long)]
unused_exports: bool,
#[arg(long)]
unused_deps: bool,
#[arg(long)]
unused_types: bool,
#[arg(long)]
unused_enum_members: bool,
#[arg(long)]
unused_class_members: bool,
#[arg(long)]
unresolved_imports: bool,
#[arg(long)]
unlisted_deps: bool,
#[arg(long)]
duplicate_exports: bool,
#[arg(long)]
circular_deps: bool,
#[arg(long)]
boundary_violations: bool,
#[arg(long)]
stale_suppressions: bool,
#[arg(long)]
include_dupes: bool,
#[arg(long, value_name = "FILE:EXPORT")]
trace: Option<String>,
#[arg(long, value_name = "PATH")]
trace_file: Option<String>,
#[arg(long, value_name = "PACKAGE")]
trace_dependency: Option<String>,
#[arg(long)]
top: Option<usize>,
#[arg(long, value_name = "PATH")]
file: Vec<std::path::PathBuf>,
#[arg(long)]
include_entry_exports: bool,
},
Watch {
#[arg(long)]
no_clear: bool,
},
Fix {
#[arg(long)]
dry_run: bool,
#[arg(long, alias = "force")]
yes: bool,
},
Init {
#[arg(long)]
toml: bool,
#[arg(long)]
hooks: bool,
#[arg(long, requires = "hooks")]
branch: Option<String>,
},
ConfigSchema,
PluginSchema,
Config {
#[arg(long)]
path: bool,
},
List {
#[arg(long)]
entry_points: bool,
#[arg(long)]
files: bool,
#[arg(long)]
plugins: bool,
#[arg(long)]
boundaries: bool,
},
Dupes {
#[arg(long, default_value = "mild")]
mode: DupesMode,
#[arg(long, default_value = "50")]
min_tokens: usize,
#[arg(long, default_value = "5")]
min_lines: usize,
#[arg(long, default_value = "0")]
threshold: f64,
#[arg(long)]
skip_local: bool,
#[arg(long)]
cross_language: bool,
#[arg(long)]
ignore_imports: bool,
#[arg(long)]
top: Option<usize>,
#[arg(long, value_name = "FILE:LINE")]
trace: Option<String>,
},
Health {
#[arg(long)]
max_cyclomatic: Option<u16>,
#[arg(long)]
max_cognitive: Option<u16>,
#[arg(long)]
top: Option<usize>,
#[arg(long, default_value = "cyclomatic")]
sort: SortBy,
#[arg(long)]
complexity: bool,
#[arg(long)]
file_scores: bool,
#[arg(long)]
coverage_gaps: bool,
#[arg(long)]
hotspots: bool,
#[arg(long)]
ownership: bool,
#[arg(long, value_name = "MODE", value_enum)]
ownership_emails: Option<EmailModeArg>,
#[arg(long)]
targets: bool,
#[arg(long, value_enum)]
effort: Option<EffortFilter>,
#[arg(long)]
score: bool,
#[arg(long, value_name = "N")]
min_score: Option<f64>,
#[arg(long, value_name = "LEVEL", value_enum)]
min_severity: Option<crate::health_types::FindingSeverity>,
#[arg(long, value_name = "DURATION")]
since: Option<String>,
#[arg(long, value_name = "N")]
min_commits: Option<u32>,
#[expect(
clippy::option_option,
reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
)]
#[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
save_snapshot: Option<Option<String>>,
#[arg(long)]
trend: bool,
#[arg(long, value_name = "PATH")]
coverage: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
coverage_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
production_coverage: Option<PathBuf>,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "N")]
min_observation_volume: Option<u32>,
#[arg(long, value_name = "RATIO")]
low_traffic_threshold: Option<f64>,
},
Flags {
#[arg(long)]
top: Option<usize>,
},
Audit,
Schema,
Migrate {
#[arg(long)]
toml: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "PATH")]
from: Option<PathBuf>,
},
License {
#[command(subcommand)]
subcommand: LicenseCli,
},
Coverage {
#[command(subcommand)]
subcommand: CoverageCli,
},
}
#[derive(clap::Subcommand)]
enum LicenseCli {
Activate {
#[arg(value_name = "JWT")]
jwt: Option<String>,
#[arg(long, value_name = "PATH")]
from_file: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["jwt", "from_file"])]
stdin: bool,
#[arg(long, requires = "email")]
trial: bool,
#[arg(long, value_name = "ADDR")]
email: Option<String>,
},
Status,
Refresh,
Deactivate,
}
#[derive(clap::Subcommand)]
enum CoverageCli {
Setup {
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
non_interactive: bool,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum Format {
Human,
Json,
Sarif,
Compact,
Markdown,
#[value(name = "codeclimate")]
CodeClimate,
Badge,
}
impl From<Format> for fallow_config::OutputFormat {
fn from(f: Format) -> Self {
match f {
Format::Human => Self::Human,
Format::Json => Self::Json,
Format::Sarif => Self::Sarif,
Format::Compact => Self::Compact,
Format::Markdown => Self::Markdown,
Format::CodeClimate => Self::CodeClimate,
Format::Badge => Self::Badge,
}
}
}
#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
pub enum AnalysisKind {
#[value(alias = "check")]
DeadCode,
Dupes,
Health,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum GroupBy {
#[value(alias = "team", alias = "codeowner")]
Owner,
Directory,
#[value(alias = "workspace", alias = "pkg")]
Package,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum EffortFilter {
Low,
Medium,
High,
}
impl EffortFilter {
const fn to_estimate(self) -> health_types::EffortEstimate {
match self {
Self::Low => health_types::EffortEstimate::Low,
Self::Medium => health_types::EffortEstimate::Medium,
Self::High => health_types::EffortEstimate::High,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum EmailModeArg {
Raw,
Handle,
Hash,
}
impl EmailModeArg {
const fn to_config(self) -> fallow_config::EmailMode {
match self {
Self::Raw => fallow_config::EmailMode::Raw,
Self::Handle => fallow_config::EmailMode::Handle,
Self::Hash => fallow_config::EmailMode::Hash,
}
}
}
fn format_from_env() -> Option<Format> {
let val = std::env::var("FALLOW_FORMAT").ok()?;
match val.to_lowercase().as_str() {
"json" => Some(Format::Json),
"human" => Some(Format::Human),
"sarif" => Some(Format::Sarif),
"compact" => Some(Format::Compact),
"markdown" | "md" => Some(Format::Markdown),
"codeclimate" => Some(Format::CodeClimate),
"badge" => Some(Format::Badge),
_ => None,
}
}
fn quiet_from_env() -> bool {
std::env::var("FALLOW_QUIET").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn build_ownership_resolver(
group_by: Option<GroupBy>,
root: &std::path::Path,
codeowners_path: Option<&str>,
output: fallow_config::OutputFormat,
) -> Result<Option<report::OwnershipResolver>, ExitCode> {
let Some(mode) = group_by else {
return Ok(None);
};
match mode {
GroupBy::Owner => match codeowners::CodeOwners::load(root, codeowners_path) {
Ok(co) => Ok(Some(report::OwnershipResolver::Owner(co))),
Err(e) => Err(emit_error(&e, 2, output)),
},
GroupBy::Directory => Ok(Some(report::OwnershipResolver::Directory)),
GroupBy::Package => {
let workspaces = fallow_config::discover_workspaces(root);
if workspaces.is_empty() {
Err(emit_error(
"--group-by package requires a monorepo with workspace packages \
(package.json workspaces, pnpm-workspace.yaml, or tsconfig references)",
2,
output,
))
} else {
Ok(Some(report::OwnershipResolver::Package(
report::grouping::PackageResolver::new(root, &workspaces),
)))
}
}
}
}
fn log_config_loaded(path: &std::path::Path, output: fallow_config::OutputFormat, quiet: bool) {
if quiet || !matches!(output, fallow_config::OutputFormat::Human) {
return;
}
eprintln!("loaded config: {}", path.display());
}
#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
fn load_config(
root: &std::path::Path,
config_path: &Option<PathBuf>,
output: fallow_config::OutputFormat,
no_cache: bool,
threads: usize,
production: bool,
quiet: bool,
) -> Result<fallow_config::ResolvedConfig, ExitCode> {
let user_config = if let Some(path) = config_path {
match FallowConfig::load(path) {
Ok(c) => {
log_config_loaded(path, output, quiet);
Some(c)
}
Err(e) => {
let msg = format!("failed to load config '{}': {e}", path.display());
return Err(emit_error(&msg, 2, output));
}
}
} else {
match FallowConfig::find_and_load(root) {
Ok(Some((config, found_path))) => {
log_config_loaded(&found_path, output, quiet);
Some(config)
}
Ok(None) => None,
Err(e) => {
return Err(emit_error(&e, 2, output));
}
}
};
Ok(match user_config {
Some(mut config) => {
if production {
config.production = true;
}
config.resolve(root.to_path_buf(), output, threads, no_cache, quiet)
}
None => FallowConfig {
production,
..FallowConfig::default()
}
.resolve(root.to_path_buf(), output, threads, no_cache, quiet),
})
}
struct FormatConfig {
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
}
fn resolve_format(cli: &Cli) -> FormatConfig {
let cli_format_was_explicit = std::env::args()
.any(|a| a == "--format" || a == "--output" || a.starts_with("--format=") || a == "-f");
let format: Format = if cli_format_was_explicit {
cli.format
} else {
format_from_env().unwrap_or(cli.format)
};
let quiet = cli.quiet || quiet_from_env();
FormatConfig {
output: format.into(),
quiet,
cli_format_was_explicit,
}
}
fn setup_tracing(quiet: bool, is_watch: bool) {
let stderr_is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
let default_level = if quiet {
tracing::Level::WARN
} else if is_watch || stderr_is_tty {
tracing::Level::WARN
} else {
tracing::Level::INFO
};
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env().add_directive(default_level.into()),
)
.with_target(false)
.with_timer(tracing_subscriber::fmt::time::uptime())
.init();
}
fn validate_inputs(
cli: &Cli,
output: fallow_config::OutputFormat,
) -> Result<(PathBuf, usize), ExitCode> {
if let Some(ref config_path) = cli.config
&& let Some(s) = config_path.to_str()
&& let Err(e) = validate::validate_no_control_chars(s, "--config")
{
return Err(emit_error(&e, 2, output));
}
if let Some(ref ws) = cli.workspace
&& let Err(e) = validate::validate_no_control_chars(ws, "--workspace")
{
return Err(emit_error(&e, 2, output));
}
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-since")
{
return Err(emit_error(&e, 2, output));
}
let raw_root = cli
.root
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
let root = match validate::validate_root(&raw_root) {
Ok(r) => r,
Err(e) => {
return Err(emit_error(&e, 2, output));
}
};
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_error(
&format!("invalid --changed-since: {e}"),
2,
output,
));
}
let threads = cli
.threads
.unwrap_or_else(|| std::thread::available_parallelism().map_or(4, std::num::NonZero::get));
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build_global();
Ok((root, threads))
}
fn apply_ci_defaults(
ci: bool,
mut fail_on_issues: bool,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
) -> (fallow_config::OutputFormat, bool, bool) {
if ci {
let ci_output = if !cli_format_was_explicit && format_from_env().is_none() {
fallow_config::OutputFormat::Sarif
} else {
output
};
fail_on_issues = true;
(ci_output, true, fail_on_issues)
} else {
(output, quiet, fail_on_issues)
}
}
fn build_regression_opts<'a>(
fail_on_regression: bool,
tolerance: regression::Tolerance,
regression_baseline: Option<&'a std::path::Path>,
save_regression_file: Option<&'a std::path::PathBuf>,
save_to_config: bool,
scoped: bool,
quiet: bool,
) -> regression::RegressionOpts<'a> {
regression::RegressionOpts {
fail_on_regression,
tolerance,
regression_baseline_file: regression_baseline,
save_target: if let Some(path) = save_regression_file {
regression::SaveRegressionTarget::File(path)
} else if save_to_config {
regression::SaveRegressionTarget::Config
} else {
regression::SaveRegressionTarget::None
},
scoped,
quiet,
}
}
fn main() -> ExitCode {
let mut cli = Cli::parse();
if matches!(cli.command, Some(Command::Schema)) {
return schema::run_schema();
}
if matches!(cli.command, Some(Command::ConfigSchema)) {
return init::run_config_schema();
}
if matches!(cli.command, Some(Command::PluginSchema)) {
return init::run_plugin_schema();
}
let fmt = resolve_format(&cli);
setup_tracing(
fmt.quiet,
matches!(cli.command, Some(Command::Watch { .. })),
);
let (root, threads) = match validate_inputs(&cli, fmt.output) {
Ok(v) => v,
Err(code) => return code,
};
let FormatConfig {
output,
quiet,
cli_format_was_explicit,
} = fmt;
if (cli.ci || cli.fail_on_issues || cli.sarif_file.is_some())
&& matches!(
cli.command,
Some(
Command::Init { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::Schema
| Command::Config { .. }
| Command::List { .. }
| Command::Flags { .. }
| Command::Migrate { .. }
| Command::License { .. }
| Command::Coverage { .. }
)
)
{
return emit_error(
"--ci, --fail-on-issues, and --sarif-file are only valid with dead-code, dupes, health, or bare invocation",
2,
output,
);
}
if (!cli.only.is_empty() || !cli.skip.is_empty()) && cli.command.is_some() {
return emit_error(
"--only and --skip can only be used without a subcommand",
2,
output,
);
}
if !cli.only.is_empty() && !cli.skip.is_empty() {
return emit_error("--only and --skip are mutually exclusive", 2, output);
}
let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
Ok(t) => t,
Err(e) => return emit_error(&format!("invalid --tolerance: {e}"), 2, output),
};
let save_regression_file: Option<std::path::PathBuf> =
cli.save_regression_baseline.as_ref().and_then(|opt| {
opt.as_ref()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
});
let save_to_config = cli.save_regression_baseline.is_some() && save_regression_file.is_none();
let command = cli.command.take();
match command {
None => dispatch_bare_command(
&cli,
&root,
output,
quiet,
cli_format_was_explicit,
threads,
tolerance,
save_regression_file.as_ref(),
save_to_config,
),
Some(cmd) => dispatch_subcommand(
cmd,
&cli,
&root,
output,
quiet,
cli_format_was_explicit,
threads,
tolerance,
save_regression_file.as_ref(),
save_to_config,
),
}
}
#[expect(
clippy::too_many_arguments,
reason = "CLI dispatch forwards many flags"
)]
fn dispatch_bare_command(
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
threads: usize,
tolerance: regression::Tolerance,
save_regression_file: Option<&std::path::PathBuf>,
save_to_config: bool,
) -> ExitCode {
let (output, quiet, fail_on_issues) = apply_ci_defaults(
cli.ci,
cli.fail_on_issues,
output,
quiet,
cli_format_was_explicit,
);
let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
combined::run_combined(&combined::CombinedOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
fail_on_issues,
sarif_file: cli.sarif_file.as_deref(),
changed_since: cli.changed_since.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
production: cli.production,
workspace: cli.workspace.as_deref(),
group_by: cli.group_by,
explain: cli.explain,
performance: cli.performance,
summary: cli.summary,
run_check,
run_dupes,
run_health,
score: cli.score || cli.trend,
trend: cli.trend,
save_snapshot: cli.save_snapshot.as_ref(),
regression_opts: build_regression_opts(
cli.fail_on_regression,
tolerance,
cli.regression_baseline.as_deref(),
save_regression_file,
save_to_config,
cli.changed_since.is_some() || cli.workspace.is_some(),
quiet,
),
})
}
#[expect(
clippy::too_many_arguments,
reason = "CLI dispatch forwards many flags"
)]
#[expect(
clippy::too_many_lines,
reason = "CLI dispatch handles all subcommands"
)]
fn dispatch_subcommand(
command: Command,
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
threads: usize,
tolerance: regression::Tolerance,
save_regression_file: Option<&std::path::PathBuf>,
save_to_config: bool,
) -> ExitCode {
match command {
Command::Check {
unused_files,
unused_exports,
unused_deps,
unused_types,
unused_enum_members,
unused_class_members,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
boundary_violations,
stale_suppressions,
include_dupes,
trace,
trace_file,
trace_dependency,
top,
file,
include_entry_exports,
} => {
let (output, quiet, fail_on_issues) = apply_ci_defaults(
cli.ci,
cli.fail_on_issues,
output,
quiet,
cli_format_was_explicit,
);
let filters = IssueFilters {
unused_files,
unused_exports,
unused_deps,
unused_types,
unused_enum_members,
unused_class_members,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
boundary_violations,
stale_suppressions,
};
let trace_opts = TraceOptions {
trace_export: trace,
trace_file,
trace_dependency,
performance: cli.performance,
};
check::run_check(&CheckOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
fail_on_issues,
filters: &filters,
changed_since: cli.changed_since.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
sarif_file: cli.sarif_file.as_deref(),
production: cli.production,
workspace: cli.workspace.as_deref(),
group_by: cli.group_by,
include_dupes,
trace_opts: &trace_opts,
explain: cli.explain,
top,
file: &file,
include_entry_exports,
summary: cli.summary,
regression_opts: build_regression_opts(
cli.fail_on_regression,
tolerance,
cli.regression_baseline.as_deref(),
save_regression_file,
save_to_config,
cli.changed_since.is_some() || cli.workspace.is_some() || !file.is_empty(),
quiet,
),
retain_modules_for_health: false,
})
}
Command::Watch { no_clear } => watch::run_watch(&watch::WatchOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
production: cli.production,
clear_screen: !no_clear,
explain: cli.explain,
}),
Command::Fix { dry_run, yes } => fix::run_fix(&fix::FixOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
dry_run,
yes,
production: cli.production,
}),
Command::Init {
toml,
hooks,
branch,
} => init::run_init(&init::InitOptions {
root,
use_toml: toml,
hooks,
branch: branch.as_deref(),
}),
Command::ConfigSchema => init::run_config_schema(),
Command::PluginSchema => init::run_plugin_schema(),
Command::Config { path } => config::run_config(root, cli.config.as_deref(), path),
Command::List {
entry_points,
files,
plugins,
boundaries,
} => list::run_list(&ListOptions {
root,
config_path: &cli.config,
output,
threads,
no_cache: cli.no_cache,
entry_points,
files,
plugins,
boundaries,
production: cli.production,
}),
Command::Dupes {
mode,
min_tokens,
min_lines,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
} => {
let (output, quiet, _fail_on_issues) = apply_ci_defaults(
cli.ci,
cli.fail_on_issues,
output,
quiet,
cli_format_was_explicit,
);
dupes::run_dupes(&DupesOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
mode,
min_tokens,
min_lines,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
baseline_path: cli.baseline.as_deref(),
save_baseline_path: cli.save_baseline.as_deref(),
production: cli.production,
trace: trace.as_deref(),
changed_since: cli.changed_since.as_deref(),
explain: cli.explain,
summary: cli.summary,
group_by: cli.group_by,
})
}
Command::Health {
max_cyclomatic,
max_cognitive,
top,
sort,
complexity,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails,
targets,
effort,
score,
min_score,
min_severity,
since,
min_commits,
save_snapshot,
trend,
coverage,
coverage_root,
production_coverage,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
} => {
let coverage =
coverage.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
let ownership = ownership || ownership_emails.is_some();
let hotspots = hotspots || ownership;
dispatch_health(
cli,
root,
output,
quiet,
cli_format_was_explicit,
threads,
max_cyclomatic,
max_cognitive,
top,
sort,
complexity,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails.map(EmailModeArg::to_config),
targets,
effort,
score,
min_score,
min_severity,
since.as_deref(),
min_commits,
save_snapshot.as_ref(),
trend,
coverage.as_deref(),
coverage_root.as_deref(),
production_coverage.as_deref(),
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
)
}
Command::Flags { top } => flags::run_flags(&flags::FlagsOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
production: cli.production,
workspace: cli.workspace.as_deref(),
changed_since: cli.changed_since.as_deref(),
explain: cli.explain,
top,
}),
Command::Audit => audit::run_audit(&audit::AuditOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
changed_since: cli.changed_since.as_deref(),
production: cli.production,
workspace: cli.workspace.as_deref(),
explain: cli.explain,
performance: cli.performance,
group_by: cli.group_by,
}),
Command::Schema => unreachable!("handled above"),
Command::Migrate {
toml,
dry_run,
from,
} => migrate::run_migrate(root, toml, dry_run, from.as_deref()),
Command::License { subcommand } => license::run(&map_license_subcommand(subcommand)),
Command::Coverage { subcommand } => {
coverage::run(map_coverage_subcommand(&subcommand), root)
}
}
}
fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
match sub {
LicenseCli::Activate {
jwt,
from_file,
stdin,
trial,
email,
} => license::LicenseSubcommand::Activate(license::ActivateArgs {
raw_jwt: jwt,
from_file,
from_stdin: stdin,
trial,
email,
}),
LicenseCli::Status => license::LicenseSubcommand::Status,
LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
}
}
fn map_coverage_subcommand(sub: &CoverageCli) -> coverage::CoverageSubcommand {
match sub {
CoverageCli::Setup {
yes,
non_interactive,
} => coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
yes: *yes,
non_interactive: *non_interactive,
}),
}
}
#[expect(
clippy::too_many_arguments,
reason = "CLI dispatch forwards many flags"
)]
fn dispatch_health(
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
threads: usize,
max_cyclomatic: Option<u16>,
max_cognitive: Option<u16>,
top: Option<usize>,
sort: health::SortBy,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
ownership: bool,
ownership_emails: Option<fallow_config::EmailMode>,
targets: bool,
effort: Option<EffortFilter>,
score: bool,
min_score: Option<f64>,
min_severity: Option<health_types::FindingSeverity>,
since: Option<&str>,
min_commits: Option<u32>,
save_snapshot: Option<&Option<String>>,
trend: bool,
coverage: Option<&std::path::Path>,
coverage_root: Option<&std::path::Path>,
production_coverage: Option<&std::path::Path>,
min_invocations_hot: u64,
min_observation_volume: Option<u32>,
low_traffic_threshold: Option<f64>,
) -> ExitCode {
let (output, quiet, _fail_on_issues) = apply_ci_defaults(
cli.ci,
cli.fail_on_issues,
output,
quiet,
cli_format_was_explicit,
);
let targets = targets || effort.is_some();
let badge_format = matches!(output, fallow_config::OutputFormat::Badge);
let score = score || min_score.is_some() || trend || badge_format;
let snapshot_requested = save_snapshot.is_some();
let any_section = complexity || file_scores || coverage_gaps || hotspots || targets || score;
let eff_score = if any_section { score } else { true } || snapshot_requested;
let force_full = snapshot_requested || eff_score;
let score_only_output =
score && !complexity && !file_scores && !coverage_gaps && !hotspots && !targets && !trend;
let eff_file_scores = if any_section { file_scores } else { true } || force_full;
let eff_coverage_gaps = if any_section { coverage_gaps } else { false };
let eff_hotspots = if any_section { hotspots } else { true } || force_full;
let eff_complexity = if any_section { complexity } else { true };
let eff_targets = if any_section { targets } else { true };
let production_coverage = if let Some(path) = production_coverage {
match health::coverage::prepare_options(
path,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
output,
) {
Ok(options) => Some(options),
Err(code) => return code,
}
} else {
None
};
health::run_health(&HealthOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
max_cyclomatic,
max_cognitive,
top,
sort,
production: cli.production,
changed_since: cli.changed_since.as_deref(),
workspace: cli.workspace.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
complexity: eff_complexity,
file_scores: eff_file_scores,
coverage_gaps: eff_coverage_gaps,
config_activates_coverage_gaps: !any_section,
hotspots: eff_hotspots,
ownership: ownership && eff_hotspots,
ownership_emails,
targets: eff_targets,
force_full,
score_only_output,
enforce_coverage_gap_gate: true,
effort: effort.map(EffortFilter::to_estimate),
score: eff_score,
min_score,
min_severity,
since,
min_commits,
explain: cli.explain,
summary: cli.summary,
save_snapshot: save_snapshot.map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
trend,
group_by: cli.group_by,
coverage,
coverage_root,
performance: cli.performance,
production_coverage,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_definition_has_no_flag_collisions() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn emit_error_returns_given_exit_code() {
let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
assert_eq!(code, ExitCode::from(2));
}
#[test]
fn format_parsing_covers_all_variants() {
let parse = |s: &str| -> Option<Format> {
match s.to_lowercase().as_str() {
"json" => Some(Format::Json),
"human" => Some(Format::Human),
"sarif" => Some(Format::Sarif),
"compact" => Some(Format::Compact),
"markdown" | "md" => Some(Format::Markdown),
"codeclimate" => Some(Format::CodeClimate),
"badge" => Some(Format::Badge),
_ => None,
}
};
assert!(matches!(parse("json"), Some(Format::Json)));
assert!(matches!(parse("JSON"), Some(Format::Json)));
assert!(matches!(parse("human"), Some(Format::Human)));
assert!(matches!(parse("sarif"), Some(Format::Sarif)));
assert!(matches!(parse("compact"), Some(Format::Compact)));
assert!(matches!(parse("markdown"), Some(Format::Markdown)));
assert!(matches!(parse("md"), Some(Format::Markdown)));
assert!(matches!(parse("codeclimate"), Some(Format::CodeClimate)));
assert!(matches!(parse("badge"), Some(Format::Badge)));
assert!(parse("xml").is_none());
assert!(parse("").is_none());
}
#[test]
fn quiet_parsing_logic() {
let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
assert!(parse("1"));
assert!(parse("true"));
assert!(parse("TRUE"));
assert!(parse("True"));
assert!(!parse("0"));
assert!(!parse("false"));
assert!(!parse("yes"));
}
}