#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI binary produces intentional terminal output"
)]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod api;
mod audit;
mod baseline;
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 setup_hooks;
mod signal;
mod telemetry;
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 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
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
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.";
#[derive(Parser)]
#[command(
name = "fallow",
about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
version,
help_template = TOP_LEVEL_HELP_TEMPLATE,
after_help = TOP_LEVEL_AFTER_HELP
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(short, long, global = true)]
root: Option<PathBuf>,
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[arg(
short,
long,
visible_alias = "output",
global = true,
default_value = "human"
)]
format: Format,
#[arg(short, long, global = true)]
quiet: bool,
#[arg(long, global = true)]
no_cache: bool,
#[arg(long, global = true)]
threads: Option<usize>,
#[arg(long, visible_alias = "base", global = true)]
changed_since: Option<String>,
#[arg(long = "diff-file", value_name = "PATH", global = true)]
diff_file: Option<PathBuf>,
#[arg(long = "diff-stdin", global = true)]
diff_stdin: bool,
#[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 = "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)]
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(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)]
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, 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)]
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)]
hooks: bool,
#[arg(long, requires = "hooks")]
branch: Option<String>,
},
Hooks {
#[command(subcommand)]
subcommand: HooksCli,
},
Ci {
#[command(subcommand)]
subcommand: CiCli,
},
ConfigSchema,
PluginSchema,
Config {
#[arg(long)]
path: bool,
},
List {
#[arg(long)]
entry_points: bool,
#[arg(long)]
files: bool,
#[arg(long)]
plugins: bool,
#[arg(long)]
boundaries: bool,
#[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)]
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>,
},
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 {
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 let Some(ref config_path) = cli.config
&& let Some(s) = config_path.to_str()
&& let Err(e) = validate::validate_no_control_chars(s, "--config")
{
return Err(emit_error(&e, 2, output));
}
if let Some(ref ws_patterns) = cli.workspace {
for ws in ws_patterns {
if let Err(e) = validate::validate_no_control_chars(ws, "--workspace") {
return Err(emit_error(&e, 2, output));
}
}
}
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-since")
{
return Err(emit_error(&e, 2, output));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-workspaces")
{
return Err(emit_error(&e, 2, output));
}
if cli.workspace.is_some() && cli.changed_workspaces.is_some() {
return Err(emit_error(
"--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,
));
}
let raw_root = cli
.root
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
let root = match validate::validate_root(&raw_root) {
Ok(r) => r,
Err(e) => {
return Err(emit_error(&e, 2, output));
}
};
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_error(
&format!("invalid --changed-since: {e}"),
2,
output,
));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_error(
&format!("invalid --changed-workspaces: {e}"),
2,
output,
));
}
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 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 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 main() -> ExitCode {
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}");
}
fallow_core::churn::set_spawn_hook(signal::scoped_child::output);
fallow_core::changed_files::set_spawn_hook(signal::scoped_child::output);
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
return signal_test_helper();
}
let mut cli = Cli::parse();
if let Some(workspaces) = cli.workspace.as_ref()
&& !workspaces.is_empty()
{
report::ci::pr_comment::set_workspace_marker_from_list(workspaces);
}
if matches!(cli.command, Some(Command::Schema)) {
return schema::run_schema();
}
if matches!(cli.command, Some(Command::ConfigSchema)) {
return init::run_config_schema();
}
if matches!(cli.command, Some(Command::PluginSchema)) {
return init::run_plugin_schema();
}
let fmt = resolve_format(&cli);
if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output) {
return code;
}
setup_tracing();
let (root, threads) = match validate_inputs(&cli, fmt.output) {
Ok(v) => v,
Err(code) => return code,
};
let FormatConfig {
output,
quiet,
cli_format_was_explicit,
} = fmt;
let diff_source = match report::ci::diff_filter::resolve_diff_source(
cli.diff_file.as_deref(),
cli.diff_stdin,
&root,
) {
Ok(src) => src,
Err(msg) => return emit_error(&msg, 2, output),
};
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);
if (cli.ci || cli.fail_on_issues || cli.sarif_file.is_some())
&& matches!(
cli.command,
Some(
Command::Init { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::Schema
| Command::Explain { .. }
| Command::CiTemplate { .. }
| Command::Config { .. }
| Command::Ci { .. }
| Command::List { .. }
| Command::Flags { .. }
| Command::Migrate { .. }
| Command::License { .. }
| Command::Coverage { .. }
| Command::Hooks { .. }
| Command::SetupHooks { .. }
)
)
{
return emit_error(
"--ci, --fail-on-issues, and --sarif-file are only valid with dead-code, dupes, health, or bare invocation",
2,
output,
);
}
if (!cli.only.is_empty() || !cli.skip.is_empty()) && cli.command.is_some() {
return emit_error(
"--only and --skip can only be used without a subcommand",
2,
output,
);
}
if (cli.production_dead_code || cli.production_health || cli.production_dupes)
&& cli.command.is_some()
{
return emit_error(
"--production-dead-code, --production-health, and --production-dupes can only be used without a subcommand. For audit, pass them after `audit`",
2,
output,
);
}
if !cli.only.is_empty() && !cli.skip.is_empty() {
return emit_error("--only and --skip are mutually exclusive", 2, output);
}
let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
Ok(t) => t,
Err(e) => return emit_error(&format!("invalid --tolerance: {e}"), 2, output),
};
let save_regression_file: Option<std::path::PathBuf> =
cli.save_regression_baseline.as_ref().and_then(|opt| {
opt.as_ref()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
});
let save_to_config = cli.save_regression_baseline.is_some() && save_regression_file.is_none();
let command = cli.command.take();
let telemetry_workflow = telemetry_workflow_for_command(command.as_ref(), output);
let telemetry_start = std::time::Instant::now();
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 = match command {
None => dispatch_bare_command(&dispatch),
Some(cmd) => dispatch_subcommand(cmd, &dispatch),
};
telemetry::record_workflow(&telemetry::WorkflowRecord {
workflow: telemetry_workflow,
output,
quiet,
elapsed: telemetry_start.elapsed(),
exit_code,
parent_run: cli.parent_run.as_deref(),
});
if exit_code == ExitCode::SUCCESS {
telemetry::maybe_print_opt_in_note(output, quiet);
}
exit_code
}
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 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,
};
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(),
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,
score: cli.score || cli.trend,
trend: cli.trend,
save_snapshot: cli.save_snapshot.as_ref(),
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(),
),
})
}
#[expect(
clippy::too_many_lines,
reason = "CLI dispatch handles all subcommands"
)]
fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let quiet = dispatch.quiet;
let threads = dispatch.threads;
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,
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,
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 } => {
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,
};
watch::run_watch(&watch::WatchOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
production,
clear_screen: !no_clear,
explain: cli.explain,
include_entry_exports: cli.include_entry_exports,
})
}
Command::Fix {
dry_run,
yes,
no_create_config,
} => {
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,
};
fix::run_fix(&fix::FixOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
dry_run,
yes,
production,
no_create_config,
})
}
Command::Init {
toml,
hooks,
branch,
} => init::run_init(&init::InitOptions {
root,
use_toml: toml,
hooks,
branch: branch.as_deref(),
}),
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::CiTemplate { subcommand } => match subcommand {
CiTemplateCli::Gitlab { vendor, force } => {
ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
vendor_dir: vendor,
force,
})
}
},
Command::Config { path } => config::run_config(root, cli.config.as_deref(), path, output),
Command::Workspaces => {
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,
};
list::run_list(&ListOptions {
root,
config_path: &cli.config,
output,
threads,
no_cache: cli.no_cache,
entry_points: false,
files: false,
plugins: false,
boundaries: false,
workspaces: true,
production,
})
}
Command::List {
entry_points,
files,
plugins,
boundaries,
workspaces,
} => {
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,
};
list::run_list(&ListOptions {
root,
config_path: &cli.config,
output,
threads,
no_cache: cli.no_cache,
entry_points,
files,
plugins,
boundaries,
workspaces,
production,
})
}
Command::Dupes {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
} => dispatch_dupes(
dispatch,
&DupesDispatchArgs {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
},
),
Command::Health {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
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,
} => {
let coverage =
coverage.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
let ownership = ownership || ownership_emails.is_some();
let hotspots = hotspots || ownership;
dispatch_health(
dispatch,
HealthDispatchArgs {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
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,
},
)
}
Command::Flags { top } => {
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,
})
}
Command::Explain { issue_type } => explain::run_explain(&issue_type.join(" "), output),
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,
} => {
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 audit_cfg = match load_config(
root,
&cli.config,
output,
cli.no_cache,
threads,
cli.production,
quiet,
) {
Ok(c) => c.audit,
Err(code) => return code,
};
let production = match resolve_production_modes(
cli,
root,
output,
production_dead_code,
production_health,
production_dupes,
) {
Ok(production) => production,
Err(code) => return code,
};
let resolved_dead_code_baseline = resolve_audit_baseline_path(
root,
dead_code_baseline.as_deref(),
audit_cfg.dead_code_baseline.as_deref(),
);
let resolved_health_baseline = resolve_audit_baseline_path(
root,
health_baseline.as_deref(),
audit_cfg.health_baseline.as_deref(),
);
let resolved_dupes_baseline = resolve_audit_baseline_path(
root,
dupes_baseline.as_deref(),
audit_cfg.dupes_baseline.as_deref(),
);
let coverage =
coverage.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
audit::run_audit(
&audit::AuditOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
changed_since: cli.changed_since.as_deref(),
production: cli.production,
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(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
group_by: cli.group_by,
dead_code_baseline: resolved_dead_code_baseline.as_deref(),
health_baseline: resolved_health_baseline.as_deref(),
dupes_baseline: resolved_dupes_baseline.as_deref(),
max_crap,
coverage: coverage.as_deref(),
coverage_root: coverage_root.as_deref(),
gate: gate.map_or(audit_cfg.gate, Into::into),
include_entry_exports: cli.include_entry_exports,
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
},
gate_marker.as_deref(),
)
}
Command::Impact { subcommand } => 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 => {
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 crate::error::emit_error(
"impact supports human, json, and markdown output",
2,
output,
);
}
};
println!("{rendered}");
ExitCode::SUCCESS
}
},
Command::Schema => unreachable!("handled above"),
Command::Migrate {
toml,
jsonc,
dry_run,
from,
} => migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref()),
Command::License { subcommand } => license::run(&map_license_subcommand(subcommand)),
Command::Telemetry { .. } => unreachable!("handled before root validation"),
Command::Coverage { subcommand } => coverage::run(
map_coverage_subcommand(&subcommand, cli.explain),
&coverage::RunContext {
root,
config_path: &cli.config,
output,
quiet,
no_cache: cli.no_cache,
threads,
explain: cli.explain,
},
),
Command::SetupHooks {
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall,
} => setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
root,
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall,
}),
}
}
fn telemetry_workflow_for_command(
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::Workflow {
match command {
None | Some(Command::Flags { .. }) => 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::List { .. }
| Command::Workspaces
| Command::Fix { .. }
| Command::Watch { .. }
| Command::Init { .. }
| Command::Hooks { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::Config { .. }
| Command::Explain { .. }
| Command::Schema
| Command::CiTemplate { .. }
| Command::Migrate { .. }
| Command::License { .. }
| Command::Telemetry { .. }
| Command::SetupHooks { .. }
| Command::Impact { .. },
) => telemetry::Workflow::Unknown,
}
}
fn run_hooks_command(
root: &std::path::Path,
subcommand: HooksCli,
output: fallow_config::OutputFormat,
) -> ExitCode {
match subcommand {
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>,
}
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 HealthDispatchArgs<'a> {
max_cyclomatic: Option<u16>,
max_cognitive: Option<u16>,
max_crap: Option<f64>,
top: Option<usize>,
sort: health::SortBy,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
ownership: bool,
ownership_emails: Option<fallow_config::EmailMode>,
targets: bool,
effort: Option<EffortFilter>,
score: bool,
min_score: Option<f64>,
min_severity: Option<health_types::FindingSeverity>,
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>,
}
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,
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 badge_format = matches!(output, fallow_config::OutputFormat::Badge);
let score = score || min_score.is_some() || trend || badge_format;
let snapshot_requested = save_snapshot.is_some();
let any_section = complexity || file_scores || coverage_gaps || hotspots || targets || score;
let eff_score = if any_section { score } else { true } || snapshot_requested;
let force_full = snapshot_requested || eff_score;
let needs_hotspot_vitals = snapshot_requested || trend;
let score_only_output =
score && !complexity && !file_scores && !coverage_gaps && !hotspots && !targets && !trend;
let eff_file_scores = if any_section { file_scores } else { true } || force_full;
let eff_coverage_gaps = if any_section { coverage_gaps } else { false };
let eff_hotspots = if any_section { hotspots } else { true } || needs_hotspot_vitals;
let eff_complexity = if any_section { complexity } else { true };
let eff_targets = if any_section { targets } else { true };
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,
};
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: eff_complexity,
file_scores: eff_file_scores,
coverage_gaps: eff_coverage_gaps,
config_activates_coverage_gaps: !any_section,
hotspots: eff_hotspots,
ownership: ownership && eff_hotspots,
ownership_emails,
targets: eff_targets,
force_full,
score_only_output,
enforce_coverage_gap_gate: true,
effort: effort.map(EffortFilter::to_estimate),
score: eff_score,
min_score,
min_severity,
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_root,
performance: cli.performance,
runtime_coverage,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_definition_has_no_flag_collisions() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[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",
" audit",
"Workflow:",
" watch",
" fix",
"Project inspection:",
" list",
" workspaces",
" explain",
" impact",
"Setup and configuration:",
" init",
" migrate",
" config",
" config-schema",
" plugin-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 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"
)
})
.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 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 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");
}
}