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;
29pub 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};
123pub(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
152fn 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#[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 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
386fn 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
419fn 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 if !quiet && report::sink::wrote() {
437 eprintln!("Report written to {}", path.display());
438 }
439 Ok(())
440}
441
442pub 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 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#[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#[doc(hidden)]
591pub use audit::AuditReviewBenchmarkCorpus;
592
593#[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#[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#[doc(hidden)]
625pub use audit::AuditAnalysesBenchmarkResult;
626
627#[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#[doc(hidden)]
649pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
650 inspect::create_inspect_benchmark_corpus(root, threads)
651}
652
653#[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#[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#[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#[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#[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#[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#[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#[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#[doc(hidden)]
769pub use watch::WatchFilterBenchmarkGlobalGitignore;
770
771#[doc(hidden)]
774pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
775 watch::create_benchmark_global_gitignore()
776}
777
778#[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#[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#[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#[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#[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
847fn 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
860fn 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 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
924fn 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
968fn 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 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#[derive(Clone, Copy)]
1108struct BareAnalyses {
1109 run_check: bool,
1110 run_dupes: bool,
1111 run_health: bool,
1112}
1113
1114fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}