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