#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI binary produces intentional terminal output"
)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests use unwrap and expect to keep fixture setup concise"
)
)]
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{CommandFactory, Parser, Subcommand};
mod api;
mod audit;
mod base_worktree;
mod baseline;
mod cache_notice;
mod check;
mod ci;
mod ci_template;
mod codeowners;
mod combined;
mod config;
mod coverage;
mod dupes;
mod error;
mod explain;
mod fix;
mod flags;
mod health;
mod health_types;
mod impact;
mod init;
mod license;
mod list;
mod migrate;
mod output_dupes;
mod output_envelope;
mod path_util;
mod rayon_pool;
mod regression;
mod report;
mod runtime_support;
mod schema;
mod security;
mod setup_hooks;
mod signal;
mod task_matrix;
mod telemetry;
mod update_check;
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;
pub use runtime_support::{AnalysisKind, GroupBy};
pub(crate) use runtime_support::{build_ownership_resolver, load_config, load_config_for_analysis};
const SECURITY_UNSUPPORTED_GLOBAL_LONGS: &[&str] = &[
"baseline",
"save-baseline",
"production",
"no-production",
"group-by",
"performance",
"explain-skipped",
"fail-on-regression",
"regression-baseline",
"save-regression-baseline",
"dupes-mode",
"dupes-threshold",
"dupes-min-tokens",
"dupes-min-lines",
"dupes-min-occurrences",
"dupes-skip-local",
"dupes-cross-language",
"dupes-ignore-imports",
"include-entry-exports",
];
const TOP_LEVEL_HELP_TEMPLATE: &str =
"{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
const TOP_LEVEL_AFTER_HELP: &str = "\
Analysis:
dead-code Analyze unused code, dependency hygiene, and architecture cycles
dupes Find copy-paste and structural code duplication
health Analyze complexity, maintainability, hotspots, and coverage gaps
flags Detect feature flag usage patterns
security Surface local security candidates for agent verification (opt-in)
audit Review changed files for dead code, complexity, and duplication
Workflow:
watch Re-run analysis as files change
fix Auto-fix safe unused-code findings
Project inspection:
list List discovered files, entry points, plugins, boundaries, and workspaces
workspaces Show monorepo workspace discovery diagnostics
explain Explain one issue type without running analysis
impact Show what fallow has done for you (opt-in, local-only)
Setup and configuration:
init Create a fallow config, optionally with a Git hook
migrate Migrate knip or jscpd config to fallow
config Show the resolved config and loaded config file
config-schema Print the fallow config JSON Schema
plugin-schema Print the external plugin JSON Schema
rule-pack-schema Print the rule pack JSON Schema
Automation and CI:
ci Build PR/MR feedback envelopes
ci-template Print or vendor CI integration templates
hooks Install or remove fallow-managed Git and agent hooks
setup-hooks Legacy agent-hook installer
Runtime coverage:
coverage Set up or analyze runtime coverage data
license Manage the paid-feature license
telemetry Manage opt-in product telemetry
Reference:
schema Dump the CLI interface as machine-readable JSON
help Print this message or the help of a command
When no command is given, fallow runs dead-code + dupes + health together.
Use --only/--skip to select specific analyses.
When the agent is about to...
delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
commit or open a PR fallow audit --base <ref>
prioritize refactoring fallow health --hotspots --targets
ask who owns code fallow health --ownership
check untested-but-reachable code fallow health --coverage-gaps
consolidate duplication fallow dupes --trace dup:<fingerprint>
find feature flags fallow flags
surface security candidates fallow security
understand a finding fallow explain <issue-type>
scope a monorepo --workspace <glob> / --changed-workspaces <ref>";
#[derive(Parser)]
#[command(
name = "fallow",
about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
version,
disable_version_flag = true,
help_template = TOP_LEVEL_HELP_TEMPLATE,
after_help = TOP_LEVEL_AFTER_HELP
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(
short = 'v',
visible_short_alias = 'V',
long = "version",
action = clap::ArgAction::Version
)]
version: Option<bool>,
#[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 = "diff-file", value_name = "PATH", global = true)]
diff_file: Option<PathBuf>,
#[arg(long = "diff-stdin", global = true)]
diff_stdin: bool,
#[arg(long = "churn-file", value_name = "PATH", global = true)]
churn_file: Option<PathBuf>,
#[arg(long = "max-file-size", value_name = "MB", global = true)]
max_file_size: Option<u32>,
#[arg(long, global = true)]
baseline: Option<PathBuf>,
#[arg(long, global = true, value_name = "RUN_ID", hide = true)]
parent_run: Option<String>,
#[arg(long, global = true)]
save_baseline: Option<PathBuf>,
#[arg(long, global = true)]
production: bool,
#[arg(long = "no-production", global = true, conflicts_with = "production")]
no_production: bool,
#[arg(long = "production-dead-code")]
production_dead_code: bool,
#[arg(long = "production-health")]
production_health: bool,
#[arg(long = "production-dupes")]
production_dupes: bool,
#[arg(short, long, global = true, value_delimiter = ',')]
workspace: Option<Vec<String>>,
#[arg(long, global = true, value_name = "REF")]
changed_workspaces: 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)]
legacy_envelope: bool,
#[arg(long, global = true)]
explain_skipped: 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(short = 'o', long, global = true, value_name = "PATH")]
output_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 = "dupes-mode", global = true)]
dupes_mode: Option<DupesMode>,
#[arg(long = "dupes-threshold", global = true)]
dupes_threshold: Option<f64>,
#[arg(long = "dupes-min-tokens", global = true)]
dupes_min_tokens: Option<usize>,
#[arg(long = "dupes-min-lines", global = true)]
dupes_min_lines: Option<usize>,
#[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
dupes_min_occurrences: Option<usize>,
#[arg(long = "dupes-skip-local", global = true)]
dupes_skip_local: bool,
#[arg(long = "dupes-cross-language", global = true)]
dupes_cross_language: bool,
#[arg(long = "dupes-ignore-imports", global = true)]
dupes_ignore_imports: bool,
#[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>>,
#[arg(long, value_name = "PATH")]
coverage: Option<PathBuf>,
#[arg(long = "coverage-root", value_name = "PATH")]
coverage_root: Option<PathBuf>,
#[arg(long, global = true)]
include_entry_exports: bool,
}
#[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)]
private_type_leaks: 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)]
re_export_cycles: bool,
#[arg(long)]
boundary_violations: bool,
#[arg(long)]
policy_violations: bool,
#[arg(long)]
stale_suppressions: bool,
#[arg(long)]
unused_catalog_entries: bool,
#[arg(long)]
empty_catalog_groups: bool,
#[arg(long)]
unresolved_catalog_references: bool,
#[arg(long)]
unused_dependency_overrides: bool,
#[arg(long)]
misconfigured_dependency_overrides: 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>,
},
Watch {
#[arg(long)]
no_clear: bool,
},
Fix {
#[arg(long)]
dry_run: bool,
#[arg(long, alias = "force")]
yes: bool,
#[arg(long)]
no_create_config: bool,
},
Init {
#[arg(long)]
toml: bool,
#[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
agents: bool,
#[arg(long)]
hooks: bool,
#[arg(long, requires = "hooks")]
branch: Option<String>,
#[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
decline: bool,
},
Hooks {
#[command(subcommand)]
subcommand: HooksCli,
},
Ci {
#[command(subcommand)]
subcommand: CiCli,
},
ConfigSchema,
PluginSchema,
RulePackSchema,
Config {
#[arg(long)]
path: bool,
},
List {
#[arg(long)]
entry_points: bool,
#[arg(long)]
files: bool,
#[arg(long)]
plugins: bool,
#[arg(long)]
boundaries: bool,
#[arg(long)]
workspaces: bool,
},
Workspaces,
Dupes {
#[arg(long)]
mode: Option<DupesMode>,
#[arg(long)]
min_tokens: Option<usize>,
#[arg(long)]
min_lines: Option<usize>,
#[arg(long, value_parser = parse_min_occurrences)]
min_occurrences: Option<usize>,
#[arg(long)]
threshold: Option<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)]
max_crap: Option<f64>,
#[arg(long)]
top: Option<usize>,
#[arg(long, default_value = "cyclomatic")]
sort: SortBy,
#[arg(long)]
complexity: bool,
#[arg(long)]
complexity_breakdown: 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)]
report_only: bool,
#[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")]
runtime_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>,
},
Explain {
#[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
issue_type: Vec<String>,
},
Audit {
#[arg(long = "production-dead-code")]
production_dead_code: bool,
#[arg(long = "production-health")]
production_health: bool,
#[arg(long = "production-dupes")]
production_dupes: bool,
#[arg(long)]
dead_code_baseline: Option<PathBuf>,
#[arg(long)]
health_baseline: Option<PathBuf>,
#[arg(long)]
dupes_baseline: Option<PathBuf>,
#[arg(long)]
max_crap: Option<f64>,
#[arg(long, value_name = "PATH")]
coverage: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
coverage_root: Option<PathBuf>,
#[arg(long, value_enum)]
gate: Option<AuditGateArg>,
#[arg(long, value_name = "PATH")]
runtime_coverage: Option<PathBuf>,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "MARKER", hide = true)]
gate_marker: Option<String>,
},
Impact {
#[command(subcommand)]
subcommand: Option<ImpactCli>,
},
Security {
#[arg(long, value_name = "PATH")]
runtime_coverage: Option<PathBuf>,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "PATH")]
file: Vec<std::path::PathBuf>,
#[arg(long, value_name = "MODE")]
gate: Option<security::SecurityGateMode>,
#[arg(long)]
surface: bool,
},
Schema,
CiTemplate {
#[command(subcommand)]
subcommand: CiTemplateCli,
},
Migrate {
#[arg(long, conflicts_with = "jsonc")]
toml: bool,
#[arg(long)]
jsonc: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "PATH")]
from: Option<PathBuf>,
},
License {
#[command(subcommand)]
subcommand: LicenseCli,
},
Telemetry {
#[command(subcommand)]
subcommand: TelemetryCli,
},
Coverage {
#[command(subcommand)]
subcommand: CoverageCli,
},
SetupHooks {
#[arg(long, value_enum)]
agent: Option<setup_hooks::HookAgentArg>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
user: bool,
#[arg(long)]
gitignore_claude: bool,
#[arg(long)]
uninstall: bool,
},
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum HooksTargetArg {
Git,
Agent,
}
#[derive(clap::Subcommand)]
enum HooksCli {
Status,
Install {
#[arg(long, value_enum)]
target: HooksTargetArg,
#[arg(long)]
branch: Option<String>,
#[arg(long, value_enum)]
agent: Option<setup_hooks::HookAgentArg>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
user: bool,
#[arg(long)]
gitignore_claude: bool,
},
Uninstall {
#[arg(long, value_enum)]
target: HooksTargetArg,
#[arg(long, value_enum)]
agent: Option<setup_hooks::HookAgentArg>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
user: bool,
},
}
#[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(Clone, Copy, clap::Subcommand)]
enum TelemetryCli {
Status,
Enable,
Disable,
Inspect {
#[arg(long)]
example: bool,
},
}
#[derive(Clone, Copy, clap::Subcommand)]
enum ImpactCli {
Enable,
Disable,
Status,
}
#[derive(clap::Subcommand)]
enum CiTemplateCli {
Gitlab {
#[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
vendor: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(clap::Subcommand)]
enum CoverageCli {
Setup {
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
non_interactive: bool,
#[arg(long)]
json: bool,
},
Analyze {
#[arg(long, value_name = "PATH", conflicts_with = "cloud")]
runtime_coverage: Option<PathBuf>,
#[arg(long, visible_alias = "runtime-coverage-cloud")]
cloud: bool,
#[arg(long, value_name = "KEY")]
api_key: Option<String>,
#[arg(long, value_name = "URL")]
api_endpoint: Option<String>,
#[arg(long, value_name = "OWNER/REPO")]
repo: Option<String>,
#[arg(long, value_name = "ID")]
project_id: Option<String>,
#[arg(long, value_name = "DAYS", default_value_t = 30)]
coverage_period: u16,
#[arg(long, value_name = "ENV")]
environment: Option<String>,
#[arg(long, value_name = "SHA")]
commit_sha: Option<String>,
#[arg(long)]
production: bool,
#[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>,
#[arg(long)]
top: Option<usize>,
#[arg(long)]
blast_radius: bool,
#[arg(long)]
importance: bool,
},
UploadInventory {
#[arg(long, value_name = "KEY")]
api_key: Option<String>,
#[arg(long, value_name = "URL")]
api_endpoint: Option<String>,
#[arg(long, value_name = "PROJECT_ID")]
project_id: Option<String>,
#[arg(long, value_name = "SHA")]
git_sha: Option<String>,
#[arg(long)]
allow_dirty: bool,
#[arg(long, value_name = "GLOB", num_args = 0..)]
exclude_paths: Vec<String>,
#[arg(long, value_name = "PREFIX")]
path_prefix: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
ignore_upload_errors: bool,
},
UploadSourceMaps {
#[arg(long, value_name = "PATH", default_value = "dist")]
dir: PathBuf,
#[arg(long, value_name = "GLOB", default_value = "**/*.map")]
include: String,
#[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
exclude: Vec<String>,
#[arg(long, value_name = "NAME")]
repo: Option<String>,
#[arg(long, value_name = "SHA")]
git_sha: Option<String>,
#[arg(long, value_name = "URL")]
endpoint: Option<String>,
#[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
strip_path: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "N", default_value_t = 4)]
concurrency: usize,
#[arg(long)]
fail_fast: bool,
},
UploadStaticFindings {
#[arg(long, value_name = "KEY")]
api_key: Option<String>,
#[arg(long, value_name = "URL")]
api_endpoint: Option<String>,
#[arg(long, value_name = "PROJECT_ID")]
project_id: Option<String>,
#[arg(long, value_name = "SHA")]
git_sha: Option<String>,
#[arg(long)]
allow_dirty: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
ignore_upload_errors: bool,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum Format {
Human,
Json,
Sarif,
Compact,
Markdown,
#[value(
name = "codeclimate",
alias = "gitlab-codequality",
alias = "gitlab-code-quality"
)]
CodeClimate,
#[value(name = "pr-comment-github")]
PrCommentGithub,
#[value(name = "pr-comment-gitlab")]
PrCommentGitlab,
#[value(name = "review-github")]
ReviewGithub,
#[value(name = "review-gitlab")]
ReviewGitlab,
Badge,
}
#[derive(Subcommand)]
enum CiCli {
ReconcileReview {
#[arg(long, value_enum)]
provider: CiProviderArg,
#[arg(long)]
pr: Option<String>,
#[arg(long)]
mr: Option<String>,
#[arg(long)]
envelope: PathBuf,
#[arg(long)]
repo: Option<String>,
#[arg(long = "project-id")]
project_id: Option<String>,
#[arg(long = "api-url")]
api_url: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum CiProviderArg {
Github,
Gitlab,
}
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::PrCommentGithub => Self::PrCommentGithub,
Format::PrCommentGitlab => Self::PrCommentGitlab,
Format::ReviewGithub => Self::ReviewGithub,
Format::ReviewGitlab => Self::ReviewGitlab,
Format::Badge => Self::Badge,
}
}
}
#[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,
Anonymized,
#[value(hide = true)]
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::Anonymized => fallow_config::EmailMode::Anonymized,
Self::Hash => fallow_config::EmailMode::Hash,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum AuditGateArg {
NewOnly,
All,
}
impl From<AuditGateArg> for fallow_config::AuditGate {
fn from(value: AuditGateArg) -> Self {
match value {
AuditGateArg::NewOnly => Self::NewOnly,
AuditGateArg::All => Self::All,
}
}
}
fn parse_min_occurrences(s: &str) -> Result<usize, String> {
let value: usize = s
.parse()
.map_err(|_| format!("`{s}` is not a non-negative integer"))?;
if value < 2 {
return Err(format!(
"must be at least 2 (got {value}); a single occurrence isn't a duplicate"
));
}
Ok(value)
}
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" | "gitlab-codequality" | "gitlab-code-quality" => Some(Format::CodeClimate),
"pr-comment-github" => Some(Format::PrCommentGithub),
"pr-comment-gitlab" => Some(Format::PrCommentGitlab),
"review-github" => Some(Format::ReviewGithub),
"review-gitlab" => Some(Format::ReviewGitlab),
"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 bool_from_env(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn resolve_audit_baseline_path(
root: &std::path::Path,
cli: Option<&std::path::Path>,
config: Option<&str>,
) -> Option<PathBuf> {
let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
config.map(|p| {
let path = PathBuf::from(p);
if path_util::is_absolute_path_any_platform(&path) {
path
} else {
root.join(path)
}
})
})?;
if path_util::is_absolute_path_any_platform(&path) {
Some(path)
} else {
Some(root.join(path))
}
}
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 build_tracing_filter(rust_log: Option<&str>) -> tracing_subscriber::EnvFilter {
use tracing_subscriber::filter::LevelFilter;
let builder = tracing_subscriber::EnvFilter::builder();
match rust_log.map(str::trim) {
Some("") => builder
.with_default_directive(LevelFilter::OFF.into())
.parse_lossy("off"),
Some(value) => builder
.with_default_directive(LevelFilter::OFF.into())
.parse_lossy(value),
None => builder
.with_default_directive(LevelFilter::WARN.into())
.parse_lossy(""),
}
}
fn setup_tracing() {
let rust_log = std::env::var("RUST_LOG").ok();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(build_tracing_filter(rust_log.as_deref()))
.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 matches!(&cli.command, Some(Command::Security { .. }))
&& let Some(flag) = unsupported_security_global(cli)
{
return Err(emit_known_failure(
&format!("{flag} is not valid with `fallow security`."),
2,
output,
telemetry::FailureReason::Validation,
));
}
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_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref ws_patterns) = cli.workspace {
for ws in ws_patterns {
if let Err(e) = validate::validate_no_control_chars(ws, "--workspace") {
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
}
}
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-since")
{
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-workspaces")
{
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
if cli.workspace.is_some() && cli.changed_workspaces.is_some() {
return Err(emit_known_failure(
"--workspace and --changed-workspaces are mutually exclusive. \
Pick one: --workspace for explicit package names/globs, \
--changed-workspaces for git-derived monorepo CI scoping.",
2,
output,
telemetry::FailureReason::Validation,
));
}
let raw_root = if let Some(root) = cli.root.clone() {
root
} else {
std::env::current_dir().map_err(|err| {
emit_known_failure(
&format!("Failed to get current directory: {err}"),
2,
output,
telemetry::FailureReason::Config,
)
})?
};
let root = match validate::validate_root(&raw_root) {
Ok(r) => r,
Err(e) => {
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Config,
));
}
};
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_known_failure(
&format!("invalid --changed-since: {e}"),
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_known_failure(
&format!("invalid --changed-workspaces: {e}"),
2,
output,
telemetry::FailureReason::Validation,
));
}
let threads = cli
.threads
.unwrap_or_else(|| std::thread::available_parallelism().map_or(4, std::num::NonZero::get));
rayon_pool::configure_global_pool(threads);
Ok((root, threads))
}
fn emit_known_failure(
message: &str,
exit_code: u8,
output: fallow_config::OutputFormat,
reason: telemetry::FailureReason,
) -> ExitCode {
telemetry::note_failure_reason(reason);
emit_error(message, exit_code, output)
}
fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
if cli.baseline.is_some() {
Some("--baseline")
} else if cli.save_baseline.is_some() {
Some("--save-baseline")
} else if cli.production {
Some("--production")
} else if cli.no_production {
Some("--no-production")
} else if cli.group_by.is_some() {
Some("--group-by")
} else if cli.performance {
Some("--performance")
} else if cli.explain_skipped {
Some("--explain-skipped")
} else if cli.fail_on_regression {
Some("--fail-on-regression")
} else if cli.regression_baseline.is_some() {
Some("--regression-baseline")
} else if cli.save_regression_baseline.is_some() {
Some("--save-regression-baseline")
} else if cli.dupes_mode.is_some() {
Some("--dupes-mode")
} else if cli.dupes_threshold.is_some() {
Some("--dupes-threshold")
} else if cli.dupes_min_tokens.is_some() {
Some("--dupes-min-tokens")
} else if cli.dupes_min_lines.is_some() {
Some("--dupes-min-lines")
} else if cli.dupes_min_occurrences.is_some() {
Some("--dupes-min-occurrences")
} else if cli.dupes_skip_local {
Some("--dupes-skip-local")
} else if cli.dupes_cross_language {
Some("--dupes-cross-language")
} else if cli.dupes_ignore_imports {
Some("--dupes-ignore-imports")
} else if cli.include_entry_exports {
Some("--include-entry-exports")
} else {
None
}
}
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)
}
}
struct DispatchContext<'a> {
cli: &'a Cli,
root: &'a std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
threads: usize,
tolerance: regression::Tolerance,
save_regression_file: Option<&'a std::path::PathBuf>,
save_to_config: bool,
}
impl DispatchContext<'_> {
fn ci_defaults(&self) -> (fallow_config::OutputFormat, bool, bool) {
apply_ci_defaults(
self.cli.ci,
self.cli.fail_on_issues,
self.output,
self.quiet,
self.cli_format_was_explicit,
)
}
fn production_modes(
&self,
dead_code: bool,
health: bool,
dupes: bool,
) -> Result<ProductionModes, ExitCode> {
resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
}
fn production_for(
&self,
analysis: fallow_config::ProductionAnalysis,
) -> Result<bool, ExitCode> {
self.production_modes(false, false, false)
.map(|modes| modes.for_analysis(analysis))
}
fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
regression::RegressionOpts {
fail_on_regression: self.cli.fail_on_regression,
tolerance: self.tolerance,
regression_baseline_file: self.cli.regression_baseline.as_deref(),
save_target: if let Some(path) = self.save_regression_file {
regression::SaveRegressionTarget::File(path)
} else if self.save_to_config {
regression::SaveRegressionTarget::Config
} else {
regression::SaveRegressionTarget::None
},
scoped,
quiet: self.quiet,
output: self.output,
}
}
}
#[derive(Clone, Copy)]
struct ProductionModes {
dead_code: bool,
health: bool,
dupes: bool,
}
impl ProductionModes {
const fn for_analysis(self, analysis: fallow_config::ProductionAnalysis) -> bool {
match analysis {
fallow_config::ProductionAnalysis::DeadCode => self.dead_code,
fallow_config::ProductionAnalysis::Health => self.health,
fallow_config::ProductionAnalysis::Dupes => self.dupes,
}
}
}
fn load_config_production(
root: &std::path::Path,
config_path: Option<&PathBuf>,
output: fallow_config::OutputFormat,
) -> Result<fallow_config::ProductionConfig, ExitCode> {
let loaded = if let Some(path) = config_path {
fallow_config::FallowConfig::load(path)
.map(Some)
.map_err(|e| {
emit_error(
&format!("failed to load config '{}': {e}", path.display()),
2,
output,
)
})?
} else {
fallow_config::FallowConfig::find_and_load(root)
.map(|found| found.map(|(config, _)| config))
.map_err(|e| emit_error(&e, 2, output))?
};
Ok(match loaded {
Some(config) => config.production,
None => fallow_config::ProductionConfig::default(),
})
}
fn resolve_production_modes(
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
production_dead_code: bool,
production_health: bool,
production_dupes: bool,
) -> Result<ProductionModes, ExitCode> {
let config = load_config_production(root, cli.config.as_ref(), output)?;
let env_global = bool_from_env("FALLOW_PRODUCTION");
let resolve_one =
|analysis: fallow_config::ProductionAnalysis, cli_specific: bool, env_name: &str| {
if cli.production || cli_specific {
true
} else if cli.no_production {
false
} else if let Some(value) = bool_from_env(env_name) {
value
} else if let Some(value) = env_global {
value
} else {
config.for_analysis(analysis)
}
};
Ok(ProductionModes {
dead_code: resolve_one(
fallow_config::ProductionAnalysis::DeadCode,
production_dead_code,
"FALLOW_PRODUCTION_DEAD_CODE",
),
health: resolve_one(
fallow_config::ProductionAnalysis::Health,
production_health,
"FALLOW_PRODUCTION_HEALTH",
),
dupes: resolve_one(
fallow_config::ProductionAnalysis::Dupes,
production_dupes,
"FALLOW_PRODUCTION_DUPES",
),
})
}
#[cfg(unix)]
fn signal_test_helper() -> ExitCode {
use std::io::Write as _;
use std::process::Command;
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
signal::set_graceful_mode();
}
let mut command = Command::new("sleep");
command.arg("30");
let child = match signal::ScopedChild::spawn(&mut command) {
Ok(c) => c,
Err(err) => {
let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
return ExitCode::from(2);
}
};
let pid = child.id();
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{pid}");
let _ = lock.flush();
drop(lock);
let _ = child.wait_with_output();
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
return ExitCode::SUCCESS;
}
std::thread::sleep(std::time::Duration::from_secs(5));
ExitCode::SUCCESS
}
#[cfg(not(unix))]
fn signal_test_helper() -> ExitCode {
ExitCode::from(2)
}
fn install_spawn_hooks() {
fallow_core::churn::set_spawn_hook(signal::scoped_child::output);
fallow_core::changed_files::set_spawn_hook(signal::scoped_child::output);
}
fn install_signal_handlers() {
if let Err(err) = signal::install_handlers() {
use std::io::Write as _;
let stderr = std::io::stderr();
let mut lock = stderr.lock();
let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
}
}
fn args_use_legacy_check_alias<I>(args: I) -> bool
where
I: IntoIterator<Item = String>,
{
let value_options = [
"-r",
"--root",
"-c",
"--config",
"-f",
"--format",
"--output",
"--threads",
"--changed-since",
"--base",
"--diff-file",
"--baseline",
"--parent-run",
"--save-baseline",
"-w",
"--workspace",
"--changed-workspaces",
"--group-by",
"--file",
"--sarif-file",
"--only",
"--skip",
"--dupes-mode",
"--dupes-threshold",
"--dupes-min-tokens",
"--dupes-min-lines",
"--dupes-min-occurrences",
"--dupes-skip-local",
"--dupes-cross-language",
"--dupes-ignore-imports",
"--save-snapshot",
"--regression-baseline",
"--tolerance",
"--save-regression-baseline",
];
let mut skip_next = false;
for arg in args.into_iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--" {
break;
}
if arg.starts_with('-') {
let option_name = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name);
if !arg.contains('=') && value_options.contains(&option_name) {
skip_next = true;
}
continue;
}
return arg == "check";
}
false
}
fn raw_args_use_legacy_check_alias() -> bool {
args_use_legacy_check_alias(std::env::args())
}
fn warn_legacy_check_alias_if_needed(used_legacy_check_alias: bool, quiet: bool) {
if used_legacy_check_alias && !quiet {
eprintln!("fallow: `check` is deprecated; use `dead-code` instead.");
}
}
fn redirect_report_to_file(
path: &std::path::Path,
output: fallow_config::OutputFormat,
) -> Result<(), ExitCode> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Err(emit_error(
&format!(
"failed to create {} for --output-file: {e}",
parent.display()
),
2,
output,
));
}
match std::fs::File::create(path) {
Ok(file) => {
report::sink::set_file_sink(file);
colored::control::set_override(false);
Ok(())
}
Err(e) => Err(emit_error(
&format!("failed to open {} for --output-file: {e}", path.display()),
2,
output,
)),
}
}
fn finalize_report_file(
path: &std::path::Path,
quiet: bool,
output: fallow_config::OutputFormat,
) -> Result<(), ExitCode> {
if let Err(e) = report::sink::flush() {
return Err(emit_error(
&format!("failed to write {}: {e}", path.display()),
2,
output,
));
}
if !quiet && report::sink::wrote() {
eprintln!("Report written to {}", path.display());
}
Ok(())
}
fn main() -> ExitCode {
install_signal_handlers();
install_spawn_hooks();
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
return signal_test_helper();
}
let used_legacy_check_alias = raw_args_use_legacy_check_alias();
let mut cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => return handle_cli_parse_error(&err),
};
warn_legacy_check_alias_if_needed(used_legacy_check_alias, cli.quiet);
output_envelope::set_legacy_envelope(cli.legacy_envelope);
runtime_support::set_max_file_size_override(cli.max_file_size);
if let Some(workspaces) = cli.workspace.as_ref()
&& !workspaces.is_empty()
{
report::ci::pr_comment::set_workspace_marker_from_list(workspaces);
}
if let Some(code) = run_schema_command_if_requested(&cli) {
return code;
}
let fmt = resolve_format(&cli);
if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output) {
return code;
}
let telemetry_run = start_telemetry_run(&cli, &fmt);
let (root, threads) = match validate_inputs(&cli, fmt.output) {
Ok(v) => v,
Err(code) => {
return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
}
};
let FormatConfig {
output,
quiet,
cli_format_was_explicit,
} = fmt;
if let Err(code) = init_cli_diff_filter(&cli, &root, output, quiet) {
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Diff),
cli.parent_run.as_deref(),
);
}
if (cli.ci || cli.fail_on_issues || cli.sarif_file.is_some() || cli.output_file.is_some())
&& command_rejects_output_gate(cli.command.as_ref())
{
let code = emit_known_failure(
"--ci, --fail-on-issues, --sarif-file, and --output-file are only valid with dead-code, dupes, health, security, or bare invocation",
2,
output,
telemetry::FailureReason::Validation,
);
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Validation),
cli.parent_run.as_deref(),
);
}
if let Some(message) = global_filter_error(&cli) {
let code = emit_known_failure(message, 2, output, telemetry::FailureReason::Validation);
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Validation),
cli.parent_run.as_deref(),
);
}
let tolerance = match parse_cli_tolerance(&cli, output) {
Ok(tolerance) => tolerance,
Err(code) => {
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Validation),
cli.parent_run.as_deref(),
);
}
};
let (save_regression_file, save_to_config) = regression_save_targets(&cli);
if let Some(path) = cli.output_file.as_deref()
&& let Err(code) = redirect_report_to_file(path, output)
{
return code;
}
let command = cli.command.take();
let dispatch = DispatchContext {
cli: &cli,
root: &root,
output,
quiet,
cli_format_was_explicit,
threads,
tolerance,
save_regression_file: save_regression_file.as_ref(),
save_to_config,
};
let exit_code = if command.is_some() && cli_has_bare_coverage_input(&cli) {
emit_error(bare_coverage_subcommand_error_message(), 2, output)
} else {
match command {
None => dispatch_bare_command(&dispatch),
Some(cmd) => dispatch_subcommand(cmd, &dispatch),
}
};
if let Some(path) = cli.output_file.as_deref()
&& let Err(code) = finalize_report_file(path, quiet, output)
{
return code;
}
record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
}
#[derive(Clone, Copy)]
struct TelemetryRun {
workflow: telemetry::Workflow,
output: fallow_config::OutputFormat,
quiet: bool,
start: std::time::Instant,
context: telemetry::WorkflowContext,
}
fn record_run_epilogue(
run: TelemetryRun,
exit_code: ExitCode,
failure_reason: Option<telemetry::FailureReason>,
parent_run: Option<&str>,
) -> ExitCode {
let cache_notice_printed = cache_notice::maybe_print_created_notice();
telemetry::record_workflow(&telemetry::WorkflowRecord {
workflow: run.workflow,
output: run.output,
quiet: run.quiet,
elapsed: run.start.elapsed(),
exit_code,
failure_reason,
parent_run,
context: run.context,
});
if exit_code == ExitCode::SUCCESS {
let note_printed = telemetry::maybe_print_opt_in_note(run.output, run.quiet);
update_check::maybe_nudge(run.output, run.quiet, note_printed || cache_notice_printed);
}
exit_code
}
fn start_telemetry_run(cli: &Cli, fmt: &FormatConfig) -> TelemetryRun {
setup_tracing();
let run = TelemetryRun {
workflow: telemetry_workflow_for_command(cli.command.as_ref(), fmt.output),
output: fmt.output,
quiet: fmt.quiet,
start: std::time::Instant::now(),
context: telemetry_context_for_command(cli, cli.command.as_ref(), fmt.output),
};
output_envelope::set_telemetry_analysis_run_id(
matches!(fmt.output, fallow_config::OutputFormat::Json)
.then(telemetry::new_analysis_run_id),
);
telemetry::flush_spool_in_background();
run
}
fn telemetry_context_for_command(
cli: &Cli,
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::WorkflowContext {
telemetry::WorkflowContext {
run_scope: telemetry_run_scope_for_command(cli, command),
config_shape: telemetry_config_shape_for_cli(cli),
output_destination: telemetry_output_destination_for_command(cli, command, output),
analysis_mode: telemetry_analysis_mode_for_command(command),
}
}
fn telemetry_run_scope_for_command(cli: &Cli, command: Option<&Command>) -> telemetry::RunScope {
if command_is_file_scoped(command) {
return telemetry::RunScope::FileScoped;
}
if cli
.workspace
.as_ref()
.is_some_and(|workspaces| !workspaces.is_empty())
|| cli.changed_workspaces.is_some()
{
return telemetry::RunScope::WorkspaceScoped;
}
if cli.changed_since.is_some()
|| cli.diff_file.is_some()
|| cli.diff_stdin
|| matches!(command, Some(Command::Audit { .. }))
{
return telemetry::RunScope::ChangedOnly;
}
if command_runs_full_project_analysis(command) {
return telemetry::RunScope::FullProject;
}
telemetry::RunScope::Unknown
}
fn command_is_file_scoped(command: Option<&Command>) -> bool {
matches!(
command,
Some(Command::Check { file, .. } | Command::Security { file, .. }) if !file.is_empty()
)
}
fn command_runs_full_project_analysis(command: Option<&Command>) -> bool {
matches!(
command,
None | Some(
Command::Check { .. }
| Command::Dupes { .. }
| Command::Health { .. }
| Command::Flags { .. }
| Command::Security { .. }
| Command::Fix { .. }
| Command::Watch { .. },
)
)
}
fn telemetry_config_shape_for_cli(cli: &Cli) -> telemetry::ConfigShape {
if cli.config.is_some() {
telemetry::ConfigShape::CustomConfig
} else {
telemetry::ConfigShape::Unknown
}
}
fn telemetry_output_destination_for_command(
cli: &Cli,
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::OutputDestination {
if matches!(command, Some(Command::Ci { .. }))
|| matches!(
output,
fallow_config::OutputFormat::PrCommentGithub
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::ReviewGithub
| fallow_config::OutputFormat::CodeClimate
)
{
return telemetry::OutputDestination::CiComment;
}
if cli.output_file.is_some() || cli.sarif_file.is_some() {
return telemetry::OutputDestination::File;
}
telemetry::OutputDestination::Stdout
}
fn telemetry_analysis_mode_for_command(command: Option<&Command>) -> telemetry::AnalysisMode {
match command {
Some(Command::Security { .. }) => telemetry::AnalysisMode::Security,
Some(Command::Fix { .. }) => telemetry::AnalysisMode::Fix,
Some(Command::Health {
runtime_coverage: Some(_),
..
})
| Some(Command::Audit {
runtime_coverage: Some(_),
..
})
| Some(Command::Coverage { .. }) => telemetry::AnalysisMode::ProductionCoverage,
Some(Command::Health {
coverage: Some(_), ..
})
| Some(Command::Audit {
coverage: Some(_), ..
}) => telemetry::AnalysisMode::RuntimeCoverage,
None
| Some(
Command::Check { .. }
| Command::Dupes { .. }
| Command::Health { .. }
| Command::Audit { .. }
| Command::Flags { .. }
| Command::Watch { .. },
) => telemetry::AnalysisMode::Static,
_ => telemetry::AnalysisMode::Unknown,
}
}
fn handle_cli_parse_error(err: &clap::Error) -> ExitCode {
if err.kind() == clap::error::ErrorKind::DisplayHelp
&& args_request_security_help(std::env::args_os().skip(1))
{
print!("{}", render_security_help());
return ExitCode::SUCCESS;
}
let exit_code = err.exit_code();
let _ = err.print();
ExitCode::from(u8::try_from(exit_code).unwrap_or(2))
}
fn args_request_security_help<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args: Vec<String> = args
.into_iter()
.map(|arg| arg.as_ref().to_string_lossy().into_owned())
.collect();
if args.first().is_some_and(|arg| arg == "help") {
return args.get(1).is_some_and(|arg| arg == "security");
}
let mut saw_security = false;
for arg in args {
if arg == "security" {
saw_security = true;
continue;
}
if saw_security && matches!(arg.as_str(), "--help" | "-h") {
return true;
}
}
false
}
fn render_security_help() -> String {
let mut root = Cli::command().mut_args(|arg| {
if arg.get_long().is_some_and(security_unsupported_global_long) {
arg.hide(true)
} else {
arg
}
});
match root.try_get_matches_from_mut(["fallow", "security", "--help"]) {
Ok(_) => String::new(),
Err(err) => err.to_string(),
}
}
fn security_unsupported_global_long(long: &str) -> bool {
SECURITY_UNSUPPORTED_GLOBAL_LONGS.contains(&long)
}
fn cli_has_bare_coverage_input(cli: &Cli) -> bool {
cli.coverage.is_some() || cli.coverage_root.is_some()
}
fn bare_coverage_subcommand_error_message() -> &'static str {
"`--coverage` and `--coverage-root` are bare combined-mode flags. Use `fallow health --coverage <coverage-final.json>` for standalone health analysis, or omit the subcommand to run combined mode."
}
fn run_telemetry_command_if_requested(
cli: &mut Cli,
output: fallow_config::OutputFormat,
) -> Option<ExitCode> {
if matches!(cli.command, Some(Command::Telemetry { .. }))
&& let Some(Command::Telemetry { subcommand }) = cli.command.take()
{
return Some(telemetry::run(map_telemetry_subcommand(subcommand), output));
}
None
}
fn run_schema_command_if_requested(cli: &Cli) -> Option<ExitCode> {
match cli.command {
Some(Command::Schema) => Some(schema::run_schema()),
Some(Command::ConfigSchema) => Some(init::run_config_schema()),
Some(Command::PluginSchema) => Some(init::run_plugin_schema()),
Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema()),
_ => None,
}
}
fn command_rejects_output_gate(command: Option<&Command>) -> bool {
matches!(
command,
Some(
Command::Init { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::RulePackSchema
| Command::Schema
| Command::Explain { .. }
| Command::CiTemplate { .. }
| Command::Config { .. }
| Command::Ci { .. }
| Command::List { .. }
| Command::Flags { .. }
| Command::Migrate { .. }
| Command::License { .. }
| Command::Coverage { .. }
| Command::Hooks { .. }
| Command::SetupHooks { .. }
)
)
}
fn global_filter_error(cli: &Cli) -> Option<&'static str> {
if (!cli.only.is_empty() || !cli.skip.is_empty()) && cli.command.is_some() {
return Some("--only and --skip can only be used without a subcommand");
}
if (cli.production_dead_code || cli.production_health || cli.production_dupes)
&& cli.command.is_some()
{
return Some(
"--production-dead-code, --production-health, and --production-dupes can only be used without a subcommand. For audit, pass them after `audit`",
);
}
if !cli.only.is_empty() && !cli.skip.is_empty() {
return Some("--only and --skip are mutually exclusive");
}
None
}
fn parse_cli_tolerance(
cli: &Cli,
output: fallow_config::OutputFormat,
) -> Result<regression::Tolerance, ExitCode> {
regression::Tolerance::parse(&cli.tolerance).map_err(|e| {
emit_known_failure(
&format!("invalid --tolerance: {e}"),
2,
output,
telemetry::FailureReason::Validation,
)
})
}
fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
opt.as_ref()
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from)
});
let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
(save_file, save_to_config)
}
fn init_cli_diff_filter(
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
) -> Result<(), ExitCode> {
let diff_source = report::ci::diff_filter::resolve_diff_source(
cli.diff_file.as_deref(),
cli.diff_stdin,
root,
)
.map_err(|msg| emit_known_failure(&msg, 2, output, telemetry::FailureReason::Diff))?;
if diff_source.is_some() && cli.changed_since.is_some() && !quiet {
eprintln!(
"fallow: --diff-file precedes --changed-since for line-level \
filtering; --changed-since still scopes file discovery. Drop \
one of them to disable this combination."
);
}
let suppress_warnings = quiet
&& matches!(
diff_source,
Some(report::ci::diff_filter::DiffSource::EnvVar(_)) | None
);
let _ = report::ci::diff_filter::init_shared_diff(diff_source.as_ref(), suppress_warnings);
Ok(())
}
fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
let production = match dispatch.production_modes(
cli.production_dead_code,
cli.production_health,
cli.production_dupes,
) {
Ok(production) => production,
Err(code) => return code,
};
let coverage_inputs = match resolve_health_coverage_inputs(
dispatch,
cli.coverage.as_deref(),
cli.coverage_root.as_deref(),
) {
Ok(inputs) => inputs,
Err(code) => return code,
};
combined::run_combined(&combined::CombinedOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
fail_on_issues,
sarif_file: cli.sarif_file.as_deref(),
changed_since: cli.changed_since.as_deref(),
churn_file: cli.churn_file.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
production: cli.production,
production_dead_code: Some(production.dead_code),
production_health: Some(production.health),
production_dupes: Some(production.dupes),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
group_by: cli.group_by,
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
summary: cli.summary,
run_check,
run_dupes,
run_health,
dupes_mode: cli.dupes_mode,
dupes_threshold: cli.dupes_threshold,
dupes_min_tokens: cli.dupes_min_tokens,
dupes_min_lines: cli.dupes_min_lines,
dupes_min_occurrences: cli.dupes_min_occurrences,
dupes_skip_local: cli.dupes_skip_local,
dupes_cross_language: cli.dupes_cross_language,
dupes_ignore_imports: cli.dupes_ignore_imports,
score: cli.score || cli.trend,
trend: cli.trend,
save_snapshot: cli.save_snapshot.as_ref(),
coverage: coverage_inputs.coverage.as_deref(),
coverage_root: coverage_inputs.coverage_root.as_deref(),
include_entry_exports: cli.include_entry_exports,
regression_opts: dispatch.regression_opts(
cli.changed_since.is_some()
|| cli.workspace.is_some()
|| cli.changed_workspaces.is_some(),
),
})
}
fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let quiet = dispatch.quiet;
match command {
Command::Check {
unused_files,
unused_exports,
unused_deps,
unused_types,
private_type_leaks,
unused_enum_members,
unused_class_members,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
policy_violations,
stale_suppressions,
unused_catalog_entries,
empty_catalog_groups,
unresolved_catalog_references,
unused_dependency_overrides,
misconfigured_dependency_overrides,
include_dupes,
trace,
trace_file,
trace_dependency,
top,
file,
} => dispatch_check(
dispatch,
&CheckDispatchArgs {
filters: IssueFilters {
unused_files,
unused_exports,
unused_deps,
unused_types,
private_type_leaks,
unused_enum_members,
unused_class_members,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
policy_violations,
stale_suppressions,
unused_catalog_entries,
empty_catalog_groups,
unresolved_catalog_references,
unused_dependency_overrides,
misconfigured_dependency_overrides,
},
trace_opts: TraceOptions {
trace_export: trace,
trace_file,
trace_dependency,
performance: cli.performance,
},
include_dupes,
top,
file,
},
),
Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
Command::Hooks { subcommand } => run_hooks_command(root, subcommand, output),
Command::Ci { subcommand } => ci::run(map_ci_subcommand(subcommand), output),
Command::ConfigSchema => init::run_config_schema(),
Command::PluginSchema => init::run_plugin_schema(),
Command::RulePackSchema => init::run_rule_pack_schema(),
Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
Command::Config { path } => config::run_config(root, cli.config.as_deref(), path, output),
list @ (Command::Workspaces | Command::List { .. }) => {
dispatch_list_command(&list, dispatch)
}
dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
Command::Flags { top } => dispatch_flags_command(dispatch, top),
Command::Explain { issue_type } => explain::run_explain(&issue_type.join(" "), output),
audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
Command::Impact { subcommand } => dispatch_impact(root, quiet, output, subcommand),
security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
Command::Schema => unreachable!("handled above"),
migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
Command::License { subcommand } => dispatch_license_command(subcommand, output),
Command::Telemetry { .. } => unreachable!("handled before root validation"),
Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
setup_hooks @ Command::SetupHooks { .. } => {
dispatch_setup_hooks_command(&setup_hooks, dispatch)
}
}
}
fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Security {
runtime_coverage,
min_invocations_hot,
file,
gate,
surface,
} = command
else {
unreachable!("security dispatcher only handles security commands");
};
let cli = dispatch.cli;
let root = dispatch.root;
let threads = dispatch.threads;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
security::run(&security::SecurityOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
fail_on_issues,
sarif_file: cli.sarif_file.as_deref(),
summary: cli.summary,
changed_since: cli.changed_since.as_deref(),
use_shared_diff_index: true,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
file: file.as_slice(),
surface,
gate,
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
explain: cli.explain,
})
}
fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Dupes {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
} = command
else {
unreachable!("dupes dispatcher only handles dupes commands");
};
dispatch_dupes(
dispatch,
&DupesDispatchArgs {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
},
)
}
fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
let Command::Init {
toml,
agents,
hooks,
branch,
decline,
} = command
else {
unreachable!("init dispatcher only handles init commands");
};
init::run_init(&init::InitOptions {
root,
use_toml: toml,
agents,
hooks,
branch: branch.as_deref(),
decline,
quiet,
})
}
fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Fix {
dry_run,
yes,
no_create_config,
} = command
else {
unreachable!("fix dispatcher only handles fix commands");
};
dispatch_fix(
dispatch,
FixDispatchArgs {
dry_run: *dry_run,
yes: *yes,
no_create_config: *no_create_config,
},
)
}
fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
match command {
Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
Command::List {
entry_points,
files,
plugins,
boundaries,
workspaces,
} => dispatch_list(
dispatch,
ListDispatchArgs {
entry_points: *entry_points,
files: *files,
plugins: *plugins,
boundaries: *boundaries,
workspaces: *workspaces,
},
),
_ => unreachable!("list dispatcher only handles list commands"),
}
}
fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
let Command::Migrate {
toml,
jsonc,
dry_run,
from,
} = command
else {
unreachable!("migrate dispatcher only handles migrate commands");
};
migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
}
fn dispatch_license_command(
subcommand: LicenseCli,
output: fallow_config::OutputFormat,
) -> ExitCode {
license::run(&map_license_subcommand(subcommand), output)
}
fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
match subcommand {
CiTemplateCli::Gitlab { vendor, force } => {
ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
vendor_dir: vendor,
force,
})
}
}
}
fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
let cli = dispatch.cli;
coverage::run(
map_coverage_subcommand(subcommand, cli.explain),
&coverage::RunContext {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
quiet: dispatch.quiet,
no_cache: cli.no_cache,
threads: dispatch.threads,
explain: cli.explain,
},
)
}
fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Health {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
complexity_breakdown,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails,
targets,
effort,
score,
min_score,
min_severity,
report_only,
since,
min_commits,
save_snapshot,
trend,
coverage,
coverage_root,
runtime_coverage,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
} = command
else {
unreachable!("health dispatcher only handles health commands");
};
let ownership = ownership || ownership_emails.is_some();
let hotspots = hotspots || ownership;
dispatch_health(
dispatch,
HealthDispatchArgs {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
complexity_breakdown,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails: ownership_emails.map(EmailModeArg::to_config),
targets,
effort,
score,
min_score,
min_severity,
report_only,
since: since.as_deref(),
min_commits,
save_snapshot: save_snapshot.as_ref(),
trend,
coverage: coverage.as_deref(),
coverage_root: coverage_root.as_deref(),
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
},
)
}
fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::SetupHooks {
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall,
} = command
else {
unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
};
setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
root: dispatch.root,
agent: *agent,
dry_run: *dry_run,
force: *force,
user: *user,
gitignore_claude: *gitignore_claude,
uninstall: *uninstall,
})
}
fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Audit {
production_dead_code,
production_health,
production_dupes,
dead_code_baseline,
health_baseline,
dupes_baseline,
max_crap,
coverage,
coverage_root,
gate,
runtime_coverage,
min_invocations_hot,
gate_marker,
} = command
else {
unreachable!("audit dispatcher only handles audit commands");
};
dispatch_audit(
dispatch,
&AuditDispatchArgs {
production_dead_code,
production_health,
production_dupes,
dead_code_baseline,
health_baseline,
dupes_baseline,
max_crap,
coverage,
coverage_root,
gate,
runtime_coverage,
min_invocations_hot,
gate_marker,
},
)
}
fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let quiet = dispatch.quiet;
let threads = dispatch.threads;
let production = match resolve_production_modes(cli, root, output, false, false, false) {
Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
Err(code) => return code,
};
flags::run_flags(&flags::FlagsOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
production,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
changed_since: cli.changed_since.as_deref(),
explain: cli.explain,
top,
})
}
fn dispatch_impact(
root: &std::path::Path,
quiet: bool,
output: fallow_config::OutputFormat,
subcommand: Option<ImpactCli>,
) -> ExitCode {
match subcommand {
Some(ImpactCli::Enable) => {
let newly = impact::enable(root);
if !quiet {
if newly {
println!(
"Fallow Impact enabled. Each `fallow audit` / pre-commit gate run is \
recorded locally in .fallow/impact.json (gitignored, never uploaded)."
);
println!(
"Tip: run `fallow init --hooks` (or add `--gate-marker pre-commit` to \
your existing hook's `fallow audit` line) so blocked-then-fixed \
commits are recorded as contained."
);
} else {
println!("Fallow Impact is already enabled.");
}
}
ExitCode::SUCCESS
}
Some(ImpactCli::Disable) => {
let was_enabled = impact::disable(root);
if !quiet {
println!(
"{}",
if was_enabled {
"Fallow Impact disabled. Existing history is retained."
} else {
"Fallow Impact was already disabled."
}
);
}
ExitCode::SUCCESS
}
Some(ImpactCli::Status) | None => render_impact_status(root, output),
}
}
fn render_impact_status(root: &std::path::Path, output: fallow_config::OutputFormat) -> ExitCode {
let store = impact::load(root);
let report = impact::build_report(&store);
let rendered = match output {
fallow_config::OutputFormat::Json => impact::render_json(&report),
fallow_config::OutputFormat::Markdown => impact::render_markdown(&report),
fallow_config::OutputFormat::Human => impact::render_human(&report),
fallow_config::OutputFormat::Sarif
| fallow_config::OutputFormat::Compact
| fallow_config::OutputFormat::CodeClimate
| fallow_config::OutputFormat::PrCommentGithub
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::ReviewGithub
| fallow_config::OutputFormat::ReviewGitlab
| fallow_config::OutputFormat::Badge => {
return emit_known_failure(
"impact supports human, json, and markdown output",
2,
output,
telemetry::FailureReason::UnsupportedFormat,
);
}
};
println!("{rendered}");
ExitCode::SUCCESS
}
fn telemetry_workflow_for_command(
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::Workflow {
match command {
None | Some(Command::Flags { .. } | Command::Watch { .. }) => {
telemetry::Workflow::CodeQualityReview
}
Some(Command::Check { .. }) => telemetry::Workflow::DeadCode,
Some(Command::Dupes { .. }) => telemetry::Workflow::Dupes,
Some(Command::Health { .. }) => telemetry::Workflow::Health,
Some(Command::Audit { .. }) => telemetry::Workflow::Audit,
Some(Command::Ci { .. }) => match output {
fallow_config::OutputFormat::ReviewGitlab
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::CodeClimate => telemetry::Workflow::GitlabCi,
_ => telemetry::Workflow::GithubAction,
},
Some(Command::Coverage { .. }) => telemetry::Workflow::RuntimeCoverageSetup,
Some(Command::Impact { .. }) => telemetry::Workflow::Impact,
Some(Command::Security { .. }) => telemetry::Workflow::Security,
Some(Command::Fix { .. }) => telemetry::Workflow::Fix,
Some(Command::Explain { .. }) => telemetry::Workflow::Explain,
Some(Command::List { .. } | Command::Workspaces | Command::Schema) => {
telemetry::Workflow::ProjectInventory
}
Some(Command::License { .. }) => telemetry::Workflow::License,
Some(
Command::Init { .. }
| Command::Hooks { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::RulePackSchema
| Command::Config { .. }
| Command::CiTemplate { .. }
| Command::Migrate { .. }
| Command::Telemetry { .. }
| Command::SetupHooks { .. },
) => telemetry::Workflow::Setup,
}
}
fn run_hooks_command(
root: &std::path::Path,
subcommand: HooksCli,
output: fallow_config::OutputFormat,
) -> ExitCode {
match subcommand {
HooksCli::Status => setup_hooks::run_hooks_status(root, output),
HooksCli::Install {
target: HooksTargetArg::Git,
branch,
agent,
dry_run,
force,
user,
gitignore_claude,
} => {
if agent.is_some() || user || gitignore_claude {
return emit_error(
"--agent, --user, and --gitignore-claude are only valid with `fallow hooks install --target agent`",
2,
output,
);
}
init::run_git_hooks_install(&init::GitHooksInstallOptions {
root,
branch: branch.as_deref(),
dry_run,
force,
})
}
HooksCli::Install {
target: HooksTargetArg::Agent,
branch,
agent,
dry_run,
force,
user,
gitignore_claude,
} => {
if branch.is_some() {
return emit_error(
"--branch is only valid with `fallow hooks install --target git`",
2,
output,
);
}
setup_hooks::run_setup_hooks_with_label(
&setup_hooks::SetupHooksOptions {
root,
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall: false,
},
"fallow hooks install --target agent",
)
}
HooksCli::Uninstall {
target: HooksTargetArg::Git,
agent,
dry_run,
force,
user,
} => {
if agent.is_some() || user {
return emit_error(
"--agent and --user are only valid with `fallow hooks uninstall --target agent`",
2,
output,
);
}
init::run_git_hooks_uninstall(&init::GitHooksUninstallOptions {
root,
dry_run,
force,
})
}
HooksCli::Uninstall {
target: HooksTargetArg::Agent,
agent,
dry_run,
force,
user,
} => setup_hooks::run_setup_hooks_with_label(
&setup_hooks::SetupHooksOptions {
root,
agent,
dry_run,
force,
user,
gitignore_claude: false,
uninstall: true,
},
"fallow hooks uninstall --target agent",
),
}
}
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_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
match sub {
TelemetryCli::Status => telemetry::TelemetryCommand::Status,
TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
}
}
fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
match sub {
CiCli::ReconcileReview {
provider,
pr,
mr,
envelope,
repo,
project_id,
api_url,
dry_run,
} => ci::CiCommand::ReconcileReview {
provider: match provider {
CiProviderArg::Github => ci::CiProvider::Github,
CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
},
target: pr.or(mr),
envelope,
repo,
project_id,
api_url,
dry_run,
},
}
}
fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
match sub {
CoverageCli::Setup {
yes,
non_interactive,
json,
} => coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
yes: *yes,
non_interactive: *non_interactive || *json,
json: *json,
explain,
}),
CoverageCli::Analyze {
runtime_coverage,
cloud,
api_key,
api_endpoint,
repo,
project_id,
coverage_period,
environment,
commit_sha,
production,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
top,
blast_radius,
importance,
} => coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
runtime_coverage: runtime_coverage.clone(),
cloud: *cloud,
api_key: api_key.clone(),
api_endpoint: api_endpoint.clone(),
repo: repo.clone(),
project_id: project_id.clone(),
coverage_period: *coverage_period,
environment: environment.clone(),
commit_sha: commit_sha.clone(),
production: *production,
min_invocations_hot: *min_invocations_hot,
min_observation_volume: *min_observation_volume,
low_traffic_threshold: *low_traffic_threshold,
top: *top,
blast_radius: *blast_radius,
importance: *importance,
}),
CoverageCli::UploadInventory {
api_key,
api_endpoint,
project_id,
git_sha,
allow_dirty,
exclude_paths,
path_prefix,
dry_run,
ignore_upload_errors,
} => coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
api_key: api_key.clone(),
api_endpoint: api_endpoint.clone(),
project_id: project_id.clone(),
git_sha: git_sha.clone(),
allow_dirty: *allow_dirty,
exclude_paths: exclude_paths.clone(),
path_prefix: path_prefix.clone(),
dry_run: *dry_run,
ignore_upload_errors: *ignore_upload_errors,
}),
CoverageCli::UploadSourceMaps {
dir,
include,
exclude,
repo,
git_sha,
endpoint,
strip_path,
dry_run,
concurrency,
fail_fast,
} => coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
dir: dir.clone(),
include: include.clone(),
exclude: exclude.clone(),
repo: repo.clone(),
git_sha: git_sha.clone(),
endpoint: endpoint.clone(),
strip_path: *strip_path,
dry_run: *dry_run,
concurrency: *concurrency,
fail_fast: *fail_fast,
}),
CoverageCli::UploadStaticFindings {
api_key,
api_endpoint,
project_id,
git_sha,
allow_dirty,
dry_run,
ignore_upload_errors,
} => {
coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
api_key: api_key.clone(),
api_endpoint: api_endpoint.clone(),
project_id: project_id.clone(),
git_sha: git_sha.clone(),
allow_dirty: *allow_dirty,
dry_run: *dry_run,
ignore_upload_errors: *ignore_upload_errors,
})
}
}
}
struct CheckDispatchArgs {
filters: IssueFilters,
trace_opts: TraceOptions,
include_dupes: bool,
top: Option<usize>,
file: Vec<std::path::PathBuf>,
}
#[derive(Clone, Copy)]
struct ListDispatchArgs {
entry_points: bool,
files: bool,
plugins: bool,
boundaries: bool,
workspaces: bool,
}
impl ListDispatchArgs {
fn workspaces() -> Self {
Self {
entry_points: false,
files: false,
plugins: false,
boundaries: false,
workspaces: true,
}
}
}
fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
let cli = dispatch.cli;
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
watch::run_watch(&watch::WatchOptions {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
production,
clear_screen: !no_clear,
explain: cli.explain,
include_entry_exports: cli.include_entry_exports,
})
}
#[derive(Clone, Copy)]
struct FixDispatchArgs {
dry_run: bool,
yes: bool,
no_create_config: bool,
}
fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
fix::run_fix(&fix::FixOptions {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
dry_run: args.dry_run,
yes: args.yes,
production,
no_create_config: args.no_create_config,
})
}
fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
list::run_list(&ListOptions {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
threads: dispatch.threads,
no_cache: cli.no_cache,
entry_points: args.entry_points,
files: args.files,
plugins: args.plugins,
boundaries: args.boundaries,
workspaces: args.workspaces,
production,
})
}
fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
check::run_check(&CheckOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
fail_on_issues,
filters: &args.filters,
changed_since: cli.changed_since.as_deref(),
diff_index: None,
use_shared_diff_index: true,
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
sarif_file: cli.sarif_file.as_deref(),
production,
production_override: Some(production),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
group_by: cli.group_by,
include_dupes: args.include_dupes,
trace_opts: &args.trace_opts,
explain: cli.explain,
top: args.top,
file: &args.file,
include_entry_exports: cli.include_entry_exports,
summary: cli.summary,
regression_opts: dispatch.regression_opts(
cli.changed_since.is_some()
|| cli.workspace.is_some()
|| cli.changed_workspaces.is_some()
|| !args.file.is_empty(),
),
retain_modules_for_health: false,
defer_performance: false,
})
}
struct DupesDispatchArgs {
mode: Option<DupesMode>,
min_tokens: Option<usize>,
min_lines: Option<usize>,
min_occurrences: Option<usize>,
threshold: Option<f64>,
skip_local: bool,
cross_language: bool,
ignore_imports: bool,
top: Option<usize>,
trace: Option<String>,
}
fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
Ok(production) => production,
Err(code) => return code,
};
dupes::run_dupes(&DupesOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
mode: args.mode,
min_tokens: args.min_tokens,
min_lines: args.min_lines,
min_occurrences: args.min_occurrences,
threshold: args.threshold,
skip_local: args.skip_local,
cross_language: args.cross_language,
ignore_imports: args.ignore_imports,
top: args.top,
baseline_path: cli.baseline.as_deref(),
save_baseline_path: cli.save_baseline.as_deref(),
production,
production_override: Some(production),
trace: args.trace.as_deref(),
changed_since: cli.changed_since.as_deref(),
diff_index: None,
use_shared_diff_index: true,
changed_files: None,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
summary: cli.summary,
group_by: cli.group_by,
performance: cli.performance,
})
}
struct AuditDispatchArgs {
production_dead_code: bool,
production_health: bool,
production_dupes: bool,
dead_code_baseline: Option<PathBuf>,
health_baseline: Option<PathBuf>,
dupes_baseline: Option<PathBuf>,
max_crap: Option<f64>,
coverage: Option<PathBuf>,
coverage_root: Option<PathBuf>,
gate: Option<AuditGateArg>,
runtime_coverage: Option<PathBuf>,
min_invocations_hot: u64,
gate_marker: Option<String>,
}
struct ResolvedAuditInputs {
audit_cfg: fallow_config::AuditConfig,
cache_dir: PathBuf,
production: ProductionModes,
dead_code_baseline: Option<PathBuf>,
health_baseline: Option<PathBuf>,
dupes_baseline: Option<PathBuf>,
coverage: Option<PathBuf>,
}
fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let output = dispatch.output;
if cli.baseline.is_some() || cli.save_baseline.is_some() {
return emit_error(
"audit uses per-analysis baselines. Use --dead-code-baseline, --health-baseline, or --dupes-baseline (or save them with `fallow dead-code|health|dupes --save-baseline <file>`)",
2,
output,
);
}
let inputs = match resolve_audit_inputs(dispatch, args) {
Ok(inputs) => inputs,
Err(code) => return code,
};
run_resolved_audit(dispatch, args, &inputs)
}
fn resolve_audit_inputs(
dispatch: &DispatchContext<'_>,
args: &AuditDispatchArgs,
) -> Result<ResolvedAuditInputs, ExitCode> {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let config = load_config(
root,
&cli.config,
output,
cli.no_cache,
dispatch.threads,
cli.production,
dispatch.quiet,
)?;
let cache_dir = config.cache_dir.clone();
let audit_cfg = config.audit;
let production = resolve_production_modes(
cli,
root,
output,
args.production_dead_code,
args.production_health,
args.production_dupes,
)?;
let resolved_dead_code_baseline = resolve_audit_baseline_path(
root,
args.dead_code_baseline.as_deref(),
audit_cfg.dead_code_baseline.as_deref(),
);
let resolved_health_baseline = resolve_audit_baseline_path(
root,
args.health_baseline.as_deref(),
audit_cfg.health_baseline.as_deref(),
);
let resolved_dupes_baseline = resolve_audit_baseline_path(
root,
args.dupes_baseline.as_deref(),
audit_cfg.dupes_baseline.as_deref(),
);
let coverage = args
.coverage
.clone()
.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
Ok(ResolvedAuditInputs {
audit_cfg,
cache_dir,
production,
dead_code_baseline: resolved_dead_code_baseline,
health_baseline: resolved_health_baseline,
dupes_baseline: resolved_dupes_baseline,
coverage,
})
}
fn run_resolved_audit(
dispatch: &DispatchContext<'_>,
args: &AuditDispatchArgs,
inputs: &ResolvedAuditInputs,
) -> ExitCode {
let cli = dispatch.cli;
audit::run_audit(
&audit::AuditOptions {
root: dispatch.root,
config_path: &cli.config,
cache_dir: &inputs.cache_dir,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
changed_since: cli.changed_since.as_deref(),
production: cli.production,
production_dead_code: Some(inputs.production.dead_code),
production_health: Some(inputs.production.health),
production_dupes: Some(inputs.production.dupes),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
group_by: cli.group_by,
dead_code_baseline: inputs.dead_code_baseline.as_deref(),
health_baseline: inputs.health_baseline.as_deref(),
dupes_baseline: inputs.dupes_baseline.as_deref(),
max_crap: args.max_crap,
coverage: inputs.coverage.as_deref(),
coverage_root: args.coverage_root.as_deref(),
gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
include_entry_exports: cli.include_entry_exports,
runtime_coverage: args.runtime_coverage.as_deref(),
min_invocations_hot: args.min_invocations_hot,
},
args.gate_marker.as_deref(),
)
}
struct HealthDispatchArgs<'a> {
max_cyclomatic: Option<u16>,
max_cognitive: Option<u16>,
max_crap: Option<f64>,
top: Option<usize>,
sort: health::SortBy,
complexity: bool,
complexity_breakdown: 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>,
report_only: bool,
since: Option<&'a str>,
min_commits: Option<u32>,
save_snapshot: Option<&'a Option<String>>,
trend: bool,
coverage: Option<&'a std::path::Path>,
coverage_root: Option<&'a std::path::Path>,
runtime_coverage: Option<&'a std::path::Path>,
min_invocations_hot: u64,
min_observation_volume: Option<u32>,
low_traffic_threshold: Option<f64>,
}
struct ResolvedHealthCoverageInputs {
coverage: Option<PathBuf>,
coverage_root: Option<PathBuf>,
}
fn resolve_health_coverage_inputs(
dispatch: &DispatchContext<'_>,
cli_coverage: Option<&std::path::Path>,
cli_coverage_root: Option<&std::path::Path>,
) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
let env_coverage = path_from_env("FALLOW_COVERAGE");
let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
let config_health = if needs_config_coverage || needs_config_coverage_root {
Some(
load_config(
dispatch.root,
&dispatch.cli.config,
dispatch.output,
dispatch.cli.no_cache,
dispatch.threads,
dispatch.cli.production,
dispatch.quiet,
)?
.health,
)
} else {
None
};
Ok(ResolvedHealthCoverageInputs {
coverage: cli_coverage
.map(std::path::Path::to_path_buf)
.or(env_coverage)
.or_else(|| {
config_health
.as_ref()
.and_then(|health| health.coverage.clone())
}),
coverage_root: cli_coverage_root
.map(std::path::Path::to_path_buf)
.or(env_coverage_root)
.or_else(|| {
config_health
.as_ref()
.and_then(|health| health.coverage_root.clone())
}),
})
}
fn path_from_env(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn dispatch_health(dispatch: &DispatchContext<'_>, args: HealthDispatchArgs<'_>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let threads = dispatch.threads;
let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
let HealthDispatchArgs {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
complexity_breakdown,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails,
targets,
effort,
score,
min_score,
min_severity,
report_only,
since,
min_commits,
save_snapshot,
trend,
coverage,
coverage_root,
runtime_coverage,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
} = args;
if report_only && (min_score.is_some() || min_severity.is_some()) {
return emit_error(
"--report-only cannot be combined with --min-score or --min-severity. \
--report-only always exits 0; drop it to gate on score/severity, or \
drop the gate flags to stay advisory.",
2,
output,
);
}
let targets = targets || effort.is_some();
let sections = effective_health_sections(&EffectiveHealthSectionInput {
output,
complexity,
file_scores,
coverage_gaps,
hotspots,
targets,
score,
min_score,
save_snapshot,
trend,
});
let runtime_coverage = if let Some(path) = runtime_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
};
let production = match resolve_production_modes(cli, root, output, false, false, false) {
Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
Err(code) => return code,
};
let coverage_inputs = match resolve_health_coverage_inputs(dispatch, coverage, coverage_root) {
Ok(inputs) => inputs,
Err(code) => return code,
};
health::run_health(&HealthOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
production,
production_override: Some(production),
changed_since: cli.changed_since.as_deref(),
diff_index: None,
use_shared_diff_index: true,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
complexity: sections.complexity,
complexity_breakdown,
file_scores: sections.file_scores,
coverage_gaps: sections.coverage_gaps,
config_activates_coverage_gaps: !sections.any_section,
hotspots: sections.hotspots,
ownership: ownership && sections.hotspots,
ownership_emails,
targets: sections.targets,
force_full: sections.force_full,
score_only_output: sections.score_only_output,
enforce_coverage_gap_gate: true,
effort: effort.map(EffortFilter::to_estimate),
score: sections.score,
min_score,
min_severity,
report_only,
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_inputs.coverage.as_deref(),
coverage_root: coverage_inputs.coverage_root.as_deref(),
performance: cli.performance,
runtime_coverage,
churn_file: cli.churn_file.as_deref(),
})
}
struct EffectiveHealthSectionInput<'a> {
output: fallow_config::OutputFormat,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
targets: bool,
score: bool,
min_score: Option<f64>,
save_snapshot: Option<&'a Option<String>>,
trend: bool,
}
struct EffectiveHealthSections {
any_section: bool,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
targets: bool,
score: bool,
force_full: bool,
score_only_output: bool,
}
fn effective_health_sections(input: &EffectiveHealthSectionInput<'_>) -> EffectiveHealthSections {
let score = input.score
|| input.min_score.is_some()
|| input.trend
|| matches!(input.output, fallow_config::OutputFormat::Badge);
let snapshot_requested = input.save_snapshot.is_some();
let any_section = input.complexity
|| input.file_scores
|| input.coverage_gaps
|| input.hotspots
|| input.targets
|| score;
let eff_score = if any_section { score } else { true } || snapshot_requested;
let force_full = snapshot_requested || eff_score;
EffectiveHealthSections {
any_section,
complexity: if any_section { input.complexity } else { true },
file_scores: if any_section { input.file_scores } else { true } || force_full,
coverage_gaps: if any_section {
input.coverage_gaps
} else {
false
},
hotspots: if any_section { input.hotspots } else { true }
|| snapshot_requested
|| input.trend,
targets: if any_section { input.targets } else { true },
score: eff_score,
force_full,
score_only_output: is_health_score_only_output(input, score),
}
}
fn is_health_score_only_output(input: &EffectiveHealthSectionInput<'_>, score: bool) -> bool {
score
&& !input.complexity
&& !input.file_scores
&& !input.coverage_gaps
&& !input.hotspots
&& !input.targets
&& !input.trend
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_definition_has_no_flag_collisions() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn after_help_lists_every_task_matrix_command() {
for row in crate::task_matrix::TASK_MATRIX {
assert!(
TOP_LEVEL_AFTER_HELP.contains(row.command),
"root --help cheat sheet is missing task-matrix command '{}'; \
update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
row.command
);
}
}
#[test]
fn high_value_commands_route_to_distinct_workflows() {
use clap::Parser;
use fallow_config::OutputFormat;
let distinct = [
(vec!["fallow", "impact"], telemetry::Workflow::Impact),
(vec!["fallow", "security"], telemetry::Workflow::Security),
(vec!["fallow", "fix"], telemetry::Workflow::Fix),
(
vec!["fallow", "explain", "unused-exports"],
telemetry::Workflow::Explain,
),
(
vec!["fallow", "watch"],
telemetry::Workflow::CodeQualityReview,
),
(
vec!["fallow", "list"],
telemetry::Workflow::ProjectInventory,
),
(
vec!["fallow", "workspaces"],
telemetry::Workflow::ProjectInventory,
),
(
vec!["fallow", "schema"],
telemetry::Workflow::ProjectInventory,
),
(vec!["fallow", "init"], telemetry::Workflow::Setup),
(
vec!["fallow", "hooks", "install", "--target", "git"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
(vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
(
vec!["fallow", "rule-pack-schema"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "config"], telemetry::Workflow::Setup),
(
vec!["fallow", "ci-template", "gitlab"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "migrate"], telemetry::Workflow::Setup),
(
vec!["fallow", "telemetry", "status"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
(
vec!["fallow", "license", "status"],
telemetry::Workflow::License,
),
];
for (argv, expected) in distinct {
let cli = Cli::try_parse_from(&argv).expect("argv parses");
assert_eq!(
telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
expected,
"{argv:?} should map to {expected:?}"
);
}
}
#[test]
fn version_flag_accepts_lower_v_upper_v_and_long() {
use clap::CommandFactory;
for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
let err = Cli::command()
.try_get_matches_from(argv)
.expect_err("version flag should short-circuit parsing");
assert_eq!(
err.kind(),
clap::error::ErrorKind::DisplayVersion,
"{argv:?} should trigger the Version action"
);
}
}
#[test]
fn cli_help_text_contains_no_implementation_status_wording() {
use clap::CommandFactory;
let mut root = Cli::command();
let mut violations: Vec<(String, String)> = Vec::new();
visit_help(&mut root, "fallow", &mut violations);
assert!(
violations.is_empty(),
"found implementation-status wording in --help output:\n{}",
violations
.iter()
.map(|(cmd, line)| format!(" {cmd}: {line}"))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn top_level_help_groups_commands_by_workflow() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
let expected_order = [
"Analysis:",
" dead-code",
" dupes",
" health",
" flags",
" security",
" audit",
"Workflow:",
" watch",
" fix",
"Project inspection:",
" list",
" workspaces",
" explain",
" impact",
"Setup and configuration:",
" init",
" migrate",
" config",
" config-schema",
" plugin-schema",
" rule-pack-schema",
"Automation and CI:",
" ci",
" ci-template",
" hooks",
" setup-hooks",
"Runtime coverage:",
" coverage",
" license",
"Reference:",
" schema",
" help",
"Options:",
];
let mut cursor = 0;
for needle in expected_order {
let Some(offset) = help[cursor..].find(needle) else {
panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
};
cursor += offset + needle.len();
}
}
#[test]
fn security_help_hides_globals_rejected_by_security_validator() {
let help = render_security_help();
for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
assert!(
!help_contains_long_flag(&help, long),
"security help must hide unsupported --{long}:\n{help}"
);
}
for long in [
"root",
"config",
"format",
"quiet",
"no-cache",
"threads",
"changed-since",
"diff-file",
"diff-stdin",
"workspace",
"changed-workspaces",
"ci",
"fail-on-issues",
"sarif-file",
"summary",
"output-file",
"legacy-envelope",
"max-file-size",
"explain",
"surface",
] {
assert!(
help_contains_long_flag(&help, long),
"security help must keep supported --{long}:\n{help}"
);
}
}
#[test]
fn security_help_detection_covers_subcommand_and_help_alias_forms() {
assert!(args_request_security_help(["security", "--help"]));
assert!(args_request_security_help(["security", "-h"]));
assert!(args_request_security_help([
"--format", "json", "security", "--help"
]));
assert!(args_request_security_help(["help", "security"]));
assert!(!args_request_security_help(["health", "--help"]));
assert!(!args_request_security_help(["help", "health"]));
}
#[test]
fn security_unsupported_global_validator_matches_hidden_help_contract() {
for (argv, expected) in [
(vec!["fallow", "security", "--performance"], "--performance"),
(
vec!["fallow", "security", "--baseline", "base.json"],
"--baseline",
),
(
vec!["fallow", "security", "--dupes-mode", "weak"],
"--dupes-mode",
),
] {
let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
assert_eq!(unsupported_security_global(&cli), Some(expected));
}
let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
.expect("security --explain parses");
assert_eq!(unsupported_security_global(&explain), None);
}
#[test]
fn programmatic_common_options_track_analysis_affecting_cli_globals() {
use clap::CommandFactory;
let cli_flags: std::collections::BTreeSet<String> = Cli::command()
.get_arguments()
.filter(|arg| arg.is_global_set())
.filter_map(|arg| arg.get_long().map(str::to_owned))
.filter(|name| {
matches!(
name.as_str(),
"root"
| "config"
| "no-cache"
| "threads"
| "changed-since"
| "diff-file"
| "production"
| "workspace"
| "changed-workspaces"
| "explain"
| "legacy-envelope"
)
})
.collect();
let programmatic_flags: std::collections::BTreeSet<String> =
fallow_cli::programmatic::COMMON_ANALYSIS_OPTION_FLAGS
.iter()
.map(|flag| (*flag).to_owned())
.collect();
assert_eq!(programmatic_flags, cli_flags);
}
fn help_contains_long_flag(help: &str, long: &str) -> bool {
let flag = format!("--{long}");
help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
.any(|token| token == flag)
}
fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
let help = cmd.render_long_help().to_string();
for line in scan_forbidden(&help) {
violations.push((path.to_owned(), line));
}
let names: Vec<String> = cmd
.get_subcommands()
.map(|sub| sub.get_name().to_owned())
.collect();
for name in names {
if name == "help" {
continue;
}
if let Some(sub) = cmd.find_subcommand_mut(&name) {
let sub_path = format!("{path} {name}");
visit_help(sub, &sub_path, violations);
}
}
}
fn scan_forbidden(s: &str) -> Vec<String> {
let lower = s.to_ascii_lowercase();
let mut out = Vec::new();
for word in ["stub", "placeholder"] {
if let Some(idx) = find_whole_word(&lower, word) {
out.push(extract_line(s, idx));
}
}
if let Some(idx) = lower.find("not yet") {
out.push(extract_line(s, idx));
}
out
}
fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
let bytes = haystack.as_bytes();
let mut start = 0;
while let Some(rel) = haystack[start..].find(word) {
let abs = start + rel;
let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
let after_idx = abs + word.len();
let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
if before_ok && after_ok {
return Some(abs);
}
start = abs + word.len();
}
None
}
fn extract_line(s: &str, byte_idx: usize) -> String {
let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
s[line_start..line_end].trim().to_owned()
}
#[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 bare_coverage_flags_parse_without_subcommand() {
let cli = Cli::try_parse_from([
"fallow",
"--coverage",
"coverage/coverage-final.json",
"--coverage-root",
"/ci/workspace",
])
.expect("bare combined coverage flags should parse");
assert!(cli.command.is_none());
assert_eq!(
cli.coverage.as_deref(),
Some(std::path::Path::new("coverage/coverage-final.json"))
);
assert_eq!(
cli.coverage_root.as_deref(),
Some(std::path::Path::new("/ci/workspace"))
);
}
#[test]
fn bare_coverage_before_subcommand_is_detectable() {
let cli = Cli::try_parse_from([
"fallow",
"--coverage",
"coverage/coverage-final.json",
"dead-code",
])
.expect("clap should parse pre-subcommand bare coverage for custom rejection");
assert!(cli.command.is_some());
assert!(cli_has_bare_coverage_input(&cli));
let message = bare_coverage_subcommand_error_message();
assert!(message.contains("bare combined-mode flags"));
assert!(message.contains("fallow health --coverage <coverage-final.json>"));
}
#[test]
fn subcommand_coverage_flag_keeps_regular_clap_error() {
let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
panic!("dead-code --coverage should fail to parse");
};
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn legacy_check_alias_detection_ignores_option_values() {
assert!(args_use_legacy_check_alias(vec![
"fallow".to_string(),
"check".to_string(),
"--summary".to_string(),
]));
assert!(!args_use_legacy_check_alias(vec![
"fallow".to_string(),
"--root".to_string(),
"check".to_string(),
"dead-code".to_string(),
]));
assert!(!args_use_legacy_check_alias(vec![
"fallow".to_string(),
"dead-code".to_string(),
"--file".to_string(),
"check".to_string(),
]));
}
#[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" | "gitlab-codequality" | "gitlab-code-quality" => {
Some(Format::CodeClimate)
}
"pr-comment-github" => Some(Format::PrCommentGithub),
"pr-comment-gitlab" => Some(Format::PrCommentGitlab),
"review-github" => Some(Format::ReviewGithub),
"review-gitlab" => Some(Format::ReviewGitlab),
"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("gitlab-codequality"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse("gitlab-code-quality"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse("pr-comment-github"),
Some(Format::PrCommentGithub)
));
assert!(matches!(
parse("pr-comment-gitlab"),
Some(Format::PrCommentGitlab)
));
assert!(matches!(parse("review-github"), Some(Format::ReviewGithub)));
assert!(matches!(parse("review-gitlab"), Some(Format::ReviewGitlab)));
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"));
}
#[test]
fn tracing_filter_defaults_to_warn_without_env() {
assert_eq!(build_tracing_filter(None).to_string(), "warn");
}
#[test]
fn tracing_filter_respects_explicit_env_directives() {
assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
}
#[test]
fn tracing_filter_treats_empty_env_as_off() {
assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
}
}