Skip to main content

fallow_cli/
lib.rs

1#![expect(
2    clippy::print_stdout,
3    clippy::print_stderr,
4    reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7    test,
8    allow(
9        clippy::unwrap_used,
10        clippy::expect_used,
11        reason = "tests use unwrap and expect to keep fixture setup concise"
12    )
13)]
14
15use std::path::{Path, PathBuf};
16use std::process::ExitCode;
17
18mod api;
19#[cfg(test)]
20mod architecture_boundaries;
21mod audit;
22mod audit_brief;
23mod audit_cache_prune;
24mod audit_decision_surface;
25mod audit_focus;
26mod audit_walkthrough;
27mod base_worktree;
28mod baselines;
29/// Re-exported for integration tests so they hash reusable-cache roots through
30/// the exact production path (`dunce` canonicalization + platform path-identity
31/// bytes) rather than an approximation that diverges on Windows.
32pub use base_worktree::canonical_root_hash;
33mod walkthrough_state;
34use fallow_engine::baseline;
35mod agent_install;
36mod baseline_gate;
37mod baseline_growth;
38mod cache_notice;
39mod check;
40mod ci;
41mod ci_template;
42mod claude_code_hint;
43mod cli_agent;
44mod cli_args;
45mod cli_format;
46mod cli_hooks;
47mod cli_impact;
48mod cli_production;
49mod cli_report;
50mod cli_startup;
51pub use fallow_engine::codeowners;
52mod combined;
53mod config;
54mod count_delta;
55mod coverage;
56mod discovery_note;
57mod dispatch;
58mod doctor;
59mod dupes;
60mod exit_codes;
61pub mod explain;
62mod fix;
63mod flags;
64mod flags_retirement_formats;
65mod gates;
66mod guard;
67mod health;
68mod impact;
69mod init;
70mod inspect;
71mod json_style;
72mod license;
73mod list;
74mod migrate;
75mod mutation_cli;
76mod mutation_human;
77mod mutation_inspect;
78mod onboarding;
79#[cfg(test)]
80mod output_envelope;
81mod output_runtime;
82mod path_util;
83mod plugin_check;
84mod process_clock;
85mod rayon_pool;
86mod regression;
87pub mod report;
88mod requests;
89mod rule_pack;
90mod runtime_support;
91mod schema;
92mod scope_path;
93mod security;
94mod security_help;
95mod selector;
96mod setup_hooks;
97mod signal;
98mod similar_code_cli;
99mod similar_code_help;
100mod suppressions;
101mod task_matrix;
102mod telemetry;
103mod trace_chain;
104mod trace_dependency;
105mod trace_error;
106mod trace_path;
107mod type_aware_degrade;
108mod update_check;
109use fallow_engine::validate;
110use fallow_engine::vital_signs;
111mod cli_telemetry;
112mod viz;
113mod watch;
114mod write_scope;
115
116use cli_args::{AuditCacheCli, Cli, Command, CoverageCli, RulePackCli};
117pub use cli_args::{AuditGateArg, BaselineModeArg, EffortFilter, EmailModeArg, HealthSeverityCli};
118use dispatch::{
119    ResolvedHealthCoverageInputs, dispatch_subcommand, map_telemetry_subcommand,
120    resolve_health_coverage_inputs,
121};
122/// Structured error output for CLI and JSON formats.
123pub(crate) mod error;
124use cli_format::FormatConfig;
125#[cfg(test)]
126use cli_format::parse_format_arg;
127use cli_impact::ImpactCli;
128use cli_production::{ProductionModes, resolve_production_modes};
129#[cfg(test)]
130use cli_startup::build_tracing_filter;
131use cli_startup::{
132    bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
133    cli_bare_combined_baseline_flag, cli_has_bare_coverage_input, parse_cli_args,
134    reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing, validate_inputs,
135};
136#[cfg(test)]
137use cli_telemetry::TelemetryRun;
138#[cfg(test)]
139use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
140use cli_telemetry::{record_run_epilogue, start_telemetry_run};
141use error::emit_error;
142pub(crate) use runtime_support::{AnalysisKind, GroupBy};
143pub(crate) use runtime_support::{
144    ConfigLoadOptions, build_ownership_resolver, load_config_for_analysis,
145};
146#[cfg(test)]
147use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
148use security_help::{render_security_help, security_help_target};
149use similar_code_help::{render_similar_code_help, similar_code_help_target};
150
151const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
152
153/// Resolve an audit baseline path using CLI > config precedence.
154///
155/// Both sources resolve relative paths against the project root. This keeps
156/// behavior consistent in CI scripts where `--root $REPO_ROOT` differs from
157/// the process CWD.
158fn resolve_audit_baseline_path(
159    root: &std::path::Path,
160    cli: Option<&std::path::Path>,
161    config: Option<&str>,
162) -> Option<PathBuf> {
163    let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
164        config.map(|p| {
165            let path = PathBuf::from(p);
166            if path_util::is_absolute_path_any_platform(&path) {
167                path
168            } else {
169                root.join(path)
170            }
171        })
172    })?;
173    if path_util::is_absolute_path_any_platform(&path) {
174        Some(path)
175    } else {
176        Some(root.join(path))
177    }
178}
179
180fn emit_known_failure(
181    message: &str,
182    exit_code: u8,
183    output: fallow_config::OutputFormat,
184    reason: telemetry::FailureReason,
185) -> ExitCode {
186    telemetry::note_failure_reason(reason);
187    emit_error(message, exit_code, output)
188}
189
190fn emit_known_failure_with_style(
191    message: &str,
192    exit_code: u8,
193    output: fallow_config::OutputFormat,
194    json_style: json_style::JsonStyle,
195    reason: telemetry::FailureReason,
196) -> ExitCode {
197    telemetry::note_failure_reason(reason);
198    error::emit_error_with_style(message, exit_code, output, json_style)
199}
200
201fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
202    if cli.baseline.is_some() {
203        Some("--baseline")
204    } else if cli.save_baseline.is_some() {
205        Some("--save-baseline")
206    } else if cli.fail_on_stale_baseline {
207        Some("--fail-on-stale-baseline")
208    } else if cli.fail_on_baseline_growth {
209        Some("--fail-on-baseline-growth")
210    } else if cli.baseline_base.is_some() {
211        Some("--baseline-base")
212    } else if cli.fail_on_parse_error {
213        Some("--fail-on-parse-error")
214    } else if cli.production {
215        Some("--production")
216    } else if cli.no_production {
217        Some("--no-production")
218    } else if cli.no_package_baselines {
219        Some("--no-package-baselines")
220    } else if cli.group_by.is_some() {
221        Some("--group-by")
222    } else if cli.performance {
223        Some("--performance")
224    } else if cli.explain_skipped {
225        Some("--explain-skipped")
226    } else if cli.fail_on_regression {
227        Some("--fail-on-regression")
228    } else if cli.regression_baseline.is_some() {
229        Some("--regression-baseline")
230    } else if cli.save_regression_baseline.is_some() {
231        Some("--save-regression-baseline")
232    } else if let Some(flag) = cli.dupes_overrides().first_global_flag() {
233        Some(flag)
234    } else if cli.include_entry_exports {
235        Some("--include-entry-exports")
236    } else if cli.show_cascade {
237        Some("--show-cascade")
238    } else {
239        None
240    }
241}
242
243struct DispatchContext<'a> {
244    cli: &'a Cli,
245    root: &'a std::path::Path,
246    output: fallow_config::OutputFormat,
247    quiet: bool,
248    fail_on_issues: bool,
249    json_style: json_style::JsonStyle,
250    threads: usize,
251    tolerance: regression::Tolerance,
252    save_regression_file: Option<&'a std::path::PathBuf>,
253    save_to_config: bool,
254}
255
256impl DispatchContext<'_> {
257    fn production_modes(
258        &self,
259        dead_code: bool,
260        health: bool,
261        dupes: bool,
262    ) -> Result<ProductionModes, ExitCode> {
263        resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
264    }
265
266    fn production_for(
267        &self,
268        analysis: fallow_config::ProductionAnalysis,
269    ) -> Result<bool, ExitCode> {
270        self.production_modes(false, false, false)
271            .map(|modes| modes.for_analysis(analysis))
272    }
273
274    fn growth_flags(&self) -> baseline_growth::GrowthFlags<'_> {
275        baseline_growth::GrowthFlags {
276            enabled: self.cli.fail_on_baseline_growth,
277            base: self.cli.baseline_base.as_deref(),
278            changed_since: self.cli.changed_since.as_deref(),
279        }
280    }
281
282    fn growth_ctx(
283        &self,
284        owner: baseline_growth::GrowthOwner,
285    ) -> baseline_growth::GrowthContext<'_> {
286        baseline_growth::GrowthContext {
287            root: self.root,
288            flags: self.growth_flags(),
289            owner,
290            output: self.output,
291            json_style: self.json_style,
292        }
293    }
294
295    fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
296        regression::RegressionOpts {
297            fail_on_regression: self.cli.fail_on_regression,
298            tolerance: self.tolerance,
299            regression_baseline_file: self.cli.regression_baseline.as_deref(),
300            save_target: if let Some(path) = self.save_regression_file {
301                regression::SaveRegressionTarget::File(path)
302            } else if self.save_to_config {
303                regression::SaveRegressionTarget::Config
304            } else {
305                regression::SaveRegressionTarget::None
306            },
307            scoped,
308            quiet: self.quiet,
309            output: self.output,
310        }
311    }
312}
313
314/// Test-only helper invoked when `FALLOW_TEST_SIGNAL_HELPER=1` is set.
315/// Spawns `sleep 30` via the `ScopedChild` registry so the child is
316/// tracked by the signal handler, prints the child PID to stdout, then
317/// busy-waits so a SIGINT/SIGTERM delivered to the parent fires the
318/// signal handler (which kills the child and exits 128+signum).
319///
320/// When `FALLOW_TEST_SIGNAL_HELPER_GRACEFUL=1` is also set, graceful
321/// mode is activated BEFORE spawning the child. In graceful mode the
322/// signal handler kills the child (proving drain runs unconditionally)
323/// but does NOT call `std::process::exit`, so the helper itself sees
324/// `wait_with_output` return and exits 0. This is the path the
325/// integration test asserts: graceful drain + clean exit.
326///
327/// With `FALLOW_TEST_SIGNAL_HELPER=panic`, a thread panics after the PID
328/// line, so the test can assert that the panic hook kills the child. Lives in
329/// `main.rs` (not tests/) because clap is already parsed below and we
330/// need to intercept before that.
331#[cfg(unix)]
332fn signal_test_helper() -> ExitCode {
333    use std::io::Write as _;
334    use std::process::Command;
335
336    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
337        signal::set_graceful_mode();
338    }
339
340    let mut command = Command::new("sleep");
341    command.arg("30");
342    let child = match signal::ScopedChild::spawn(&mut command) {
343        Ok(c) => c,
344        Err(err) => {
345            let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
346            return ExitCode::from(2);
347        }
348    };
349    let pid = child.id();
350    let stdout = std::io::stdout();
351    let mut lock = stdout.lock();
352    let _ = writeln!(lock, "{pid}");
353    let _ = lock.flush();
354    drop(lock);
355    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some_and(|value| value == "panic") {
356        // A panic on another thread leaves this thread and its `Drop` alive,
357        // so only the panic hook can end the child.
358        let _ = std::thread::spawn(|| panic!("signal helper panic")).join();
359    }
360    let _ = child.wait_with_output();
361    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
362        return ExitCode::SUCCESS;
363    }
364    std::thread::sleep(std::time::Duration::from_secs(5));
365    ExitCode::SUCCESS
366}
367
368#[cfg(not(unix))]
369fn signal_test_helper() -> ExitCode {
370    ExitCode::from(2)
371}
372
373fn install_spawn_hooks() {
374    fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
375    fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
376}
377
378fn install_signal_handlers() {
379    if let Err(err) = signal::install_handlers() {
380        use std::io::Write as _;
381        let stderr = std::io::stderr();
382        let mut lock = stderr.lock();
383        let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
384    }
385}
386
387/// Open `path` (creating parent dirs, truncating) and redirect report output
388/// there via the ambient sink, forcing color off so the file carries no ANSI
389/// codes even when attached to a TTY. Returns the error exit code if the file
390/// cannot be created. Backs `--output-file`.
391fn redirect_report_to_file(
392    path: &std::path::Path,
393    output: fallow_config::OutputFormat,
394) -> Result<(), ExitCode> {
395    match fallow_engine::write_guard::create_file(
396        path,
397        fallow_engine::write_guard::WriteTarget::Path,
398    ) {
399        Ok(file) => {
400            report::sink::set_file_sink(file);
401            colored::control::set_override(false);
402            Ok(())
403        }
404        Err(e) if e.is_directory() => Err(emit_error(
405            &format!(
406                "failed to create the directory of {} for --output-file: {e}",
407                path.display()
408            ),
409            2,
410            output,
411        )),
412        Err(e) => Err(emit_error(
413            &format!("failed to open {} for --output-file: {e}", path.display()),
414            2,
415            output,
416        )),
417    }
418}
419
420/// Flush the report file after rendering and print the stderr confirmation
421/// (suppressed by `--quiet`). Returns the error exit code on a write failure.
422fn finalize_report_file(
423    path: &std::path::Path,
424    quiet: bool,
425    output: fallow_config::OutputFormat,
426) -> Result<(), ExitCode> {
427    if let Err(e) = report::sink::flush() {
428        return Err(emit_error(
429            &format!("failed to write {}: {e}", path.display()),
430            2,
431            output,
432        ));
433    }
434    // Suppress the confirmation when nothing was rendered to the file (a command
435    // that errored before producing output sends its error to stdout, not the
436    // file), so we never claim "Report written" over an empty file.
437    if !quiet && report::sink::wrote() {
438        eprintln!("Report written to {}", path.display());
439    }
440    Ok(())
441}
442
443/// Run the full fallow CLI: parse argv, dispatch the selected command, and
444/// return the process exit code. This is the crate's single entry point; the
445/// `fallow` binary and the multicall `fallow-multicall` binary both delegate
446/// here so there is exactly one clap tree and one dispatch path.
447///
448/// A stdout write error other than a closed pipe changes the exit code to 2,
449/// so that a cut-off report never looks like a successful run.
450pub fn run() -> ExitCode {
451    report::sink::finish_stdout(run_command())
452}
453
454fn run_command() -> ExitCode {
455    process_clock::mark_process_start();
456    install_signal_handlers();
457    signal::install_panic_drain();
458    install_spawn_hooks();
459
460    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
461        return signal_test_helper();
462    }
463
464    let (mut cli, fmt) = match parse_cli_args() {
465        Ok(parsed) => parsed,
466        Err(code) => return code,
467    };
468    if cli.pretty && !fmt.payload_is_json {
469        eprintln!(
470            "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
471        );
472        return ExitCode::from(2);
473    }
474
475    if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
476        return code;
477    }
478
479    if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
480        return code;
481    }
482
483    if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
484        return code;
485    }
486    if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
487        return code;
488    }
489    if is_impact_statusline(&cli) {
490        let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
491            Ok(validated) => validated,
492            Err(code) => return code,
493        };
494        return cli_impact::render_impact_statusline(&root);
495    }
496    let telemetry_run = start_telemetry_run(&cli, &fmt);
497
498    let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
499        Ok(v) => v,
500        Err(code) => {
501            return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref(), None);
502        }
503    };
504
505    let FormatConfig {
506        output,
507        payload_is_json: _,
508        quiet,
509        fail_on_issues,
510        json_style,
511    } = fmt;
512
513    let tolerance =
514        match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
515            Ok(tolerance) => tolerance,
516            Err(code) => return code,
517        };
518
519    if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
520        eprintln!("{note}");
521        cli.no_cache = true;
522    }
523
524    let (save_regression_file, save_to_config) = regression_save_targets(&cli);
525
526    // The agent and hooks commands manage the Claude Code setup themselves.
527    // A plugin hint after `agent uninstall` would ask the user to install
528    // again at once, so these commands never write the hint.
529    let hint_root = (!matches!(
530        cli.command,
531        Some(Command::Agent { .. } | Command::Hooks { .. } | Command::SetupHooks { .. })
532    ))
533    .then_some(root.as_path());
534    let command = cli.command.take();
535    process_clock::record_startup();
536    let dispatch = DispatchContext {
537        cli: &cli,
538        root: &root,
539        output,
540        quiet,
541        fail_on_issues,
542        json_style,
543        threads,
544        tolerance,
545        save_regression_file: save_regression_file.as_ref(),
546        save_to_config,
547    };
548    let exit_code = match dispatch_and_finalize(&dispatch, command) {
549        Ok(code) => code,
550        Err(code) => return code,
551    };
552    record_run_epilogue(
553        telemetry_run,
554        exit_code,
555        None,
556        cli.parent_run.as_deref(),
557        hint_root,
558    )
559}
560
561/// Benchmark hook for the production fix dry-run pipeline. This is not a
562/// supported API. Rendered output is disabled so the benchmark measures
563/// analysis and fix planning rather than terminal I/O.
564#[doc(hidden)]
565pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
566    let config_path = None;
567    fix::run_fix_with_count(&fix::FixOptions {
568        root,
569        config_path: &config_path,
570        output: fallow_config::OutputFormat::Json,
571        json_style: json_style::JsonStyle::Compact,
572        no_cache: true,
573        threads,
574        quiet: true,
575        emit_output: false,
576        allow_remote_extends: false,
577        dry_run: true,
578        yes: false,
579        production: false,
580        no_create_config: true,
581        type_aware: None,
582        type_aware_projects: &[],
583        type_aware_require: None,
584        scope: None,
585        show_cascade: false,
586    })
587}
588
589/// Owned production-analysis corpus for the stable audit review benchmark.
590/// This is not a supported API.
591#[doc(hidden)]
592pub use audit::AuditReviewBenchmarkCorpus;
593
594/// Build audit analysis and preload external review inputs outside the timed
595/// benchmark. This is not a supported API.
596#[doc(hidden)]
597pub fn create_audit_review_benchmark_corpus(
598    root: &Path,
599    changed_files: &[PathBuf],
600    threads: usize,
601) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
602    audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
603}
604
605/// Benchmark production audit review assembly and compact tagged JSON without
606/// git, cache, worktree, or file I/O. This is not a supported API.
607#[doc(hidden)]
608pub fn benchmark_audit_review_brief_many_changed_files_json(
609    corpus: &mut AuditReviewBenchmarkCorpus,
610) -> (ExitCode, usize, usize, usize, usize, usize) {
611    match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
612        Ok(result) => (
613            ExitCode::SUCCESS,
614            result.introduced_count,
615            result.inherited_count,
616            result.public_api_added_count,
617            result.decision_count,
618            result.rendered_bytes,
619        ),
620        Err(code) => (code, 0, 0, 0, 0, 0),
621    }
622}
623
624/// Finding counts of the audit analyses benchmark. This is not a supported API.
625#[doc(hidden)]
626pub use audit::AuditAnalysesBenchmarkResult;
627
628/// Benchmark the HEAD and base analyses of one audit over two prepared trees,
629/// without git. This is not a supported API.
630///
631/// # Errors
632///
633/// Returns the exit code of the failed analysis.
634#[doc(hidden)]
635pub fn benchmark_audit_analyses(
636    head_root: &Path,
637    base_root: &Path,
638    changed_files: &[PathBuf],
639    threads: usize,
640) -> Result<AuditAnalysesBenchmarkResult, ExitCode> {
641    audit::benchmark_audit_analyses(head_root, base_root, changed_files, threads)
642}
643
644#[doc(hidden)]
645pub use inspect::InspectBenchmarkCorpus;
646
647/// Build the child-response corpus outside the timed inspect benchmark. This
648/// is not a supported API.
649#[doc(hidden)]
650pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
651    inspect::create_inspect_benchmark_corpus(root, threads)
652}
653
654/// Benchmark file inspect orchestration and compact tagged JSON rendering
655/// without process startup. This is not a supported API.
656#[doc(hidden)]
657pub fn benchmark_inspect_file_evidence_bundle_json(
658    root: &Path,
659    threads: usize,
660    corpus: &InspectBenchmarkCorpus,
661) -> (ExitCode, usize, usize) {
662    match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
663        Ok((child_call_count, rendered_bytes)) => {
664            (ExitCode::SUCCESS, child_call_count, rendered_bytes)
665        }
666        Err(_) => (ExitCode::from(2), 0, 0),
667    }
668}
669
670/// Benchmark hook for the production dead-code analysis and compact JSON
671/// rendering pipeline. This is not a supported API.
672#[doc(hidden)]
673pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
674    match check::benchmark_dead_code_json(root, threads) {
675        Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
676        Err(code) => (code, 0, 0),
677    }
678}
679
680/// Benchmark hook for the production security analysis and JSON rendering
681/// pipeline. This is not a supported API.
682#[doc(hidden)]
683pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
684    match security::benchmark_security_json(root, threads) {
685        Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
686        Err(code) => (code, 0, 0),
687    }
688}
689
690#[doc(hidden)]
691pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
692
693/// Build the explicit candidate and verifier inputs outside the timed
694/// survivors benchmark. This is not a supported API.
695#[doc(hidden)]
696pub fn create_security_survivors_benchmark_corpus(
697    root: &Path,
698    threads: usize,
699) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
700    security::create_security_survivors_benchmark_corpus(root, threads)
701}
702
703/// Benchmark the production survivors loaders, candidate/verdict join, and
704/// compact JSON serializer. This is not a supported API.
705#[doc(hidden)]
706pub fn benchmark_security_survivors_json(
707    corpus: &SecuritySurvivorsBenchmarkCorpus,
708) -> (ExitCode, usize, usize, usize, usize, usize) {
709    match security::benchmark_security_survivors_json(corpus) {
710        Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
711            ExitCode::SUCCESS,
712            survivors,
713            dismissed,
714            needs_human_review,
715            unverdicted,
716            rendered_bytes,
717        ),
718        Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
719    }
720}
721
722/// Benchmark unresolved-callee normalization, blind-spot grouping, and compact
723/// JSON serialization without project I/O. This is not a supported API.
724#[doc(hidden)]
725pub fn benchmark_security_blind_spots_json(
726    root: &Path,
727    diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
728) -> SecurityBlindSpotsBenchmarkResult {
729    security::benchmark_security_blind_spots_json(root, diagnostics)
730}
731
732/// Benchmark hook for the production list inventory and JSON rendering
733/// pipeline. This is not a supported API.
734#[doc(hidden)]
735pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
736    match list::benchmark_list_json(root, threads) {
737        Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
738            ExitCode::SUCCESS,
739            file_count,
740            entry_point_count,
741            workspace_count,
742            rendered_bytes,
743        ),
744        Err(code) => (code, 0, 0, 0, 0),
745    }
746}
747
748/// Benchmark hook for the production boundaries listing and compact JSON
749/// rendering pipeline. This is not a supported API.
750#[doc(hidden)]
751pub fn benchmark_list_boundaries_json(
752    root: &Path,
753    threads: usize,
754) -> (ExitCode, usize, usize, usize, usize) {
755    match list::benchmark_list_boundaries_json(root, threads) {
756        Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
757            ExitCode::SUCCESS,
758            zone_count,
759            rule_count,
760            matched_file_count,
761            rendered_bytes,
762        ),
763        Err(code) => (code, 0, 0, 0, 0),
764    }
765}
766
767/// Opaque deterministic global matcher for the watch-filter benchmark. This is
768/// not a supported API.
769#[doc(hidden)]
770pub use watch::WatchFilterBenchmarkGlobalGitignore;
771
772/// Build a deterministic global gitignore matcher outside the timed watch
773/// benchmark. This is not a supported API.
774#[doc(hidden)]
775pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
776    watch::create_benchmark_global_gitignore()
777}
778
779/// Benchmark hook for production watch-filter initialization and project
780/// gitignore discovery. This is not a supported API.
781#[doc(hidden)]
782pub fn benchmark_watch_filter_initialization(
783    config: &fallow_config::ResolvedConfig,
784    global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
785) -> (usize, usize) {
786    watch::benchmark_filter_initialization(config, global_gitignore)
787}
788
789/// Benchmark hook for the production Viz analysis, payload, and HTML
790/// rendering pipeline. This is not a supported API.
791#[doc(hidden)]
792pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
793    match viz::benchmark_viz_html(root, threads) {
794        Ok((file_count, edge_count, rendered_bytes)) => {
795            (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
796        }
797        Err(code) => (code, 0, 0, 0),
798    }
799}
800
801/// Benchmark hook for the production rule-pack analysis and JSON rendering
802/// pipeline. This is not a supported API.
803#[doc(hidden)]
804pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
805    match rule_pack::benchmark_rule_pack_test_json(root, threads) {
806        Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
807        Err(code) => (code, 0, 0),
808    }
809}
810
811/// Benchmark hook for the production recommendation discovery and compact JSON
812/// rendering pipeline. This is not a supported API.
813#[doc(hidden)]
814pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
815    match onboarding::benchmark_recommend_json(root) {
816        Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
817            ExitCode::SUCCESS,
818            decision_count,
819            framework_count,
820            heterogeneous,
821            rendered_bytes,
822        ),
823        Err(_) => (ExitCode::from(2), 0, 0, false, 0),
824    }
825}
826
827/// Benchmark hook for local runtime coverage analysis and compact JSON
828/// rendering with an in-process sidecar response. This is not a supported API.
829#[doc(hidden)]
830pub fn benchmark_runtime_coverage_analyze_json(
831    root: &Path,
832    runtime_coverage_path: &Path,
833    response_bytes: &[u8],
834    threads: usize,
835) -> (ExitCode, usize, usize, usize, String) {
836    match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
837        Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
838            ExitCode::SUCCESS,
839            finding_count,
840            hot_path_count,
841            request_bytes,
842            rendered,
843        ),
844        Err(code) => (code, 0, 0, 0, String::new()),
845    }
846}
847
848/// Status bars refresh frequently, so their local read path bypasses telemetry,
849/// update checks, notices, and every other command epilogue.
850fn is_impact_statusline(cli: &Cli) -> bool {
851    matches!(
852        cli.command.as_ref(),
853        Some(Command::Impact {
854            subcommand: Some(ImpactCli::Statusline),
855            all: false,
856            ..
857        })
858    )
859}
860
861/// Redirect the rendered report to `--output-file` (ambient sink), dispatch the
862/// command, then flush+close the report file. Returns the dispatch exit code, or
863/// `Err` carrying a redirect/finalize failure code for `main` to return directly.
864fn dispatch_and_finalize(
865    dispatch: &DispatchContext<'_>,
866    command: Option<Command>,
867) -> Result<ExitCode, ExitCode> {
868    let cli = dispatch.cli;
869    let output = dispatch.output;
870    let quiet = dispatch.quiet;
871
872    // Set up the report-file sink before dispatch so rendering lands in the file;
873    // progress and the confirmation stay on stderr. `fallow mutation` opens the
874    // file itself after the run, so the file never makes the worktree dirty.
875    let deferred_output = matches!(command, Some(Command::Mutation { .. }));
876    let output_file = cli.output_file.as_deref().filter(|_| !deferred_output);
877    if let Some(path) = output_file
878        && let Err(code) = redirect_report_to_file(path, output)
879    {
880        return Err(code);
881    }
882
883    let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
884        emit_error(bare_coverage_subcommand_error_message(), 2, output)
885    } else if command.is_some()
886        && let Some(flag) = cli_bare_combined_baseline_flag(cli)
887    {
888        emit_error(
889            &bare_combined_baseline_subcommand_error_message(flag),
890            2,
891            output,
892        )
893    } else {
894        match command {
895            None => dispatch_bare_command(dispatch),
896            Some(cmd) => dispatch_subcommand(cmd, dispatch),
897        }
898    };
899
900    if let Some(path) = output_file
901        && let Err(code) = finalize_report_file(path, quiet, output)
902    {
903        return Err(code);
904    }
905    Ok(exit_code)
906}
907
908fn run_telemetry_command_if_requested(
909    cli: &mut Cli,
910    output: fallow_config::OutputFormat,
911    json_style: json_style::JsonStyle,
912) -> Option<ExitCode> {
913    if matches!(cli.command, Some(Command::Telemetry { .. }))
914        && let Some(Command::Telemetry { subcommand }) = cli.command.take()
915    {
916        return Some(telemetry::run(
917            map_telemetry_subcommand(subcommand),
918            output,
919            json_style,
920        ));
921    }
922    None
923}
924
925/// Doctor deliberately bypasses the normal run epilogue: collecting readiness
926/// must not write telemetry, flush a spool, perform an update check, or create
927/// caches. Root validation stays inside the typed report so failures still
928/// return the complete doctor envelope.
929fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
930    if !matches!(cli.command, Some(Command::Doctor)) {
931        return None;
932    }
933
934    if let Some(flag) = unsupported_doctor_option(cli) {
935        let message = if flag == "--output-file" {
936            "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
937        } else {
938            format!("{flag} is not valid with `fallow doctor`.")
939        };
940        return Some(crate::error::emit_error_with_style(
941            &message,
942            2,
943            format.output,
944            format.json_style,
945        ));
946    }
947    if let Err(code) = doctor::validate_output(format.output, format.json_style) {
948        return Some(code);
949    }
950
951    let root = cli.root.clone().unwrap_or_else(|| {
952        std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
953    });
954    let config_path = cli.config.as_ref().map(|path| {
955        if path_util::is_absolute_path_any_platform(path) {
956            path.clone()
957        } else {
958            root.join(path)
959        }
960    });
961    let report = doctor::collect_report(&root, config_path.as_deref());
962    Some(doctor::render_report(
963        &report,
964        format.output,
965        format.json_style,
966    ))
967}
968
969/// Doctor accepts only project selection and presentation options. Rejecting
970/// analysis flags avoids implying that a readiness check ran an analysis or
971/// applied a gate that it deliberately bypasses.
972fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
973    let dupes_flag = cli.dupes_overrides().first_global_flag();
974    [
975        (cli.allow_remote_extends, "--allow-remote-extends"),
976        (cli.no_cache, "--no-cache"),
977        (cli.threads.is_some(), "--threads"),
978        (cli.changed_since.is_some(), "--changed-since"),
979        (cli.diff_file.is_some(), "--diff-file"),
980        (cli.diff_stdin, "--diff-stdin"),
981        (cli.churn_file.is_some(), "--churn-file"),
982        (cli.max_file_size.is_some(), "--max-file-size"),
983        (cli.baseline.is_some(), "--baseline"),
984        (cli.baseline_mode.is_some(), "--baseline-mode"),
985        (cli.parent_run.is_some(), "--parent-run"),
986        (cli.save_baseline.is_some(), "--save-baseline"),
987        (cli.production, "--production"),
988        (cli.no_production, "--no-production"),
989        (cli.no_package_baselines, "--no-package-baselines"),
990        (cli.production_dead_code, "--production-dead-code"),
991        (cli.production_health, "--production-health"),
992        (cli.production_dupes, "--production-dupes"),
993        (cli.workspace.is_some(), "--workspace"),
994        (cli.changed_workspaces.is_some(), "--changed-workspaces"),
995        (cli.group_by.is_some(), "--group-by"),
996        (cli.performance, "--performance"),
997        (cli.explain, "--explain"),
998        (cli.explain_skipped, "--explain-skipped"),
999        (cli.summary, "--summary"),
1000        (cli.ci, "--ci"),
1001        (cli.fail_on_issues, "--fail-on-issues"),
1002        (cli.sarif_file.is_some(), "--sarif-file"),
1003        (cli.output_file.is_some(), "--output-file"),
1004        (cli.report_path_prefix.is_some(), "--report-path-prefix"),
1005        (cli.fail_on_regression, "--fail-on-regression"),
1006        (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
1007        (cli.fail_on_baseline_growth, "--fail-on-baseline-growth"),
1008        (cli.baseline_base.is_some(), "--baseline-base"),
1009        (cli.fail_on_parse_error, "--fail-on-parse-error"),
1010        (cli.tolerance != "0", "--tolerance"),
1011        (cli.regression_baseline.is_some(), "--regression-baseline"),
1012        (
1013            cli.save_regression_baseline.is_some(),
1014            "--save-regression-baseline",
1015        ),
1016        (!cli.only.is_empty(), "--only"),
1017        (!cli.skip.is_empty(), "--skip"),
1018        (dupes_flag.is_some(), dupes_flag.unwrap_or_default()),
1019        (cli.score, "--score"),
1020        (cli.trend, "--trend"),
1021        (cli.trend_from.is_some(), "--trend-from"),
1022        (cli.save_snapshot.is_some(), "--save-snapshot"),
1023        (cli.coverage.is_some(), "--coverage"),
1024        (cli.coverage_root.is_some(), "--coverage-root"),
1025        (cli.dupes_baseline.is_some(), "--dupes-baseline"),
1026        (cli.health_baseline.is_some(), "--health-baseline"),
1027        (cli.include_entry_exports, "--include-entry-exports"),
1028        (cli.show_cascade, "--show-cascade"),
1029        (cli.type_aware, "--type-aware"),
1030        (cli.no_type_aware, "--no-type-aware"),
1031        (!cli.type_aware_project.is_empty(), "--type-aware-project"),
1032        (cli.type_aware_require.is_some(), "--type-aware-require"),
1033    ]
1034    .into_iter()
1035    .find_map(|(used, flag)| used.then_some(flag))
1036}
1037
1038fn run_schema_command_if_requested(
1039    cli: &Cli,
1040    json_style: json_style::JsonStyle,
1041) -> Option<ExitCode> {
1042    match cli.command {
1043        Some(Command::Schema) => Some(schema::run_schema(json_style)),
1044        Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
1045        Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
1046        Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
1047        _ => None,
1048    }
1049}
1050
1051fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
1052    let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
1053        opt.as_ref()
1054            .filter(|path| !path.is_empty())
1055            .map(std::path::PathBuf::from)
1056    });
1057    let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
1058    (save_file, save_to_config)
1059}
1060
1061fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
1062    let cli = dispatch.cli;
1063    let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
1064    if !(run_check || run_dupes || run_health) {
1065        return emit_error(
1066            "--skip leaves no analysis to run: pass at most two of dead-code, dupes and health",
1067            2,
1068            dispatch.output,
1069        );
1070    }
1071    let production = match dispatch.production_modes(
1072        cli.production_dead_code,
1073        cli.production_health,
1074        cli.production_dupes,
1075    ) {
1076        Ok(production) => production,
1077        Err(code) => return code,
1078    };
1079    // Coverage only feeds health scoring, and resolving it validates the
1080    // winning root. A bare run that excludes health (`--only check`,
1081    // `--skip health`) must neither load config for coverage nor reject a
1082    // relative `health.coverageRoot` it never reads.
1083    let coverage_inputs = if run_health {
1084        match resolve_health_coverage_inputs(
1085            dispatch,
1086            cli.coverage.as_deref(),
1087            cli.coverage_root.as_deref(),
1088        ) {
1089            Ok(inputs) => inputs,
1090            Err(code) => return code,
1091        }
1092    } else {
1093        ResolvedHealthCoverageInputs::default()
1094    };
1095    run_bare_combined(
1096        dispatch,
1097        production,
1098        &coverage_inputs,
1099        BareAnalyses {
1100            run_check,
1101            run_dupes,
1102            run_health,
1103        },
1104    )
1105}
1106
1107/// Which analyses the bare `fallow` run executes (resolved from `--only`/`--skip`).
1108#[derive(Clone, Copy)]
1109struct BareAnalyses {
1110    run_check: bool,
1111    run_dupes: bool,
1112    run_health: bool,
1113}
1114
1115/// Build `CombinedOptions` for a bare `fallow` invocation and run the combined
1116/// pipeline.
1117fn run_bare_combined(
1118    dispatch: &DispatchContext<'_>,
1119    production: ProductionModes,
1120    coverage_inputs: &ResolvedHealthCoverageInputs,
1121    analyses: BareAnalyses,
1122) -> ExitCode {
1123    let cli = dispatch.cli;
1124    let output = dispatch.output;
1125    if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
1126        return error::emit_error_with_style(
1127            "--fail-on-parse-error needs the dead-code or health analysis, and this run analyzes neither. Include dead-code or health in --only or --skip, or remove the flag.",
1128            2,
1129            output,
1130            dispatch.json_style,
1131        );
1132    }
1133    let scope = match crate::scope_path::resolve_command_scope(
1134        dispatch.root,
1135        dispatch.output,
1136        cli.path.clone(),
1137    ) {
1138        Ok(scope) => scope.map(|resolved| resolved.absolute),
1139        Err(code) => return code,
1140    };
1141    let growth_targets = baseline_growth::targets(&[
1142        (
1143            cli.baseline.as_deref().filter(|_| analyses.run_check),
1144            fallow_engine::baseline::BaselineKind::DeadCode,
1145        ),
1146        (
1147            cli.dupes_baseline.as_deref().filter(|_| analyses.run_dupes),
1148            fallow_engine::baseline::BaselineKind::Dupes,
1149        ),
1150        (
1151            cli.health_baseline
1152                .as_deref()
1153                .filter(|_| analyses.run_health),
1154            fallow_engine::baseline::BaselineKind::Health,
1155        ),
1156    ]);
1157    baseline_growth::run_gated(
1158        &dispatch.growth_ctx(baseline_growth::GrowthOwner::Combined),
1159        &growth_targets,
1160        || run_combined_scoped(dispatch, production, coverage_inputs, analyses, scope),
1161    )
1162}
1163
1164fn run_combined_scoped(
1165    dispatch: &DispatchContext<'_>,
1166    production: ProductionModes,
1167    coverage_inputs: &ResolvedHealthCoverageInputs,
1168    analyses: BareAnalyses,
1169    scope: Option<PathBuf>,
1170) -> ExitCode {
1171    let cli = dispatch.cli;
1172    let (output, quiet, fail_on_issues) =
1173        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
1174    let scoped_run = scope.is_some();
1175    let architecture = combined::resolve_architecture_selection(&cli.only, &cli.skip);
1176    // A narrowed dead-code section saves narrowed regression counts.
1177    let narrowed_report = architecture != check::ArchitectureSelection::All;
1178    combined::run_combined(&combined::CombinedOptions {
1179        root: dispatch.root,
1180        config_path: &cli.config,
1181        output,
1182        json_style: dispatch.json_style,
1183        no_cache: cli.no_cache,
1184        threads: dispatch.threads,
1185        quiet,
1186        allow_remote_extends: cli.allow_remote_extends,
1187        fail_on_issues,
1188        sarif_file: cli.sarif_file.as_deref(),
1189        changed_since: cli.changed_since.as_deref(),
1190        no_package_baselines: cli.no_package_baselines,
1191        churn_file: cli.churn_file.as_deref(),
1192        baseline: cli.baseline.as_deref(),
1193        save_baseline: cli.save_baseline.as_deref(),
1194        dupes_baseline: cli.dupes_baseline.as_deref(),
1195        health_baseline: cli.health_baseline.as_deref(),
1196        health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
1197        health_baseline_mode_explicit: cli.baseline_mode.is_some(),
1198        fail_on_stale_baseline: cli.fail_on_stale_baseline,
1199        production: cli.production,
1200        production_dead_code: Some(production.dead_code),
1201        production_health: Some(production.health),
1202        production_dupes: Some(production.dupes),
1203        workspace: cli.workspace.as_deref(),
1204        changed_workspaces: cli.changed_workspaces.as_deref(),
1205        group_by: cli.group_by,
1206        type_aware: cli.type_aware_override(),
1207        type_aware_projects: &cli.type_aware_project,
1208        type_aware_require: cli.type_aware_require.map(Into::into),
1209        explain: cli.explain,
1210        explain_skipped: cli.explain_skipped,
1211        performance: cli.performance,
1212        summary: cli.summary,
1213        run_check: analyses.run_check,
1214        run_dupes: analyses.run_dupes,
1215        run_health: analyses.run_health,
1216        architecture,
1217        dupes: cli.dupes_overrides(),
1218        score: cli.score || cli.trend || cli.trend_from.is_some(),
1219        trend: cli.trend || cli.trend_from.is_some(),
1220        trend_from: cli.trend_from.as_deref(),
1221        save_snapshot: cli.save_snapshot.as_ref(),
1222        coverage: coverage_inputs.coverage.as_deref(),
1223        coverage_root: coverage_inputs.coverage_root.as_deref(),
1224        include_entry_exports: cli.include_entry_exports,
1225        show_cascade: cli.show_cascade,
1226        fail_on_parse_error: cli.fail_on_parse_error,
1227        scope,
1228        regression_opts: dispatch.regression_opts(
1229            cli.changed_since.is_some()
1230                || cli.workspace.is_some()
1231                || cli.changed_workspaces.is_some()
1232                || scoped_run
1233                || narrowed_report,
1234        ),
1235    })
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use clap::Parser;
1241
1242    use super::*;
1243    use crate::cli_args::TOP_LEVEL_AFTER_LONG_HELP;
1244    use crate::cli_format::Format;
1245    use crate::dispatch::{
1246        architecture_issue_filters, check_issue_filters, type_aware_status_output,
1247    };
1248
1249    /// Validates that the CLI definition has no flag name collisions, missing
1250    /// fields, or other structural errors. Catches issues like a global alias
1251    /// `--base` colliding with a subcommand's `--base` flag.
1252    #[test]
1253    fn cli_definition_has_no_flag_collisions() {
1254        use clap::CommandFactory;
1255        Cli::command().debug_assert();
1256    }
1257
1258    #[test]
1259    fn impact_statusline_subcommand_parses() {
1260        use clap::Parser;
1261
1262        let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
1263        assert!(matches!(
1264            cli.command,
1265            Some(Command::Impact {
1266                subcommand: Some(ImpactCli::Statusline),
1267                ..
1268            })
1269        ));
1270    }
1271
1272    #[test]
1273    fn impact_statusline_bypasses_command_epilogue() {
1274        use clap::Parser;
1275
1276        let statusline =
1277            Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
1278        assert!(is_impact_statusline(&statusline));
1279
1280        let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
1281        assert!(!is_impact_statusline(&status));
1282
1283        let all_statusline =
1284            Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
1285        assert!(!is_impact_statusline(&all_statusline));
1286    }
1287
1288    #[test]
1289    fn regression_baseline_help_explains_the_default_destination() {
1290        use clap::CommandFactory;
1291        let help = Cli::command().render_long_help().to_string();
1292
1293        assert!(help.contains("Omit PATH to update regression.baseline"));
1294        assert!(help.contains("discovered fallow config"));
1295        assert!(help.contains("create .fallowrc.json when none exists"));
1296    }
1297
1298    /// The root `--help` cheat sheet is a static const that cannot call the
1299    /// shared renderer, so this test is the only guard that it stays in sync
1300    /// with `TASK_MATRIX`. Every row's command string must appear verbatim.
1301    #[test]
1302    fn after_help_lists_every_task_matrix_command() {
1303        for row in crate::task_matrix::TASK_MATRIX {
1304            assert!(
1305                TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
1306                "root --help cheat sheet is missing task-matrix command '{}'; \
1307                 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
1308                row.command
1309            );
1310        }
1311    }
1312
1313    /// The curated command groups replace clap's auto-generated subcommand
1314    /// list, so a new subcommand stays invisible in `fallow --help` unless it
1315    /// is added here. Substring matching is not enough (e.g. `--trace`
1316    /// contains `trace`), so each name must lead a group line. The full
1317    /// `--help` surface is the one that must stay complete; `-h` is the
1318    /// curated progressive subset.
1319    #[test]
1320    fn after_help_lists_every_visible_subcommand() {
1321        use clap::CommandFactory;
1322
1323        for sub in Cli::command().get_subcommands() {
1324            if sub.is_hide_set() {
1325                continue;
1326            }
1327            let name = sub.get_name();
1328            let listed = TOP_LEVEL_AFTER_LONG_HELP
1329                .lines()
1330                .any(|line| line.split_whitespace().next() == Some(name));
1331            assert!(
1332                listed,
1333                "root --help command list is missing subcommand '{name}'; \
1334                 add it to a top_level_*_command_groups! section"
1335            );
1336        }
1337    }
1338
1339    /// A new visible subcommand must say whether it writes an analysis report.
1340    /// A row in `COMMAND_ENVELOPES` binds it to the schema, `report --from`,
1341    /// MCP and drift tests; the other list records why it has no row.
1342    #[test]
1343    fn every_visible_subcommand_is_classified_for_its_machine_contract() {
1344        use clap::CommandFactory;
1345        use fallow_types::command_surfaces::{
1346            COMMAND_ENVELOPES, COMMANDS_WITHOUT_ANALYSIS_ENVELOPE,
1347        };
1348
1349        let visible: Vec<String> = Cli::command()
1350            .get_subcommands()
1351            .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
1352            .map(|sub| sub.get_name().to_string())
1353            .collect();
1354        for name in &visible {
1355            let rows = COMMAND_ENVELOPES
1356                .iter()
1357                .filter(|row| row.command == name)
1358                .count();
1359            let others = COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
1360                .iter()
1361                .filter(|(command, _)| command == name)
1362                .count();
1363            assert_eq!(
1364                rows + others,
1365                1,
1366                "subcommand `{name}` must be in exactly one of COMMAND_ENVELOPES and \
1367                 COMMANDS_WITHOUT_ANALYSIS_ENVELOPE (crates/types/src/command_surfaces.rs)"
1368            );
1369        }
1370        let listed = COMMAND_ENVELOPES.iter().map(|row| row.command).chain(
1371            COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
1372                .iter()
1373                .map(|(command, _)| *command),
1374        );
1375        for command in listed {
1376            assert!(
1377                visible.iter().any(|name| name == command),
1378                "`{command}` is listed in command_surfaces.rs but is not a visible subcommand"
1379            );
1380        }
1381    }
1382
1383    /// `fallow architecture` selects the same issue types as the deprecated
1384    /// `dead-code` structure flags, so the two produce the same report.
1385    #[test]
1386    fn architecture_flags_map_to_the_dead_code_structure_filters() {
1387        use clap::Parser;
1388
1389        let structure = |argv: &[&str]| {
1390            let cli = Cli::try_parse_from(argv).expect("argv parses");
1391            check_issue_filters(&cli.command.expect("subcommand"))
1392        };
1393        let architecture =
1394            |cycles, boundaries, policy| architecture_issue_filters(cycles, boundaries, policy);
1395        assert_eq!(
1396            architecture(false, false, false),
1397            structure(&[
1398                "fallow",
1399                "dead-code",
1400                "--circular-deps",
1401                "--re-export-cycles",
1402                "--package-cycles",
1403                "--boundary-violations",
1404                "--policy-violations",
1405            ])
1406        );
1407        assert_eq!(
1408            architecture(true, false, false),
1409            structure(&[
1410                "fallow",
1411                "dead-code",
1412                "--circular-deps",
1413                "--re-export-cycles",
1414                "--package-cycles",
1415            ])
1416        );
1417        assert_eq!(
1418            architecture(false, true, false),
1419            structure(&["fallow", "dead-code", "--boundary-violations"])
1420        );
1421        assert_eq!(
1422            architecture(false, false, true),
1423            structure(&["fallow", "dead-code", "--policy-violations"])
1424        );
1425        assert!(
1426            Cli::try_parse_from([
1427                "fallow",
1428                "architecture",
1429                "--cycles",
1430                "--boundaries",
1431                "--policy"
1432            ])
1433            .is_ok()
1434        );
1435    }
1436
1437    /// `-h` is the progressive entry point: it must stay scannable (ecosystem
1438    /// norm is 40-80 lines) while leading with the task cheat sheet and
1439    /// closing with the pointer to the complete `--help` surface.
1440    #[test]
1441    fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
1442        use clap::CommandFactory;
1443
1444        let help = Cli::command().render_help().to_string();
1445        let lines = help.lines().count();
1446        assert!(
1447            lines < 90,
1448            "root -h grew to {lines} lines; keep the short surface under 90 \
1449             (curate hide_short_help and the short after-help instead)"
1450        );
1451        assert!(help.contains("When the agent is about to..."));
1452        assert!(help.contains("Run fallow --help for the complete command list."));
1453    }
1454
1455    /// The high-value and coarse admin commands each get a distinct telemetry
1456    /// workflow instead of the `Unknown` catch-all, so command families stay
1457    /// answerable without uploading raw command lines.
1458    #[test]
1459    fn high_value_commands_route_to_distinct_workflows() {
1460        use clap::Parser;
1461        use fallow_config::OutputFormat;
1462
1463        let distinct = [
1464            (vec!["fallow", "impact"], telemetry::Workflow::Impact),
1465            (vec!["fallow", "security"], telemetry::Workflow::Security),
1466            (vec!["fallow", "fix"], telemetry::Workflow::Fix),
1467            (
1468                vec!["fallow", "explain", "unused-exports"],
1469                telemetry::Workflow::Explain,
1470            ),
1471            (
1472                vec!["fallow", "watch"],
1473                telemetry::Workflow::CodeQualityReview,
1474            ),
1475            (
1476                vec!["fallow", "list"],
1477                telemetry::Workflow::ProjectInventory,
1478            ),
1479            (
1480                vec!["fallow", "workspaces"],
1481                telemetry::Workflow::ProjectInventory,
1482            ),
1483            (
1484                vec!["fallow", "schema"],
1485                telemetry::Workflow::ProjectInventory,
1486            ),
1487            (vec!["fallow", "init"], telemetry::Workflow::Setup),
1488            (
1489                vec!["fallow", "hooks", "install", "--target", "git"],
1490                telemetry::Workflow::Setup,
1491            ),
1492            (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
1493            (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
1494            (
1495                vec!["fallow", "rule-pack-schema"],
1496                telemetry::Workflow::Setup,
1497            ),
1498            (vec!["fallow", "config"], telemetry::Workflow::Setup),
1499            (
1500                vec!["fallow", "ci-template", "gitlab"],
1501                telemetry::Workflow::Setup,
1502            ),
1503            (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
1504            (
1505                vec!["fallow", "telemetry", "status"],
1506                telemetry::Workflow::Setup,
1507            ),
1508            (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
1509            (
1510                vec!["fallow", "audit-cache", "remove", "--root", "."],
1511                telemetry::Workflow::Setup,
1512            ),
1513            (
1514                vec!["fallow", "license", "status"],
1515                telemetry::Workflow::License,
1516            ),
1517            (
1518                vec!["fallow", "architecture", "--cycles"],
1519                telemetry::Workflow::Architecture,
1520            ),
1521        ];
1522        for (argv, expected) in distinct {
1523            let cli = Cli::try_parse_from(&argv).expect("argv parses");
1524            assert_eq!(
1525                telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
1526                expected,
1527                "{argv:?} should map to {expected:?}"
1528            );
1529        }
1530    }
1531
1532    /// `-v`, `-V`, and `--version` must all trigger clap's Version action so
1533    /// the version prints regardless of which spelling the user reaches for
1534    /// (issue #916). clap surfaces a Version action from `try_get_matches_from`
1535    /// as the `DisplayVersion` error kind.
1536    #[test]
1537    fn version_flag_accepts_lower_v_upper_v_and_long() {
1538        use clap::CommandFactory;
1539        for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
1540            let err = Cli::command()
1541                .try_get_matches_from(argv)
1542                .expect_err("version flag should short-circuit parsing");
1543            assert_eq!(
1544                err.kind(),
1545                clap::error::ErrorKind::DisplayVersion,
1546                "{argv:?} should trigger the Version action"
1547            );
1548        }
1549    }
1550
1551    /// Guard against deferred-work wording leaking into clap-rendered help.
1552    /// `stub`, `placeholder`, and `not yet` framings tell users the feature
1553    /// is broken or pending; they belong in tracked issues, not in `--help`.
1554    /// Walk every (sub)command and assert each rendered long-help is clean.
1555    #[test]
1556    fn cli_help_text_contains_no_implementation_status_wording() {
1557        use clap::CommandFactory;
1558        let mut root = Cli::command();
1559        let mut violations: Vec<(String, String)> = Vec::new();
1560        visit_help(&mut root, "fallow", &mut violations);
1561        assert!(
1562            violations.is_empty(),
1563            "found implementation-status wording in --help output:\n{}",
1564            violations
1565                .iter()
1566                .map(|(cmd, line)| format!("  {cmd}: {line}"))
1567                .collect::<Vec<_>>()
1568                .join("\n")
1569        );
1570    }
1571
1572    #[test]
1573    fn dependency_override_help_is_package_manager_neutral() {
1574        use clap::CommandFactory;
1575        let help = Cli::command()
1576            .find_subcommand_mut("dead-code")
1577            .expect("dead-code command")
1578            .render_long_help()
1579            .to_string();
1580
1581        assert!(help.contains("Only report unused package-manager dependency overrides"));
1582        assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
1583        assert!(!help.contains("unused pnpm dependency overrides"));
1584        assert!(!help.contains("misconfigured pnpm dependency overrides"));
1585    }
1586
1587    #[test]
1588    fn top_level_help_groups_commands_by_workflow() {
1589        use clap::CommandFactory;
1590        let help = Cli::command().render_long_help().to_string();
1591        let expected_order = [
1592            "Analysis:",
1593            "  dead-code",
1594            "  dupes",
1595            "  health",
1596            "  flags",
1597            "  security",
1598            "  audit",
1599            "Workflow:",
1600            "  watch",
1601            "  fix",
1602            "Project inspection:",
1603            "  list",
1604            "  workspaces",
1605            "  explain",
1606            "  impact",
1607            "  viz",
1608            "Setup and configuration:",
1609            "  init",
1610            "  recommend",
1611            "  migrate",
1612            "  config",
1613            "  config-schema",
1614            "  plugin-schema",
1615            "  plugin-check",
1616            "  rule-pack-schema",
1617            "Automation and CI:",
1618            "  ci",
1619            "  ci-template",
1620            "  hooks",
1621            "  setup-hooks",
1622            "Runtime coverage:",
1623            "  coverage",
1624            "  license",
1625            "Reference:",
1626            "  schema",
1627            "  help",
1628            "Options:",
1629        ];
1630        let mut cursor = 0;
1631        for needle in expected_order {
1632            let Some(offset) = help[cursor..].find(needle) else {
1633                panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
1634            };
1635            cursor += offset + needle.len();
1636        }
1637    }
1638
1639    /// Every global `--dupes-*` override flag only applies to a duplication
1640    /// run. `security`, `doctor` and `similar-code` must reject each one, and
1641    /// the security help must hide each one.
1642    #[test]
1643    fn every_global_dupes_override_is_rejected_where_dupes_does_not_run() {
1644        let longs: Vec<String> = <Cli as clap::CommandFactory>::command()
1645            .get_arguments()
1646            .filter_map(|arg| arg.get_long())
1647            .filter(|long| long.starts_with("dupes-") && *long != "dupes-baseline")
1648            .map(str::to_owned)
1649            .collect();
1650        assert!(
1651            longs.len() > 1,
1652            "expected global --dupes-* flags, got {longs:?}"
1653        );
1654        let security_help = render_security_help(SecurityHelpTarget::Parent);
1655
1656        for long in &longs {
1657            let flag = format!("--{long}");
1658            let takes_value = <Cli as clap::CommandFactory>::command()
1659                .get_arguments()
1660                .find(|arg| arg.get_long() == Some(long.as_str()))
1661                .is_some_and(|arg| arg.get_action().takes_values());
1662            let value = if long == "dupes-mode" { "weak" } else { "3" };
1663            let argv_for = |command: &[&str]| {
1664                let mut argv = vec!["fallow"];
1665                argv.extend_from_slice(command);
1666                argv.push(flag.as_str());
1667                if takes_value {
1668                    argv.push(value);
1669                }
1670                Cli::try_parse_from(argv).expect("global dupes flag parses")
1671            };
1672
1673            let security = argv_for(&["security"]);
1674            assert_eq!(unsupported_security_global(&security), Some(flag.as_str()));
1675            let doctor = argv_for(&["doctor"]);
1676            assert_eq!(unsupported_doctor_option(&doctor), Some(flag.as_str()));
1677            let similar_code = argv_for(&["similar-code", "status"]);
1678            assert_eq!(
1679                similar_code_help::unsupported_similar_code_option(&similar_code),
1680                Some(flag.as_str())
1681            );
1682            assert!(
1683                !help_contains_long_flag(&security_help, long),
1684                "security help must hide unsupported {flag}:\n{security_help}"
1685            );
1686        }
1687    }
1688
1689    #[test]
1690    fn security_help_hides_globals_rejected_by_security_validator() {
1691        let help = render_security_help(SecurityHelpTarget::Parent);
1692
1693        for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
1694            assert!(
1695                !help_contains_long_flag(&help, long),
1696                "security help must hide unsupported --{long}:\n{help}"
1697            );
1698        }
1699
1700        for long in [
1701            "root",
1702            "config",
1703            "format",
1704            "quiet",
1705            "no-cache",
1706            "threads",
1707            "changed-since",
1708            "diff-file",
1709            "diff-stdin",
1710            "workspace",
1711            "changed-workspaces",
1712            "ci",
1713            "fail-on-issues",
1714            "sarif-file",
1715            "summary",
1716            "output-file",
1717            "max-file-size",
1718            "explain",
1719            "surface",
1720        ] {
1721            assert!(
1722                help_contains_long_flag(&help, long),
1723                "security help must keep supported --{long}:\n{help}"
1724            );
1725        }
1726    }
1727
1728    #[test]
1729    fn security_help_detection_covers_subcommand_and_help_alias_forms() {
1730        assert_eq!(
1731            security_help_target(["security", "--help"]),
1732            Some(SecurityHelpTarget::Parent)
1733        );
1734        assert_eq!(
1735            security_help_target(["security", "-h"]),
1736            Some(SecurityHelpTarget::Parent)
1737        );
1738        assert_eq!(
1739            security_help_target(["--format", "json", "security", "--help"]),
1740            Some(SecurityHelpTarget::Parent)
1741        );
1742        assert_eq!(
1743            security_help_target(["help", "security"]),
1744            Some(SecurityHelpTarget::Parent)
1745        );
1746        assert_eq!(
1747            security_help_target(["security", "survivors", "--help"]),
1748            Some(SecurityHelpTarget::Survivors)
1749        );
1750        assert_eq!(
1751            security_help_target(["security", "survivors", "-h"]),
1752            Some(SecurityHelpTarget::Survivors)
1753        );
1754        assert_eq!(
1755            security_help_target(["help", "security", "survivors"]),
1756            Some(SecurityHelpTarget::Survivors)
1757        );
1758        assert_eq!(
1759            security_help_target(["security", "blind-spots", "--help"]),
1760            Some(SecurityHelpTarget::BlindSpots)
1761        );
1762        assert_eq!(
1763            security_help_target(["help", "security", "blind-spots"]),
1764            Some(SecurityHelpTarget::BlindSpots)
1765        );
1766        assert_eq!(security_help_target(["health", "--help"]), None);
1767        assert_eq!(security_help_target(["help", "health"]), None);
1768    }
1769
1770    #[test]
1771    fn security_unsupported_global_validator_matches_hidden_help_contract() {
1772        for (argv, expected) in [
1773            (vec!["fallow", "security", "--performance"], "--performance"),
1774            (
1775                vec!["fallow", "security", "--no-package-baselines"],
1776                "--no-package-baselines",
1777            ),
1778            (
1779                vec!["fallow", "security", "--baseline", "base.json"],
1780                "--baseline",
1781            ),
1782            (
1783                vec!["fallow", "security", "--fail-on-stale-baseline"],
1784                "--fail-on-stale-baseline",
1785            ),
1786            (
1787                vec!["fallow", "security", "--dupes-mode", "weak"],
1788                "--dupes-mode",
1789            ),
1790            (vec!["fallow", "security", "--dupes-near"], "--dupes-near"),
1791        ] {
1792            let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
1793            assert_eq!(unsupported_security_global(&cli), Some(expected));
1794        }
1795
1796        let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
1797            .expect("security --explain parses");
1798        assert_eq!(unsupported_security_global(&explain), None);
1799    }
1800
1801    #[test]
1802    fn programmatic_common_options_track_analysis_affecting_cli_globals() {
1803        use clap::CommandFactory;
1804
1805        let cli_flags: std::collections::BTreeSet<String> = Cli::command()
1806            .get_arguments()
1807            .filter(|arg| arg.is_global_set())
1808            .filter_map(|arg| arg.get_long().map(str::to_owned))
1809            .filter(|name| {
1810                matches!(
1811                    name.as_str(),
1812                    "root"
1813                        | "config"
1814                        | "allow-remote-extends"
1815                        | "no-cache"
1816                        | "threads"
1817                        | "changed-since"
1818                        | "diff-file"
1819                        | "production"
1820                        | "workspace"
1821                        | "changed-workspaces"
1822                        | "explain"
1823                )
1824            })
1825            .collect();
1826        let programmatic_flags: std::collections::BTreeSet<String> =
1827            fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
1828                .iter()
1829                .map(|flag| (*flag).to_owned())
1830                .collect();
1831
1832        assert_eq!(programmatic_flags, cli_flags);
1833    }
1834
1835    #[test]
1836    fn dead_code_registry_filter_flags_are_exposed_by_clap() {
1837        use clap::CommandFactory;
1838
1839        let cli = Cli::command();
1840        let dead_code = cli
1841            .get_subcommands()
1842            .find(|command| command.get_name() == "dead-code")
1843            .expect("dead-code subcommand is registered");
1844        let cli_flags: std::collections::BTreeSet<String> = dead_code
1845            .get_arguments()
1846            .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
1847            .collect();
1848
1849        for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
1850            assert!(
1851                cli_flags.contains(*flag),
1852                "registry filter flag {flag} is missing from dead-code clap args"
1853            );
1854        }
1855    }
1856
1857    fn help_contains_long_flag(help: &str, long: &str) -> bool {
1858        let flag = format!("--{long}");
1859        help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
1860            .any(|token| token == flag)
1861    }
1862
1863    fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
1864        let help = cmd.render_long_help().to_string();
1865        for line in scan_forbidden(&help) {
1866            violations.push((path.to_owned(), line));
1867        }
1868        let names: Vec<String> = cmd
1869            .get_subcommands()
1870            .map(|sub| sub.get_name().to_owned())
1871            .collect();
1872        for name in names {
1873            if name == "help" {
1874                continue;
1875            }
1876            if let Some(sub) = cmd.find_subcommand_mut(&name) {
1877                let sub_path = format!("{path} {name}");
1878                visit_help(sub, &sub_path, violations);
1879            }
1880        }
1881    }
1882
1883    fn scan_forbidden(s: &str) -> Vec<String> {
1884        let lower = s.to_ascii_lowercase();
1885        let mut out = Vec::new();
1886        for word in ["stub", "placeholder"] {
1887            if let Some(idx) = find_whole_word(&lower, word) {
1888                out.push(extract_line(s, idx));
1889            }
1890        }
1891        if let Some(idx) = lower.find("not yet") {
1892            out.push(extract_line(s, idx));
1893        }
1894        out
1895    }
1896
1897    fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
1898        let bytes = haystack.as_bytes();
1899        let mut start = 0;
1900        while let Some(rel) = haystack[start..].find(word) {
1901            let abs = start + rel;
1902            let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
1903            let after_idx = abs + word.len();
1904            let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
1905            if before_ok && after_ok {
1906                return Some(abs);
1907            }
1908            start = abs + word.len();
1909        }
1910        None
1911    }
1912
1913    fn extract_line(s: &str, byte_idx: usize) -> String {
1914        let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
1915        let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
1916        s[line_start..line_end].trim().to_owned()
1917    }
1918
1919    #[test]
1920    fn emit_error_returns_given_exit_code() {
1921        let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
1922        assert_eq!(code, ExitCode::from(2));
1923    }
1924
1925    fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
1926        TelemetryRun {
1927            workflow: telemetry::Workflow::Health,
1928            output: fallow_config::OutputFormat::Json,
1929            quiet: true,
1930            start: std::time::Instant::now(),
1931            context: telemetry::WorkflowContext {
1932                run_scope: telemetry::RunScope::FullProject,
1933                config_shape: telemetry::ConfigShape::Default,
1934                output_destination: telemetry::OutputDestination::Stdout,
1935                analysis_mode: mode,
1936            },
1937        }
1938    }
1939
1940    #[test]
1941    fn fallback_failure_reason_skips_success_and_findings() {
1942        let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
1943
1944        assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
1945        assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
1946    }
1947
1948    #[test]
1949    fn fallback_failure_reason_classifies_network_auth_and_analysis() {
1950        let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
1951        let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
1952
1953        assert_eq!(
1954            fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
1955            Some(telemetry::FailureReason::Network),
1956        );
1957        assert_eq!(
1958            fallback_failure_reason_for(&static_run, ExitCode::from(12)),
1959            Some(telemetry::FailureReason::Auth),
1960        );
1961        assert_eq!(
1962            fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
1963            Some(telemetry::FailureReason::Auth),
1964        );
1965        assert_eq!(
1966            fallback_failure_reason_for(&static_run, ExitCode::from(2)),
1967            Some(telemetry::FailureReason::Analysis),
1968        );
1969    }
1970
1971    #[test]
1972    fn bare_coverage_flags_parse_without_subcommand() {
1973        let cli = Cli::try_parse_from([
1974            "fallow",
1975            "--coverage",
1976            "coverage/coverage-final.json",
1977            "--coverage-root",
1978            "/ci/workspace",
1979        ])
1980        .expect("bare combined coverage flags should parse");
1981        assert!(cli.command.is_none());
1982        assert_eq!(
1983            cli.coverage.as_deref(),
1984            Some(std::path::Path::new("coverage/coverage-final.json"))
1985        );
1986        assert_eq!(
1987            cli.coverage_root.as_deref(),
1988            Some(std::path::Path::new("/ci/workspace"))
1989        );
1990    }
1991
1992    #[test]
1993    fn bare_coverage_before_subcommand_is_detectable() {
1994        let cli = Cli::try_parse_from([
1995            "fallow",
1996            "--coverage",
1997            "coverage/coverage-final.json",
1998            "dead-code",
1999        ])
2000        .expect("clap should parse pre-subcommand bare coverage for custom rejection");
2001        assert!(cli.command.is_some());
2002        assert!(cli_has_bare_coverage_input(&cli));
2003        let message = bare_coverage_subcommand_error_message();
2004        assert!(message.contains("bare combined-mode flags"));
2005        assert!(message.contains("fallow health --coverage <coverage-final.json>"));
2006    }
2007
2008    #[test]
2009    fn bare_combined_baseline_before_subcommand_is_detectable() {
2010        for flag in ["--dupes-baseline", "--health-baseline"] {
2011            let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
2012                .expect("clap should parse a pre-subcommand combined baseline");
2013            assert!(cli.command.is_some());
2014            assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
2015            let message = bare_combined_baseline_subcommand_error_message(flag);
2016            assert!(message.contains(flag));
2017            assert!(message.contains("omit the subcommand"));
2018        }
2019        let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
2020            .expect("bare combined baseline should parse");
2021        assert!(bare.command.is_none());
2022    }
2023
2024    #[test]
2025    fn subcommand_coverage_flag_keeps_regular_clap_error() {
2026        let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
2027            panic!("dead-code --coverage should fail to parse");
2028        };
2029        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
2030    }
2031
2032    #[test]
2033    fn type_aware_flags_parse_for_semantic_analysis() {
2034        let cli = Cli::try_parse_from([
2035            "fallow",
2036            "dead-code",
2037            "--unused-class-members",
2038            "--type-aware",
2039            "--type-aware-project",
2040            "tsconfig.json",
2041            "--type-aware-project",
2042            "packages/web/tsconfig.json",
2043        ])
2044        .expect("type-aware flag should parse");
2045        assert!(cli.type_aware);
2046        assert_eq!(
2047            cli.type_aware_project,
2048            [
2049                PathBuf::from("tsconfig.json"),
2050                PathBuf::from("packages/web/tsconfig.json")
2051            ]
2052        );
2053        let Some(Command::Check {
2054            unused_class_members,
2055            ..
2056        }) = cli.command
2057        else {
2058            panic!("dead-code should parse as the check command");
2059        };
2060        assert!(unused_class_members);
2061    }
2062
2063    #[test]
2064    fn no_type_aware_conflicts_with_type_aware() {
2065        let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
2066        else {
2067            panic!("--no-type-aware must conflict with --type-aware");
2068        };
2069        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
2070    }
2071
2072    #[test]
2073    fn no_type_aware_forces_semantic_analysis_off() {
2074        let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
2075            .expect("--no-type-aware should parse on audit");
2076        assert_eq!(cli.type_aware_override(), Some(false));
2077
2078        let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
2079            .expect("--type-aware should parse");
2080        assert_eq!(cli.type_aware_override(), Some(true));
2081
2082        let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
2083        assert_eq!(cli.type_aware_override(), None);
2084    }
2085
2086    #[test]
2087    fn type_aware_status_output_hides_host_paths() {
2088        let root = Path::new("/private/work/project");
2089        let output = type_aware_status_output(
2090            root,
2091            fallow_api::TypeAwareStatus {
2092                available: false,
2093                discovery_source: Some("environment-override"),
2094                companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
2095                package_version: None,
2096                protocol_version: 7,
2097                backend_family: None,
2098                backend_version: None,
2099                remediation: Some(
2100                    "failed to launch /private/tools/fallow-type-aware from /private/work/project"
2101                        .to_string(),
2102                ),
2103            },
2104        );
2105
2106        assert_eq!(
2107            output.schema_version.0,
2108            fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
2109        );
2110        assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
2111        let remediation = output.remediation.expect("remediation");
2112        assert!(!remediation.contains("/private/"));
2113        assert!(remediation.contains("fallow-type-aware"));
2114    }
2115
2116    #[test]
2117    fn format_parsing_covers_all_variants() {
2118        assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
2119        assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
2120        assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
2121        assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
2122        assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
2123        assert!(matches!(
2124            parse_format_arg("markdown"),
2125            Some(Format::Markdown)
2126        ));
2127        assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
2128        assert!(matches!(
2129            parse_format_arg("codeclimate"),
2130            Some(Format::CodeClimate)
2131        ));
2132        assert!(matches!(
2133            parse_format_arg("gitlab-codequality"),
2134            Some(Format::CodeClimate)
2135        ));
2136        assert!(matches!(
2137            parse_format_arg("gitlab-code-quality"),
2138            Some(Format::CodeClimate)
2139        ));
2140        assert!(matches!(
2141            parse_format_arg("pr-comment-github"),
2142            Some(Format::PrCommentGithub)
2143        ));
2144        assert!(matches!(
2145            parse_format_arg("pr-comment-gitlab"),
2146            Some(Format::PrCommentGitlab)
2147        ));
2148        assert!(matches!(
2149            parse_format_arg("review-github"),
2150            Some(Format::ReviewGithub)
2151        ));
2152        assert!(matches!(
2153            parse_format_arg("review-gitlab"),
2154            Some(Format::ReviewGitlab)
2155        ));
2156        assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
2157        assert!(parse_format_arg("xml").is_none());
2158        assert!(parse_format_arg("").is_none());
2159    }
2160
2161    #[test]
2162    fn quiet_parsing_logic() {
2163        let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
2164        assert!(parse("1"));
2165        assert!(parse("true"));
2166        assert!(parse("TRUE"));
2167        assert!(parse("True"));
2168        assert!(!parse("0"));
2169        assert!(!parse("false"));
2170        assert!(!parse("yes"));
2171    }
2172
2173    #[test]
2174    fn tracing_filter_defaults_to_warn_without_env() {
2175        let filter = build_tracing_filter(None);
2176        assert!(filter.would_enable("fallow_core", &tracing::Level::WARN));
2177        assert!(!filter.would_enable("fallow_core", &tracing::Level::INFO));
2178    }
2179
2180    #[test]
2181    fn tracing_filter_respects_explicit_env_directives() {
2182        let filter = build_tracing_filter(Some("info"));
2183        assert!(filter.would_enable("fallow_core", &tracing::Level::INFO));
2184        assert!(!filter.would_enable("fallow_core", &tracing::Level::DEBUG));
2185
2186        let filter = build_tracing_filter(Some("fallow=debug"));
2187        assert!(filter.would_enable("fallow_core::lib", &tracing::Level::DEBUG));
2188        assert!(!filter.would_enable("oxc_resolver", &tracing::Level::ERROR));
2189    }
2190
2191    #[test]
2192    fn tracing_filter_skips_a_directive_that_it_cannot_read() {
2193        let filter = build_tracing_filter(Some("fallow[span{id=1}]=trace,fallow_graph=debug,warn"));
2194        assert!(filter.would_enable("fallow_graph", &tracing::Level::DEBUG));
2195        assert!(filter.would_enable("oxc_resolver", &tracing::Level::WARN));
2196        assert!(!filter.would_enable("fallow_core", &tracing::Level::TRACE));
2197    }
2198
2199    #[test]
2200    fn tracing_filter_treats_empty_env_as_off() {
2201        for value in ["", "   "] {
2202            let filter = build_tracing_filter(Some(value));
2203            assert!(!filter.would_enable("fallow_core", &tracing::Level::ERROR));
2204        }
2205    }
2206}