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::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31pub use base_worktree::canonical_root_hash;
35mod walkthrough_state;
36use fallow_engine::baseline;
37mod agent_install;
38mod cache_notice;
39mod check;
40mod ci;
41mod ci_template;
42mod cli_agent;
43mod cli_format;
44mod cli_hooks;
45mod cli_impact;
46mod cli_production;
47mod cli_report;
48mod cli_startup;
49pub use fallow_engine::codeowners;
50mod combined;
51mod config;
52mod coverage;
53mod doctor;
54mod dupes;
55mod exit_codes;
56pub mod explain;
57mod fix;
58mod flags;
59mod guard;
60mod health;
61mod impact;
62mod init;
63mod inspect;
64mod json_style;
65mod license;
66mod list;
67mod migrate;
68mod onboarding;
69#[cfg(test)]
70mod output_envelope;
71mod output_runtime;
72mod path_util;
73mod plugin_check;
74mod rayon_pool;
75mod regression;
76pub mod report;
77mod rule_pack;
78mod runtime_support;
79mod schema;
80mod security;
81mod security_help;
82mod selector;
83mod setup_hooks;
84mod signal;
85mod similar_code_cli;
86mod similar_code_help;
87mod suppressions;
88mod task_matrix;
89mod telemetry;
90mod trace_chain;
91mod trace_error;
92mod trace_path;
93mod type_aware_degrade;
94mod update_check;
95use fallow_engine::validate;
96use fallow_engine::vital_signs;
97mod cli_telemetry;
98mod viz;
99mod watch;
100
101use check::{CheckOptions, IssueFilters, TraceOptions};
102pub(crate) mod error;
104use cli_agent::{AgentCli, run_agent_command};
105#[cfg(test)]
106use cli_format::parse_format_arg;
107use cli_format::{Format, FormatConfig};
108use cli_hooks::{HooksCli, run_hooks_command};
109use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
110use cli_production::{ProductionModes, resolve_production_modes};
111#[cfg(test)]
112use cli_startup::build_tracing_filter;
113use cli_startup::{
114 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
115 run_pre_dispatch_checks, setup_tracing, validate_inputs,
116};
117#[cfg(test)]
118use cli_telemetry::TelemetryRun;
119#[cfg(test)]
120use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
121use cli_telemetry::{record_run_epilogue, start_telemetry_run};
122use dupes::{DupesMode, DupesOptions};
123use error::emit_error;
124use health::{HealthOptions, SortBy};
125use list::ListOptions;
126pub(crate) use runtime_support::{AnalysisKind, GroupBy};
127pub(crate) use runtime_support::{
128 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
129 load_config_for_analysis,
130};
131#[cfg(test)]
132use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
133use security_help::{render_security_help, security_help_target};
134use similar_code_help::{render_similar_code_help, similar_code_help_target};
135
136const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
137
138const TOP_LEVEL_HELP_TEMPLATE: &str =
139 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
140
141macro_rules! top_level_task_cheat_sheet {
144 () => {
145 "\
146When the agent is about to...
147 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
148 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
149 find how one module reaches another fallow trace --path <from> <to>
150 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
151 commit or open a PR fallow audit --base <ref>
152 read a diff before approving it fallow review --base <ref> --brief
153 prioritize refactoring fallow health --hotspots --targets
154 ask who owns code fallow health --ownership
155 check untested-but-reachable code fallow health --coverage-gaps
156 consolidate duplication fallow dupes --trace dup:<fingerprint>
157 find feature flags fallow flags
158 check architecture rules before editing fallow guard <files>
159 surface security candidates fallow security
160 inspect a target before editing fallow inspect --file <path>
161 understand a finding fallow explain <issue-type>
162 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
163 };
164}
165
166macro_rules! top_level_core_command_groups {
167 () => {
168 "\
169Analysis:
170 dead-code Analyze unused code, dependency hygiene, and architecture cycles
171 dupes Find copy-paste and structural code duplication
172 health Analyze complexity, maintainability, hotspots, and coverage gaps
173 flags Detect feature flag usage patterns
174 security Surface local security candidates for agent verification (opt-in)
175 similar-code Find semantic implementation overlap for verification (opt-in, local)
176 audit Review changed files for dead code, complexity, duplication, and styling
177
178Workflow:
179 watch Re-run analysis as files change
180 fix Auto-fix safe unused-code findings"
181 };
182}
183
184macro_rules! top_level_extended_command_groups {
185 () => {
186 "\
187Project inspection:
188 list List discovered files, entry points, plugins, boundaries, and workspaces
189 inspect Inspect one file or exported symbol as a bundled evidence query
190 trace Trace a symbol's call chain (best-effort, syntactic)
191 trace-error Resolve a runtime stack trace's frames to project definitions
192 guard Show which architecture rules apply to files before editing
193 decision-surface Surface the structural decisions a change embeds (advisory)
194 workspaces Show monorepo workspace discovery diagnostics
195 explain Explain one issue type without running analysis
196 suppressions List active fallow-ignore suppression markers
197 impact Show what fallow has done for you (opt-in, local-only)
198 viz Generate an interactive HTML map of the codebase
199
200Setup and configuration:
201 doctor Diagnose project readiness without changing anything
202 init Create a fallow config, optionally with a Git hook
203 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
204 audit-cache Maintain reusable audit base-snapshot caches
205 recommend Recommend a project-tailored config for an agent to author
206 migrate Migrate knip, jscpd, or stylelint config to fallow
207 config Show the resolved config and loaded config file
208 config-schema Print the fallow config JSON Schema
209 plugin-schema Print the external plugin JSON Schema
210 plugin-check Dry-run external plugins and report what they seed
211 rule-pack Manage declarative rule packs (policy-as-code)
212 rule-pack-schema Print the rule pack JSON Schema
213 type-aware Inspect the optional TypeScript semantic companion
214
215Automation and CI:
216 ci Build PR/MR feedback envelopes
217 ci-template Print or vendor CI integration templates
218 report Re-render saved JSON as GitHub or CodeClimate output
219 hooks Install or remove fallow-managed Git and agent hooks
220 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
221
222Runtime coverage:
223 coverage Set up or analyze runtime coverage data
224 license Manage the paid-feature license
225 telemetry Manage opt-in product telemetry
226
227Reference:
228 schema Dump the CLI interface as machine-readable JSON
229 help Print this message or the help of a command"
230 };
231}
232
233const TOP_LEVEL_AFTER_HELP: &str = concat!(
234 top_level_task_cheat_sheet!(),
235 "\n\n",
236 top_level_core_command_groups!(),
237 "\n\nRun fallow --help for the complete command list."
238);
239
240const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
241 top_level_task_cheat_sheet!(),
242 "\n\n",
243 top_level_core_command_groups!(),
244 "\n\n",
245 top_level_extended_command_groups!(),
246 "\n\n",
247 "When no command is given, fallow runs dead-code + dupes + health together.\n",
248 "Use --only/--skip to select specific analyses."
249);
250
251#[derive(Parser)]
252#[command(
253 name = "fallow",
254 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
255 version,
256 disable_version_flag = true,
257 help_template = TOP_LEVEL_HELP_TEMPLATE,
258 after_help = TOP_LEVEL_AFTER_HELP,
259 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
260)]
261struct Cli {
262 #[command(subcommand)]
263 command: Option<Command>,
264
265 #[arg(
269 short = 'v',
270 visible_short_alias = 'V',
271 long = "version",
272 action = clap::ArgAction::Version
273 )]
274 version: Option<bool>,
275
276 #[arg(short, long, global = true)]
278 root: Option<PathBuf>,
279
280 #[arg(short, long, global = true)]
282 config: Option<PathBuf>,
283
284 #[arg(hide_short_help = true, long, global = true)]
286 allow_remote_extends: bool,
287
288 #[arg(
290 short,
291 long,
292 visible_alias = "output",
293 global = true,
294 default_value = "human"
295 )]
296 format: Format,
297
298 #[arg(hide_short_help = true, long, global = true)]
300 pretty: bool,
301
302 #[arg(short, long, global = true)]
304 quiet: bool,
305
306 #[arg(hide_short_help = true, long, global = true)]
308 no_cache: bool,
309
310 #[arg(hide_short_help = true, long, global = true)]
312 threads: Option<usize>,
313
314 #[arg(long, visible_alias = "base", global = true)]
316 changed_since: Option<String>,
317
318 #[arg(
323 hide_short_help = true,
324 long = "diff-file",
325 value_name = "PATH",
326 global = true
327 )]
328 diff_file: Option<PathBuf>,
329
330 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
333 diff_stdin: bool,
334
335 #[arg(
342 hide_short_help = true,
343 long = "churn-file",
344 value_name = "PATH",
345 global = true
346 )]
347 churn_file: Option<PathBuf>,
348
349 #[arg(
356 hide_short_help = true,
357 long = "max-file-size",
358 value_name = "MB",
359 global = true
360 )]
361 max_file_size: Option<u32>,
362
363 #[arg(hide_short_help = true, long, global = true)]
365 baseline: Option<PathBuf>,
366
367 #[arg(
382 hide_short_help = true,
383 long = "baseline-mode",
384 value_enum,
385 global = true
386 )]
387 baseline_mode: Option<BaselineModeArg>,
388
389 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
395 parent_run: Option<String>,
396
397 #[arg(hide_short_help = true, long, global = true)]
399 save_baseline: Option<PathBuf>,
400
401 #[arg(long, global = true)]
404 production: bool,
405
406 #[arg(
410 hide_short_help = true,
411 long = "no-production",
412 global = true,
413 conflicts_with = "production"
414 )]
415 no_production: bool,
416
417 #[arg(hide_short_help = true, long = "production-dead-code")]
419 production_dead_code: bool,
420
421 #[arg(hide_short_help = true, long = "production-health")]
423 production_health: bool,
424
425 #[arg(hide_short_help = true, long = "production-dupes")]
427 production_dupes: bool,
428
429 #[arg(short, long, global = true, value_delimiter = ',')]
433 workspace: Option<Vec<String>>,
434
435 #[arg(long, global = true, value_name = "REF")]
438 changed_workspaces: Option<String>,
439
440 #[arg(hide_short_help = true, long, global = true)]
442 group_by: Option<GroupBy>,
443
444 #[arg(hide_short_help = true, long, global = true)]
446 performance: bool,
447
448 #[arg(hide_short_help = true, long, global = true)]
450 explain: bool,
451
452 #[arg(hide_short_help = true, long, global = true)]
454 explain_skipped: bool,
455
456 #[arg(hide_short_help = true, long, global = true)]
458 summary: bool,
459
460 #[arg(long, global = true)]
462 ci: bool,
463
464 #[arg(hide_short_help = true, long, global = true)]
466 fail_on_issues: bool,
467
468 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
470 sarif_file: Option<PathBuf>,
471
472 #[arg(short = 'o', long, global = true, value_name = "PATH")]
476 output_file: Option<PathBuf>,
477
478 #[arg(
488 hide_short_help = true,
489 long = "report-path-prefix",
490 visible_alias = "annotations-path-prefix",
491 global = true,
492 value_name = "PREFIX"
493 )]
494 report_path_prefix: Option<String>,
495
496 #[arg(hide_short_help = true, long, global = true)]
498 fail_on_regression: bool,
499
500 #[arg(
502 hide_short_help = true,
503 long,
504 global = true,
505 value_name = "TOLERANCE",
506 default_value = "0"
507 )]
508 tolerance: String,
509
510 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
512 regression_baseline: Option<PathBuf>,
513
514 #[expect(
518 clippy::option_option,
519 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
520 )]
521 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
522 save_regression_baseline: Option<Option<String>>,
523
524 #[arg(long, value_delimiter = ',')]
526 only: Vec<AnalysisKind>,
527
528 #[arg(long, value_delimiter = ',')]
530 skip: Vec<AnalysisKind>,
531
532 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
534 dupes_mode: Option<DupesMode>,
535
536 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
538 dupes_near: bool,
539
540 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
542 dupes_threshold: Option<f64>,
543
544 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
546 dupes_min_tokens: Option<usize>,
547
548 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
550 dupes_min_lines: Option<usize>,
551
552 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
554 dupes_min_occurrences: Option<usize>,
555
556 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
558 dupes_skip_local: bool,
559
560 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
562 dupes_cross_language: bool,
563
564 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
567 dupes_ignore_imports: bool,
568
569 #[arg(
572 hide_short_help = true,
573 long = "dupes-no-ignore-imports",
574 global = true,
575 conflicts_with = "dupes_ignore_imports"
576 )]
577 dupes_no_ignore_imports: bool,
578
579 #[arg(hide_short_help = true, long)]
581 score: bool,
582
583 #[arg(hide_short_help = true, long)]
585 trend: bool,
586
587 #[expect(
590 clippy::option_option,
591 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
592 )]
593 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
594 save_snapshot: Option<Option<String>>,
595
596 #[arg(hide_short_help = true, long, value_name = "PATH")]
599 coverage: Option<PathBuf>,
600
601 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
604 coverage_root: Option<PathBuf>,
605
606 #[arg(hide_short_help = true, long, global = true)]
608 include_entry_exports: bool,
609
610 #[arg(hide_short_help = true, long, global = true)]
613 type_aware: bool,
614
615 #[arg(
618 hide_short_help = true,
619 long,
620 global = true,
621 conflicts_with = "type_aware"
622 )]
623 no_type_aware: bool,
624
625 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
627 type_aware_project: Vec<PathBuf>,
628
629 #[arg(hide_short_help = true, long, global = true, value_enum)]
631 type_aware_require: Option<TypeAwareRequireArg>,
632}
633
634impl Cli {
635 const fn type_aware_override(&self) -> Option<bool> {
639 if self.no_type_aware {
640 Some(false)
641 } else if self.type_aware {
642 Some(true)
643 } else {
644 None
645 }
646 }
647}
648
649#[derive(Clone, Copy, Subcommand)]
650enum TypeAwareCli {
651 Status,
653}
654
655#[derive(Subcommand)]
656enum Command {
657 #[command(name = "dead-code", alias = "check")]
659 Check {
660 #[arg(long)]
662 unused_files: bool,
663
664 #[arg(long)]
666 unused_exports: bool,
667
668 #[arg(long)]
670 unused_deps: bool,
671
672 #[arg(long)]
674 unused_types: bool,
675
676 #[arg(long)]
678 private_type_leaks: bool,
679
680 #[arg(long)]
682 unused_enum_members: bool,
683
684 #[arg(long)]
686 unused_class_members: bool,
687
688 #[arg(long)]
690 unused_store_members: bool,
691
692 #[arg(long)]
694 unprovided_injects: bool,
695
696 #[arg(long)]
698 unrendered_components: bool,
699
700 #[arg(long)]
702 unused_component_props: bool,
703
704 #[arg(long)]
706 unused_component_emits: bool,
707
708 #[arg(long)]
710 unused_component_inputs: bool,
711
712 #[arg(long)]
714 unused_component_outputs: bool,
715
716 #[arg(long)]
718 unused_svelte_events: bool,
719
720 #[arg(long)]
722 unused_server_actions: bool,
723
724 #[arg(long)]
726 unused_load_data_keys: bool,
727
728 #[arg(long)]
730 unresolved_imports: bool,
731
732 #[arg(long)]
734 unlisted_deps: bool,
735
736 #[arg(long)]
738 duplicate_exports: bool,
739
740 #[arg(long)]
742 circular_deps: bool,
743
744 #[arg(long)]
746 re_export_cycles: bool,
747
748 #[arg(long)]
750 boundary_violations: bool,
751
752 #[arg(long)]
754 policy_violations: bool,
755
756 #[arg(long)]
758 stale_suppressions: bool,
759
760 #[arg(long)]
762 unused_catalog_entries: bool,
763
764 #[arg(long)]
766 empty_catalog_groups: bool,
767
768 #[arg(long)]
770 unresolved_catalog_references: bool,
771
772 #[arg(long)]
774 unused_dependency_overrides: bool,
775
776 #[arg(long)]
778 misconfigured_dependency_overrides: bool,
779
780 #[arg(long)]
782 include_dupes: bool,
783
784 #[arg(long, value_name = "FILE:EXPORT")]
786 trace: Option<String>,
787
788 #[arg(long, value_name = "PATH")]
790 trace_file: Option<String>,
791
792 #[arg(long, value_name = "PACKAGE")]
794 trace_dependency: Option<String>,
795
796 #[arg(long, value_name = "PATH")]
800 impact_closure: Option<String>,
801
802 #[arg(long, value_name = "FILE:EXPORT")]
804 symbol_impact: Option<String>,
805
806 #[arg(long)]
811 top: Option<usize>,
812
813 #[arg(long, value_name = "PATH")]
817 file: Vec<std::path::PathBuf>,
818 },
819
820 Watch {
822 #[arg(long)]
824 no_clear: bool,
825 },
826
827 TypeAware {
829 #[command(subcommand)]
830 subcommand: TypeAwareCli,
831 },
832
833 #[command(override_help = doctor::HELP)]
840 Doctor,
841
842 SimilarCode {
848 #[command(subcommand)]
849 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
850 #[arg(long, value_name = "0..1")]
852 threshold: Option<f64>,
853 #[arg(long, value_name = "N")]
855 min_lines: Option<usize>,
856 #[arg(long, value_name = "N")]
858 top: Option<usize>,
859 #[arg(long, value_name = "PATH")]
861 file: Vec<PathBuf>,
862 },
863
864 Inspect {
866 #[arg(
868 long,
869 value_name = "PATH",
870 conflicts_with = "symbol",
871 required_unless_present = "symbol"
872 )]
873 file: Option<String>,
874
875 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
877 symbol: Option<String>,
878
879 #[arg(long)]
884 symbol_chain: bool,
885
886 #[arg(long)]
889 churn: bool,
890 },
891
892 Trace {
907 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
910 symbol: Option<String>,
911
912 #[arg(
916 long,
917 num_args = 2,
918 value_names = ["FROM", "TO"],
919 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
920 )]
921 path: Vec<String>,
922
923 #[arg(long)]
926 callers: bool,
927
928 #[arg(long)]
931 callees: bool,
932
933 #[arg(long, value_name = "N")]
936 depth: Option<u32>,
937 },
938
939 #[command(name = "trace-error")]
958 TraceError {
959 #[arg(value_name = "FILE")]
963 trace_file: Option<String>,
964 },
965
966 Fix {
981 #[arg(long)]
983 dry_run: bool,
984
985 #[arg(long, alias = "force")]
987 yes: bool,
988
989 #[arg(long)]
996 no_create_config: bool,
997 },
998
999 Init {
1008 #[arg(long)]
1010 toml: bool,
1011
1012 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1014 agents: bool,
1015
1016 #[arg(long)]
1020 hooks: bool,
1021
1022 #[arg(long, requires = "hooks")]
1024 branch: Option<String>,
1025
1026 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1030 decline: bool,
1031 },
1032
1033 Hooks {
1040 #[command(subcommand)]
1041 subcommand: HooksCli,
1042 },
1043
1044 Agent {
1050 #[command(subcommand)]
1051 subcommand: AgentCli,
1052 },
1053
1054 Ci {
1056 #[command(subcommand)]
1057 subcommand: CiCli,
1058 },
1059
1060 ConfigSchema,
1062
1063 PluginSchema,
1065
1066 PluginCheck,
1068
1069 RulePackSchema,
1071
1072 RulePack {
1074 #[command(subcommand)]
1075 subcommand: RulePackCli,
1076 },
1077
1078 Guard {
1080 #[arg(required = true, num_args = 1..)]
1082 files: Vec<String>,
1083 },
1084
1085 Config {
1103 #[arg(long)]
1105 path: bool,
1106 },
1107
1108 Recommend,
1116
1117 List {
1119 #[arg(long)]
1121 entry_points: bool,
1122
1123 #[arg(long)]
1125 files: bool,
1126
1127 #[arg(long)]
1129 plugins: bool,
1130
1131 #[arg(long)]
1133 boundaries: bool,
1134
1135 #[arg(long)]
1139 workspaces: bool,
1140 },
1141
1142 Workspaces,
1148
1149 Dupes {
1151 #[arg(long)]
1154 mode: Option<DupesMode>,
1155
1156 #[arg(long)]
1158 near: bool,
1159
1160 #[arg(long)]
1163 min_tokens: Option<usize>,
1164
1165 #[arg(long)]
1168 min_lines: Option<usize>,
1169
1170 #[arg(long, value_parser = parse_min_occurrences)]
1175 min_occurrences: Option<usize>,
1176
1177 #[arg(long)]
1180 threshold: Option<f64>,
1181
1182 #[arg(long)]
1184 skip_local: bool,
1185
1186 #[arg(long)]
1188 cross_language: bool,
1189
1190 #[arg(long)]
1194 ignore_imports: bool,
1195
1196 #[arg(long, conflicts_with = "ignore_imports")]
1199 no_ignore_imports: bool,
1200
1201 #[arg(long)]
1211 top: Option<usize>,
1212
1213 #[arg(long)]
1217 no_fragments: bool,
1218
1219 #[arg(long, value_name = "FILE:LINE")]
1221 trace: Option<String>,
1222 },
1223
1224 Health {
1230 #[arg(long)]
1232 max_cyclomatic: Option<u16>,
1233
1234 #[arg(long)]
1236 max_cognitive: Option<u16>,
1237
1238 #[arg(long)]
1242 max_crap: Option<f64>,
1243
1244 #[arg(long)]
1246 top: Option<usize>,
1247
1248 #[arg(long, default_value = "cyclomatic")]
1250 sort: SortBy,
1251
1252 #[arg(long)]
1255 complexity: bool,
1256
1257 #[arg(long)]
1264 complexity_breakdown: bool,
1265
1266 #[arg(long)]
1271 file_scores: bool,
1272
1273 #[arg(long)]
1276 coverage_gaps: bool,
1277
1278 #[arg(long)]
1281 hotspots: bool,
1282
1283 #[arg(long)]
1287 ownership: bool,
1288
1289 #[arg(long, value_name = "MODE", value_enum)]
1294 ownership_emails: Option<EmailModeArg>,
1295
1296 #[arg(long)]
1299 targets: bool,
1300
1301 #[arg(long)]
1304 type_coupling: bool,
1305
1306 #[arg(long)]
1311 css: bool,
1312
1313 #[arg(long, value_enum)]
1316 effort: Option<EffortFilter>,
1317
1318 #[arg(long)]
1321 score: bool,
1322
1323 #[arg(long, value_name = "N")]
1332 min_score: Option<f64>,
1333
1334 #[arg(long, value_name = "LEVEL", value_enum)]
1338 min_severity: Option<HealthSeverityCli>,
1339
1340 #[arg(long)]
1344 report_only: bool,
1345
1346 #[arg(long, value_name = "DURATION")]
1349 since: Option<String>,
1350
1351 #[arg(long, value_name = "N")]
1353 min_commits: Option<u32>,
1354
1355 #[expect(
1359 clippy::option_option,
1360 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1361 )]
1362 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1363 save_snapshot: Option<Option<String>>,
1364
1365 #[arg(long)]
1369 trend: bool,
1370
1371 #[arg(long, value_name = "PATH")]
1380 coverage: Option<PathBuf>,
1381
1382 #[arg(long, value_name = "PATH")]
1388 coverage_root: Option<PathBuf>,
1389
1390 #[arg(long, value_name = "PATH")]
1394 runtime_coverage: Option<PathBuf>,
1395
1396 #[arg(long, default_value_t = 100)]
1398 min_invocations_hot: u64,
1399
1400 #[arg(long, value_name = "N")]
1406 min_observation_volume: Option<u32>,
1407
1408 #[arg(long, value_name = "RATIO")]
1413 low_traffic_threshold: Option<f64>,
1414 },
1415
1416 Flags {
1423 #[arg(long)]
1425 top: Option<usize>,
1426 },
1427
1428 Suppressions {
1438 #[arg(long, value_name = "PATH")]
1440 file: Vec<std::path::PathBuf>,
1441 },
1442
1443 Explain {
1449 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1451 issue_type: Vec<String>,
1452 },
1453
1454 #[command(visible_alias = "review")]
1479 Audit {
1480 #[arg(long = "production-dead-code")]
1482 production_dead_code: bool,
1483
1484 #[arg(long = "production-health")]
1486 production_health: bool,
1487
1488 #[arg(long = "production-dupes")]
1490 production_dupes: bool,
1491
1492 #[arg(long)]
1495 dead_code_baseline: Option<PathBuf>,
1496
1497 #[arg(long)]
1500 health_baseline: Option<PathBuf>,
1501
1502 #[arg(long)]
1505 dupes_baseline: Option<PathBuf>,
1506
1507 #[arg(long)]
1511 max_crap: Option<f64>,
1512
1513 #[arg(long, value_name = "PATH")]
1517 coverage: Option<PathBuf>,
1518
1519 #[arg(long, value_name = "PATH")]
1523 coverage_root: Option<PathBuf>,
1524
1525 #[arg(long = "no-css")]
1527 no_css: bool,
1528
1529 #[arg(long)]
1533 css_deep: bool,
1534
1535 #[arg(long = "no-css-deep")]
1537 no_css_deep: bool,
1538
1539 #[arg(long, value_enum)]
1545 gate: Option<AuditGateArg>,
1546
1547 #[arg(long, value_name = "PATH")]
1556 runtime_coverage: Option<PathBuf>,
1557
1558 #[arg(long, default_value_t = 100)]
1561 min_invocations_hot: u64,
1562
1563 #[arg(long, value_name = "MARKER", hide = true)]
1568 gate_marker: Option<String>,
1569
1570 #[arg(long)]
1576 brief: bool,
1577
1578 #[arg(
1583 long,
1584 value_name = "N",
1585 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1586 )]
1587 max_decisions: usize,
1588
1589 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1597 walkthrough_guide: bool,
1598
1599 #[arg(long, value_name = "PATH")]
1609 walkthrough_file: Option<PathBuf>,
1610
1611 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1617 walkthrough: bool,
1618
1619 #[arg(long, value_name = "PATH")]
1625 mark_viewed: Vec<PathBuf>,
1626
1627 #[arg(long)]
1631 show_cleared: bool,
1632
1633 #[arg(long)]
1639 show_deprioritized: bool,
1640 },
1641
1642 AuditCache {
1644 #[command(subcommand)]
1645 subcommand: AuditCacheCli,
1646 },
1647
1648 DecisionSurface {
1662 #[arg(
1665 long,
1666 value_name = "N",
1667 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1668 )]
1669 max_decisions: usize,
1670 },
1671
1672 Impact {
1682 #[command(subcommand)]
1683 subcommand: Option<ImpactCli>,
1684 #[arg(long)]
1688 all: bool,
1689 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1691 sort: ImpactSortCli,
1692 #[arg(long)]
1695 limit: Option<usize>,
1696 },
1697
1698 Security {
1729 #[command(subcommand)]
1730 subcommand: Option<SecuritySubcommand>,
1731 #[arg(long, value_name = "PATH")]
1736 runtime_coverage: Option<PathBuf>,
1737 #[arg(long, default_value_t = 100)]
1740 min_invocations_hot: u64,
1741 #[arg(long, value_name = "PATH")]
1745 file: Vec<std::path::PathBuf>,
1746 #[arg(long, value_name = "MODE")]
1752 gate: Option<security::SecurityGateArg>,
1753 #[arg(long)]
1755 surface: bool,
1756 },
1757
1758 Report {
1763 #[arg(long, value_name = "PATH")]
1766 from: PathBuf,
1767 },
1768 Schema,
1770
1771 CiTemplate {
1778 #[command(subcommand)]
1779 subcommand: CiTemplateCli,
1780 },
1781
1782 Migrate {
1784 #[arg(long, conflicts_with = "jsonc")]
1786 toml: bool,
1787
1788 #[arg(long)]
1796 jsonc: bool,
1797
1798 #[arg(long)]
1800 dry_run: bool,
1801
1802 #[arg(long, value_name = "PATH")]
1804 from: Option<PathBuf>,
1805 },
1806
1807 License {
1814 #[command(subcommand)]
1815 subcommand: LicenseCli,
1816 },
1817
1818 Telemetry {
1826 #[command(subcommand)]
1827 subcommand: TelemetryCli,
1828 },
1829
1830 Coverage {
1836 #[command(subcommand)]
1837 subcommand: CoverageCli,
1838 },
1839
1840 SetupHooks {
1852 #[arg(long, value_enum)]
1854 agent: Option<setup_hooks::HookAgentArg>,
1855
1856 #[arg(long)]
1858 dry_run: bool,
1859
1860 #[arg(long)]
1863 force: bool,
1864
1865 #[arg(long)]
1867 user: bool,
1868
1869 #[arg(long)]
1871 gitignore_claude: bool,
1872
1873 #[arg(long)]
1877 uninstall: bool,
1878 },
1879
1880 Viz {
1882 #[arg(long = "out", value_name = "PATH")]
1884 output: Option<PathBuf>,
1885
1886 #[arg(long)]
1888 no_open: bool,
1889
1890 #[arg(long = "viz-format", default_value = "html")]
1892 viz_format: viz::VizFormat,
1893 },
1894}
1895
1896#[derive(Subcommand)]
1897enum SecuritySubcommand {
1898 Survivors {
1900 #[arg(long, value_name = "PATH")]
1902 candidates: PathBuf,
1903 #[arg(long, value_name = "PATH")]
1905 verdicts: PathBuf,
1906 #[arg(long)]
1908 require_verdict_for_each_candidate: bool,
1909 },
1910 #[command(name = "blind-spots")]
1912 BlindSpots {
1913 #[arg(long, value_name = "PATH")]
1915 file: Vec<PathBuf>,
1916 },
1917}
1918
1919#[derive(clap::Subcommand)]
1920enum AuditCacheCli {
1921 Remove {
1927 #[arg(long)]
1929 dry_run: bool,
1930
1931 #[arg(long, alias = "force")]
1933 yes: bool,
1934 },
1935
1936 Prune {
1949 #[arg(long)]
1951 dry_run: bool,
1952
1953 #[arg(long, value_name = "N")]
1960 max_age_days: Option<u32>,
1961 },
1962}
1963
1964#[derive(clap::Subcommand)]
1965enum LicenseCli {
1966 Activate {
1971 #[arg(value_name = "JWT")]
1973 jwt: Option<String>,
1974
1975 #[arg(long, value_name = "PATH")]
1977 from_file: Option<PathBuf>,
1978
1979 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1981 stdin: bool,
1982
1983 #[arg(long, requires = "email")]
1990 trial: bool,
1991
1992 #[arg(long, value_name = "ADDR")]
1994 email: Option<String>,
1995 },
1996 Status,
1998 Refresh {
2004 #[arg(long, value_name = "KEY")]
2014 api_key: Option<String>,
2015 },
2016 Deactivate,
2018}
2019
2020#[derive(Clone, Copy, clap::Subcommand)]
2021enum TelemetryCli {
2022 Status,
2024 Enable,
2026 Disable,
2028 Inspect {
2030 #[arg(long)]
2032 example: bool,
2033 },
2034}
2035
2036#[derive(clap::Subcommand)]
2037enum CiTemplateCli {
2038 Gitlab {
2040 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2044 vendor: Option<PathBuf>,
2045
2046 #[arg(long)]
2048 force: bool,
2049 },
2050}
2051
2052#[derive(clap::Subcommand)]
2053enum CoverageCli {
2054 Setup {
2056 #[arg(short = 'y', long)]
2058 yes: bool,
2059
2060 #[arg(long)]
2062 non_interactive: bool,
2063
2064 #[arg(long)]
2066 json: bool,
2067 },
2068 Analyze {
2074 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2076 runtime_coverage: Option<PathBuf>,
2077
2078 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2080 cloud: bool,
2081
2082 #[arg(long, value_name = "KEY")]
2084 api_key: Option<String>,
2085
2086 #[arg(long, value_name = "URL")]
2088 api_endpoint: Option<String>,
2089
2090 #[arg(long, value_name = "OWNER/REPO")]
2096 repo: Option<String>,
2097
2098 #[arg(long, value_name = "ID")]
2100 project_id: Option<String>,
2101
2102 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2104 coverage_period: u16,
2105
2106 #[arg(long, value_name = "ENV")]
2108 environment: Option<String>,
2109
2110 #[arg(long, value_name = "SHA")]
2112 commit_sha: Option<String>,
2113
2114 #[arg(long)]
2116 production: bool,
2117
2118 #[arg(long, default_value_t = 100)]
2120 min_invocations_hot: u64,
2121
2122 #[arg(long, value_name = "N")]
2124 min_observation_volume: Option<u32>,
2125
2126 #[arg(long, value_name = "RATIO")]
2128 low_traffic_threshold: Option<f64>,
2129
2130 #[arg(long)]
2132 top: Option<usize>,
2133
2134 #[arg(long)]
2136 blast_radius: bool,
2137
2138 #[arg(long)]
2140 importance: bool,
2141
2142 #[arg(long)]
2144 debug_unmatched: bool,
2145 },
2146 UploadInventory {
2157 #[arg(long, value_name = "KEY")]
2166 api_key: Option<String>,
2167
2168 #[arg(long, value_name = "URL")]
2173 api_endpoint: Option<String>,
2174
2175 #[arg(long, value_name = "PROJECT_ID")]
2180 project_id: Option<String>,
2181
2182 #[arg(long, value_name = "SHA")]
2187 git_sha: Option<String>,
2188
2189 #[arg(long)]
2195 allow_dirty: bool,
2196
2197 #[arg(long, value_name = "GLOB", num_args = 0..)]
2201 exclude_paths: Vec<String>,
2202
2203 #[arg(long, value_name = "PREFIX")]
2216 path_prefix: Option<String>,
2217
2218 #[arg(long)]
2220 dry_run: bool,
2221
2222 #[arg(long)]
2228 with_callers: bool,
2229
2230 #[arg(long)]
2234 ignore_upload_errors: bool,
2235 },
2236 UploadSourceMaps {
2249 #[arg(long, value_name = "PATH", default_value = "dist")]
2251 dir: PathBuf,
2252
2253 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2255 include: String,
2256
2257 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2261 exclude: Vec<String>,
2262
2263 #[arg(long, value_name = "NAME")]
2267 repo: Option<String>,
2268
2269 #[arg(long, value_name = "SHA")]
2274 git_sha: Option<String>,
2275
2276 #[arg(long, value_name = "URL")]
2278 endpoint: Option<String>,
2279
2280 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2285 strip_path: bool,
2286
2287 #[arg(long)]
2289 dry_run: bool,
2290
2291 #[arg(long, value_name = "N", default_value_t = 4)]
2293 concurrency: usize,
2294
2295 #[arg(long)]
2297 fail_fast: bool,
2298 },
2299 UploadStaticFindings {
2306 #[arg(long, value_name = "KEY")]
2316 api_key: Option<String>,
2317
2318 #[arg(long, value_name = "URL")]
2323 api_endpoint: Option<String>,
2324
2325 #[arg(long, value_name = "PROJECT_ID")]
2330 project_id: Option<String>,
2331
2332 #[arg(long, value_name = "SHA")]
2337 git_sha: Option<String>,
2338
2339 #[arg(long)]
2345 allow_dirty: bool,
2346
2347 #[arg(long)]
2349 dry_run: bool,
2350
2351 #[arg(long)]
2355 ignore_upload_errors: bool,
2356 },
2357}
2358
2359#[derive(Subcommand)]
2360enum CiCli {
2361 PlanPrComment {
2363 #[arg(long)]
2365 body: PathBuf,
2366
2367 #[arg(long)]
2369 marker_id: String,
2370
2371 #[arg(long)]
2373 clean: bool,
2374
2375 #[arg(long)]
2377 existing_comment_id: Option<String>,
2378
2379 #[arg(long)]
2381 existing_body: Option<PathBuf>,
2382 },
2383
2384 PostPrComment {
2386 #[arg(long, value_enum)]
2388 provider: CiProviderArg,
2389
2390 #[arg(long)]
2392 pr: Option<String>,
2393
2394 #[arg(long)]
2396 mr: Option<String>,
2397
2398 #[arg(long)]
2400 body: PathBuf,
2401
2402 #[arg(long)]
2404 envelope: Option<PathBuf>,
2405
2406 #[arg(long)]
2408 marker_id: String,
2409
2410 #[arg(long)]
2412 clean: bool,
2413
2414 #[arg(long)]
2416 repo: Option<String>,
2417
2418 #[arg(long = "project-id")]
2420 project_id: Option<String>,
2421
2422 #[arg(long = "api-url")]
2424 api_url: Option<String>,
2425
2426 #[arg(long)]
2428 dry_run: bool,
2429 },
2430
2431 PostReview {
2433 #[arg(long, value_enum)]
2435 provider: CiProviderArg,
2436
2437 #[arg(long)]
2439 pr: Option<String>,
2440
2441 #[arg(long)]
2443 mr: Option<String>,
2444
2445 #[arg(long)]
2447 envelope: PathBuf,
2448
2449 #[arg(long)]
2451 repo: Option<String>,
2452
2453 #[arg(long = "project-id")]
2455 project_id: Option<String>,
2456
2457 #[arg(long = "api-url")]
2459 api_url: Option<String>,
2460
2461 #[arg(long)]
2463 dry_run: bool,
2464 },
2465
2466 PostCheckRun {
2468 #[arg(long, value_enum)]
2470 provider: CiProviderArg,
2471
2472 #[arg(long)]
2474 decision: PathBuf,
2475
2476 #[arg(long)]
2478 repo: String,
2479
2480 #[arg(long = "head-sha")]
2482 head_sha: String,
2483
2484 #[arg(long = "api-url")]
2486 api_url: Option<String>,
2487
2488 #[arg(long = "split-gates")]
2490 split_gates: bool,
2491
2492 #[arg(long)]
2494 dry_run: bool,
2495 },
2496
2497 ReconcileReview {
2499 #[arg(long, value_enum)]
2501 provider: CiProviderArg,
2502
2503 #[arg(long)]
2505 pr: Option<String>,
2506
2507 #[arg(long)]
2509 mr: Option<String>,
2510
2511 #[arg(long)]
2513 envelope: PathBuf,
2514
2515 #[arg(long)]
2517 repo: Option<String>,
2518
2519 #[arg(long = "project-id")]
2521 project_id: Option<String>,
2522
2523 #[arg(long = "api-url")]
2525 api_url: Option<String>,
2526
2527 #[arg(long)]
2529 dry_run: bool,
2530 },
2531}
2532
2533#[derive(Subcommand)]
2534enum RulePackCli {
2535 Init {
2537 name: Option<String>,
2539
2540 #[arg(long, default_value = "starter")]
2542 template: String,
2543
2544 #[arg(long, default_value = "rule-packs")]
2546 dir: String,
2547
2548 #[arg(long)]
2550 no_config: bool,
2551 },
2552
2553 List,
2555
2556 Test {
2558 pack: Option<PathBuf>,
2560 },
2561
2562 Schema,
2564}
2565
2566#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2568pub enum BaselineModeArg {
2569 #[default]
2571 Count,
2572 Identity,
2575}
2576
2577impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2578 fn from(value: BaselineModeArg) -> Self {
2579 match value {
2580 BaselineModeArg::Count => Self::Count,
2581 BaselineModeArg::Identity => Self::Identity,
2582 }
2583 }
2584}
2585
2586#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2587enum CiProviderArg {
2588 Github,
2589 Gitlab,
2590}
2591
2592#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2594enum TypeAwareRequireArg {
2595 BestEffort,
2597 Complete,
2599}
2600
2601impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2602 fn from(value: TypeAwareRequireArg) -> Self {
2603 match value {
2604 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2605 TypeAwareRequireArg::Complete => Self::Complete,
2606 }
2607 }
2608}
2609
2610#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2612pub enum EffortFilter {
2613 Low,
2614 Medium,
2615 High,
2616}
2617
2618impl EffortFilter {
2619 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2621 match self {
2622 Self::Low => fallow_output::EffortEstimate::Low,
2623 Self::Medium => fallow_output::EffortEstimate::Medium,
2624 Self::High => fallow_output::EffortEstimate::High,
2625 }
2626 }
2627}
2628
2629#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2631pub enum HealthSeverityCli {
2632 Moderate,
2633 High,
2634 Critical,
2635}
2636
2637impl HealthSeverityCli {
2638 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2640 match self {
2641 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2642 Self::High => fallow_output::FindingSeverity::High,
2643 Self::Critical => fallow_output::FindingSeverity::Critical,
2644 }
2645 }
2646}
2647
2648#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2654pub enum EmailModeArg {
2655 Raw,
2657 Handle,
2659 Anonymized,
2661 #[value(hide = true)]
2663 Hash,
2664}
2665
2666impl EmailModeArg {
2667 const fn to_config(self) -> fallow_config::EmailMode {
2669 match self {
2670 Self::Raw => fallow_config::EmailMode::Raw,
2671 Self::Handle => fallow_config::EmailMode::Handle,
2672 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2673 Self::Hash => fallow_config::EmailMode::Hash,
2674 }
2675 }
2676}
2677
2678#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2680pub enum AuditGateArg {
2681 NewOnly,
2683 All,
2685}
2686
2687impl From<AuditGateArg> for fallow_config::AuditGate {
2688 fn from(value: AuditGateArg) -> Self {
2689 match value {
2690 AuditGateArg::NewOnly => Self::NewOnly,
2691 AuditGateArg::All => Self::All,
2692 }
2693 }
2694}
2695
2696fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2700 let value: usize = s
2701 .parse()
2702 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2703 if value < 2 {
2704 return Err(format!(
2705 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2706 ));
2707 }
2708 Ok(value)
2709}
2710
2711fn resolve_audit_baseline_path(
2717 root: &std::path::Path,
2718 cli: Option<&std::path::Path>,
2719 config: Option<&str>,
2720) -> Option<PathBuf> {
2721 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2722 config.map(|p| {
2723 let path = PathBuf::from(p);
2724 if path_util::is_absolute_path_any_platform(&path) {
2725 path
2726 } else {
2727 root.join(path)
2728 }
2729 })
2730 })?;
2731 if path_util::is_absolute_path_any_platform(&path) {
2732 Some(path)
2733 } else {
2734 Some(root.join(path))
2735 }
2736}
2737
2738fn emit_known_failure(
2739 message: &str,
2740 exit_code: u8,
2741 output: fallow_config::OutputFormat,
2742 reason: telemetry::FailureReason,
2743) -> ExitCode {
2744 telemetry::note_failure_reason(reason);
2745 emit_error(message, exit_code, output)
2746}
2747
2748fn emit_known_failure_with_style(
2749 message: &str,
2750 exit_code: u8,
2751 output: fallow_config::OutputFormat,
2752 json_style: json_style::JsonStyle,
2753 reason: telemetry::FailureReason,
2754) -> ExitCode {
2755 telemetry::note_failure_reason(reason);
2756 error::emit_error_with_style(message, exit_code, output, json_style)
2757}
2758
2759fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2760 if cli.baseline.is_some() {
2761 Some("--baseline")
2762 } else if cli.save_baseline.is_some() {
2763 Some("--save-baseline")
2764 } else if cli.production {
2765 Some("--production")
2766 } else if cli.no_production {
2767 Some("--no-production")
2768 } else if cli.group_by.is_some() {
2769 Some("--group-by")
2770 } else if cli.performance {
2771 Some("--performance")
2772 } else if cli.explain_skipped {
2773 Some("--explain-skipped")
2774 } else if cli.fail_on_regression {
2775 Some("--fail-on-regression")
2776 } else if cli.regression_baseline.is_some() {
2777 Some("--regression-baseline")
2778 } else if cli.save_regression_baseline.is_some() {
2779 Some("--save-regression-baseline")
2780 } else if cli.dupes_mode.is_some() {
2781 Some("--dupes-mode")
2782 } else if cli.dupes_threshold.is_some() {
2783 Some("--dupes-threshold")
2784 } else if cli.dupes_min_tokens.is_some() {
2785 Some("--dupes-min-tokens")
2786 } else if cli.dupes_min_lines.is_some() {
2787 Some("--dupes-min-lines")
2788 } else if cli.dupes_min_occurrences.is_some() {
2789 Some("--dupes-min-occurrences")
2790 } else if cli.dupes_skip_local {
2791 Some("--dupes-skip-local")
2792 } else if cli.dupes_cross_language {
2793 Some("--dupes-cross-language")
2794 } else if cli.dupes_ignore_imports {
2795 Some("--dupes-ignore-imports")
2796 } else if cli.dupes_no_ignore_imports {
2797 Some("--dupes-no-ignore-imports")
2798 } else if cli.include_entry_exports {
2799 Some("--include-entry-exports")
2800 } else {
2801 None
2802 }
2803}
2804
2805struct DispatchContext<'a> {
2806 cli: &'a Cli,
2807 root: &'a std::path::Path,
2808 output: fallow_config::OutputFormat,
2809 quiet: bool,
2810 fail_on_issues: bool,
2811 json_style: json_style::JsonStyle,
2812 threads: usize,
2813 tolerance: regression::Tolerance,
2814 save_regression_file: Option<&'a std::path::PathBuf>,
2815 save_to_config: bool,
2816}
2817
2818impl DispatchContext<'_> {
2819 fn production_modes(
2820 &self,
2821 dead_code: bool,
2822 health: bool,
2823 dupes: bool,
2824 ) -> Result<ProductionModes, ExitCode> {
2825 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2826 }
2827
2828 fn production_for(
2829 &self,
2830 analysis: fallow_config::ProductionAnalysis,
2831 ) -> Result<bool, ExitCode> {
2832 self.production_modes(false, false, false)
2833 .map(|modes| modes.for_analysis(analysis))
2834 }
2835
2836 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2837 regression::RegressionOpts {
2838 fail_on_regression: self.cli.fail_on_regression,
2839 tolerance: self.tolerance,
2840 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2841 save_target: if let Some(path) = self.save_regression_file {
2842 regression::SaveRegressionTarget::File(path)
2843 } else if self.save_to_config {
2844 regression::SaveRegressionTarget::Config
2845 } else {
2846 regression::SaveRegressionTarget::None
2847 },
2848 scoped,
2849 quiet: self.quiet,
2850 output: self.output,
2851 }
2852 }
2853}
2854
2855#[cfg(unix)]
2870fn signal_test_helper() -> ExitCode {
2871 use std::io::Write as _;
2872 use std::process::Command;
2873
2874 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2875 signal::set_graceful_mode();
2876 }
2877
2878 let mut command = Command::new("sleep");
2879 command.arg("30");
2880 let child = match signal::ScopedChild::spawn(&mut command) {
2881 Ok(c) => c,
2882 Err(err) => {
2883 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2884 return ExitCode::from(2);
2885 }
2886 };
2887 let pid = child.id();
2888 let stdout = std::io::stdout();
2889 let mut lock = stdout.lock();
2890 let _ = writeln!(lock, "{pid}");
2891 let _ = lock.flush();
2892 drop(lock);
2893 let _ = child.wait_with_output();
2894 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2895 return ExitCode::SUCCESS;
2896 }
2897 std::thread::sleep(std::time::Duration::from_secs(5));
2898 ExitCode::SUCCESS
2899}
2900
2901#[cfg(not(unix))]
2902fn signal_test_helper() -> ExitCode {
2903 ExitCode::from(2)
2904}
2905
2906fn install_spawn_hooks() {
2907 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2908 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2909}
2910
2911fn install_signal_handlers() {
2912 if let Err(err) = signal::install_handlers() {
2913 use std::io::Write as _;
2914 let stderr = std::io::stderr();
2915 let mut lock = stderr.lock();
2916 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2917 }
2918}
2919
2920fn redirect_report_to_file(
2925 path: &std::path::Path,
2926 output: fallow_config::OutputFormat,
2927) -> Result<(), ExitCode> {
2928 if let Some(parent) = path.parent()
2929 && !parent.as_os_str().is_empty()
2930 && let Err(e) = std::fs::create_dir_all(parent)
2931 {
2932 return Err(emit_error(
2933 &format!(
2934 "failed to create {} for --output-file: {e}",
2935 parent.display()
2936 ),
2937 2,
2938 output,
2939 ));
2940 }
2941 match std::fs::File::create(path) {
2942 Ok(file) => {
2943 report::sink::set_file_sink(file);
2944 colored::control::set_override(false);
2945 Ok(())
2946 }
2947 Err(e) => Err(emit_error(
2948 &format!("failed to open {} for --output-file: {e}", path.display()),
2949 2,
2950 output,
2951 )),
2952 }
2953}
2954
2955fn finalize_report_file(
2958 path: &std::path::Path,
2959 quiet: bool,
2960 output: fallow_config::OutputFormat,
2961) -> Result<(), ExitCode> {
2962 if let Err(e) = report::sink::flush() {
2963 return Err(emit_error(
2964 &format!("failed to write {}: {e}", path.display()),
2965 2,
2966 output,
2967 ));
2968 }
2969 if !quiet && report::sink::wrote() {
2973 eprintln!("Report written to {}", path.display());
2974 }
2975 Ok(())
2976}
2977
2978pub fn run() -> ExitCode {
2983 install_signal_handlers();
2984 install_spawn_hooks();
2985
2986 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2987 return signal_test_helper();
2988 }
2989
2990 let (mut cli, fmt) = match parse_cli_args() {
2991 Ok(parsed) => parsed,
2992 Err(code) => return code,
2993 };
2994 if cli.pretty && !fmt.payload_is_json {
2995 eprintln!(
2996 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2997 );
2998 return ExitCode::from(2);
2999 }
3000
3001 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3002 return code;
3003 }
3004
3005 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3006 return code;
3007 }
3008 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3009 return code;
3010 }
3011 if is_impact_statusline(&cli) {
3012 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3013 Ok(validated) => validated,
3014 Err(code) => return code,
3015 };
3016 return cli_impact::render_impact_statusline(&root);
3017 }
3018 let telemetry_run = start_telemetry_run(&cli, &fmt);
3019
3020 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3021 Ok(v) => v,
3022 Err(code) => {
3023 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
3024 }
3025 };
3026
3027 let FormatConfig {
3028 output,
3029 payload_is_json: _,
3030 quiet,
3031 fail_on_issues,
3032 json_style,
3033 } = fmt;
3034
3035 let tolerance =
3036 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3037 Ok(tolerance) => tolerance,
3038 Err(code) => return code,
3039 };
3040
3041 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3042
3043 let command = cli.command.take();
3044 let dispatch = DispatchContext {
3045 cli: &cli,
3046 root: &root,
3047 output,
3048 quiet,
3049 fail_on_issues,
3050 json_style,
3051 threads,
3052 tolerance,
3053 save_regression_file: save_regression_file.as_ref(),
3054 save_to_config,
3055 };
3056 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3057 Ok(code) => code,
3058 Err(code) => return code,
3059 };
3060 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
3061}
3062
3063#[doc(hidden)]
3067pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3068 let config_path = None;
3069 fix::run_fix_with_count(&fix::FixOptions {
3070 root,
3071 config_path: &config_path,
3072 output: fallow_config::OutputFormat::Json,
3073 json_style: json_style::JsonStyle::Compact,
3074 no_cache: true,
3075 threads,
3076 quiet: true,
3077 emit_output: false,
3078 allow_remote_extends: false,
3079 dry_run: true,
3080 yes: false,
3081 production: false,
3082 no_create_config: true,
3083 type_aware: None,
3084 type_aware_projects: &[],
3085 type_aware_require: None,
3086 })
3087}
3088
3089#[doc(hidden)]
3092pub use audit::AuditReviewBenchmarkCorpus;
3093
3094#[doc(hidden)]
3097pub fn create_audit_review_benchmark_corpus(
3098 root: &Path,
3099 changed_files: &[PathBuf],
3100 threads: usize,
3101) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3102 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3103}
3104
3105#[doc(hidden)]
3108pub fn benchmark_audit_review_brief_many_changed_files_json(
3109 corpus: &mut AuditReviewBenchmarkCorpus,
3110) -> (ExitCode, usize, usize, usize, usize, usize) {
3111 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3112 Ok(result) => (
3113 ExitCode::SUCCESS,
3114 result.introduced_count,
3115 result.inherited_count,
3116 result.public_api_added_count,
3117 result.decision_count,
3118 result.rendered_bytes,
3119 ),
3120 Err(code) => (code, 0, 0, 0, 0, 0),
3121 }
3122}
3123
3124#[doc(hidden)]
3125pub use inspect::InspectBenchmarkCorpus;
3126
3127#[doc(hidden)]
3130pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3131 inspect::create_inspect_benchmark_corpus(root, threads)
3132}
3133
3134#[doc(hidden)]
3137pub fn benchmark_inspect_file_evidence_bundle_json(
3138 root: &Path,
3139 threads: usize,
3140 corpus: &InspectBenchmarkCorpus,
3141) -> (ExitCode, usize, usize) {
3142 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3143 Ok((child_call_count, rendered_bytes)) => {
3144 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3145 }
3146 Err(_) => (ExitCode::from(2), 0, 0),
3147 }
3148}
3149
3150#[doc(hidden)]
3153pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3154 match check::benchmark_dead_code_json(root, threads) {
3155 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3156 Err(code) => (code, 0, 0),
3157 }
3158}
3159
3160#[doc(hidden)]
3163pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3164 match security::benchmark_security_json(root, threads) {
3165 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3166 Err(code) => (code, 0, 0),
3167 }
3168}
3169
3170#[doc(hidden)]
3171pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3172
3173#[doc(hidden)]
3176pub fn create_security_survivors_benchmark_corpus(
3177 root: &Path,
3178 threads: usize,
3179) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3180 security::create_security_survivors_benchmark_corpus(root, threads)
3181}
3182
3183#[doc(hidden)]
3186pub fn benchmark_security_survivors_json(
3187 corpus: &SecuritySurvivorsBenchmarkCorpus,
3188) -> (ExitCode, usize, usize, usize, usize, usize) {
3189 match security::benchmark_security_survivors_json(corpus) {
3190 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3191 ExitCode::SUCCESS,
3192 survivors,
3193 dismissed,
3194 needs_human_review,
3195 unverdicted,
3196 rendered_bytes,
3197 ),
3198 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3199 }
3200}
3201
3202#[doc(hidden)]
3205pub fn benchmark_security_blind_spots_json(
3206 root: &Path,
3207 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3208) -> SecurityBlindSpotsBenchmarkResult {
3209 security::benchmark_security_blind_spots_json(root, diagnostics)
3210}
3211
3212#[doc(hidden)]
3215pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3216 match list::benchmark_list_json(root, threads) {
3217 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3218 ExitCode::SUCCESS,
3219 file_count,
3220 entry_point_count,
3221 workspace_count,
3222 rendered_bytes,
3223 ),
3224 Err(code) => (code, 0, 0, 0, 0),
3225 }
3226}
3227
3228#[doc(hidden)]
3231pub fn benchmark_list_boundaries_json(
3232 root: &Path,
3233 threads: usize,
3234) -> (ExitCode, usize, usize, usize, usize) {
3235 match list::benchmark_list_boundaries_json(root, threads) {
3236 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3237 ExitCode::SUCCESS,
3238 zone_count,
3239 rule_count,
3240 matched_file_count,
3241 rendered_bytes,
3242 ),
3243 Err(code) => (code, 0, 0, 0, 0),
3244 }
3245}
3246
3247#[doc(hidden)]
3250pub use watch::WatchFilterBenchmarkGlobalGitignore;
3251
3252#[doc(hidden)]
3255pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3256 watch::create_benchmark_global_gitignore()
3257}
3258
3259#[doc(hidden)]
3262pub fn benchmark_watch_filter_initialization(
3263 config: &fallow_config::ResolvedConfig,
3264 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3265) -> (usize, usize) {
3266 watch::benchmark_filter_initialization(config, global_gitignore)
3267}
3268
3269#[doc(hidden)]
3272pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3273 match viz::benchmark_viz_html(root, threads) {
3274 Ok((file_count, edge_count, rendered_bytes)) => {
3275 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3276 }
3277 Err(code) => (code, 0, 0, 0),
3278 }
3279}
3280
3281#[doc(hidden)]
3284pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3285 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3286 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3287 Err(code) => (code, 0, 0),
3288 }
3289}
3290
3291#[doc(hidden)]
3294pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3295 match onboarding::benchmark_recommend_json(root) {
3296 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3297 ExitCode::SUCCESS,
3298 decision_count,
3299 framework_count,
3300 heterogeneous,
3301 rendered_bytes,
3302 ),
3303 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3304 }
3305}
3306
3307#[doc(hidden)]
3310pub fn benchmark_runtime_coverage_analyze_json(
3311 root: &Path,
3312 runtime_coverage_path: &Path,
3313 response_bytes: &[u8],
3314 threads: usize,
3315) -> (ExitCode, usize, usize, usize, String) {
3316 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3317 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3318 ExitCode::SUCCESS,
3319 finding_count,
3320 hot_path_count,
3321 request_bytes,
3322 rendered,
3323 ),
3324 Err(code) => (code, 0, 0, 0, String::new()),
3325 }
3326}
3327
3328fn is_impact_statusline(cli: &Cli) -> bool {
3331 matches!(
3332 cli.command.as_ref(),
3333 Some(Command::Impact {
3334 subcommand: Some(ImpactCli::Statusline),
3335 all: false,
3336 ..
3337 })
3338 )
3339}
3340
3341fn dispatch_and_finalize(
3345 dispatch: &DispatchContext<'_>,
3346 command: Option<Command>,
3347) -> Result<ExitCode, ExitCode> {
3348 let cli = dispatch.cli;
3349 let output = dispatch.output;
3350 let quiet = dispatch.quiet;
3351
3352 if let Some(path) = cli.output_file.as_deref()
3355 && let Err(code) = redirect_report_to_file(path, output)
3356 {
3357 return Err(code);
3358 }
3359
3360 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3361 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3362 } else {
3363 match command {
3364 None => dispatch_bare_command(dispatch),
3365 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3366 }
3367 };
3368
3369 if let Some(path) = cli.output_file.as_deref()
3370 && let Err(code) = finalize_report_file(path, quiet, output)
3371 {
3372 return Err(code);
3373 }
3374 Ok(exit_code)
3375}
3376
3377fn run_telemetry_command_if_requested(
3378 cli: &mut Cli,
3379 output: fallow_config::OutputFormat,
3380 json_style: json_style::JsonStyle,
3381) -> Option<ExitCode> {
3382 if matches!(cli.command, Some(Command::Telemetry { .. }))
3383 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3384 {
3385 return Some(telemetry::run(
3386 map_telemetry_subcommand(subcommand),
3387 output,
3388 json_style,
3389 ));
3390 }
3391 None
3392}
3393
3394fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3399 if !matches!(cli.command, Some(Command::Doctor)) {
3400 return None;
3401 }
3402
3403 if let Some(flag) = unsupported_doctor_option(cli) {
3404 let message = if flag == "--output-file" {
3405 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3406 } else {
3407 format!("{flag} is not valid with `fallow doctor`.")
3408 };
3409 return Some(crate::error::emit_error_with_style(
3410 &message,
3411 2,
3412 format.output,
3413 format.json_style,
3414 ));
3415 }
3416 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3417 return Some(code);
3418 }
3419
3420 let root = cli.root.clone().unwrap_or_else(|| {
3421 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3422 });
3423 let config_path = cli.config.as_ref().map(|path| {
3424 if path_util::is_absolute_path_any_platform(path) {
3425 path.clone()
3426 } else {
3427 root.join(path)
3428 }
3429 });
3430 let report = doctor::collect_report(&root, config_path.as_deref());
3431 Some(doctor::render_report(
3432 &report,
3433 format.output,
3434 format.json_style,
3435 ))
3436}
3437
3438fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3442 [
3443 (cli.allow_remote_extends, "--allow-remote-extends"),
3444 (cli.no_cache, "--no-cache"),
3445 (cli.threads.is_some(), "--threads"),
3446 (cli.changed_since.is_some(), "--changed-since"),
3447 (cli.diff_file.is_some(), "--diff-file"),
3448 (cli.diff_stdin, "--diff-stdin"),
3449 (cli.churn_file.is_some(), "--churn-file"),
3450 (cli.max_file_size.is_some(), "--max-file-size"),
3451 (cli.baseline.is_some(), "--baseline"),
3452 (cli.baseline_mode.is_some(), "--baseline-mode"),
3453 (cli.parent_run.is_some(), "--parent-run"),
3454 (cli.save_baseline.is_some(), "--save-baseline"),
3455 (cli.production, "--production"),
3456 (cli.no_production, "--no-production"),
3457 (cli.production_dead_code, "--production-dead-code"),
3458 (cli.production_health, "--production-health"),
3459 (cli.production_dupes, "--production-dupes"),
3460 (cli.workspace.is_some(), "--workspace"),
3461 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3462 (cli.group_by.is_some(), "--group-by"),
3463 (cli.performance, "--performance"),
3464 (cli.explain, "--explain"),
3465 (cli.explain_skipped, "--explain-skipped"),
3466 (cli.summary, "--summary"),
3467 (cli.ci, "--ci"),
3468 (cli.fail_on_issues, "--fail-on-issues"),
3469 (cli.sarif_file.is_some(), "--sarif-file"),
3470 (cli.output_file.is_some(), "--output-file"),
3471 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3472 (cli.fail_on_regression, "--fail-on-regression"),
3473 (cli.tolerance != "0", "--tolerance"),
3474 (cli.regression_baseline.is_some(), "--regression-baseline"),
3475 (
3476 cli.save_regression_baseline.is_some(),
3477 "--save-regression-baseline",
3478 ),
3479 (!cli.only.is_empty(), "--only"),
3480 (!cli.skip.is_empty(), "--skip"),
3481 (cli.dupes_mode.is_some(), "--dupes-mode"),
3482 (cli.dupes_near, "--dupes-near"),
3483 (cli.dupes_threshold.is_some(), "--dupes-threshold"),
3484 (cli.dupes_min_tokens.is_some(), "--dupes-min-tokens"),
3485 (cli.dupes_min_lines.is_some(), "--dupes-min-lines"),
3486 (
3487 cli.dupes_min_occurrences.is_some(),
3488 "--dupes-min-occurrences",
3489 ),
3490 (cli.dupes_skip_local, "--dupes-skip-local"),
3491 (cli.dupes_cross_language, "--dupes-cross-language"),
3492 (cli.dupes_ignore_imports, "--dupes-ignore-imports"),
3493 (cli.dupes_no_ignore_imports, "--dupes-no-ignore-imports"),
3494 (cli.score, "--score"),
3495 (cli.trend, "--trend"),
3496 (cli.save_snapshot.is_some(), "--save-snapshot"),
3497 (cli.coverage.is_some(), "--coverage"),
3498 (cli.coverage_root.is_some(), "--coverage-root"),
3499 (cli.include_entry_exports, "--include-entry-exports"),
3500 (cli.type_aware, "--type-aware"),
3501 (cli.no_type_aware, "--no-type-aware"),
3502 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3503 (cli.type_aware_require.is_some(), "--type-aware-require"),
3504 ]
3505 .into_iter()
3506 .find_map(|(used, flag)| used.then_some(flag))
3507}
3508
3509fn run_schema_command_if_requested(
3510 cli: &Cli,
3511 json_style: json_style::JsonStyle,
3512) -> Option<ExitCode> {
3513 match cli.command {
3514 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3515 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3516 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3517 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3518 _ => None,
3519 }
3520}
3521
3522fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3523 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3524 opt.as_ref()
3525 .filter(|path| !path.is_empty())
3526 .map(std::path::PathBuf::from)
3527 });
3528 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3529 (save_file, save_to_config)
3530}
3531
3532fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3533 let cli = dispatch.cli;
3534 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3535 let production = match dispatch.production_modes(
3536 cli.production_dead_code,
3537 cli.production_health,
3538 cli.production_dupes,
3539 ) {
3540 Ok(production) => production,
3541 Err(code) => return code,
3542 };
3543 let coverage_inputs = if run_health {
3548 match resolve_health_coverage_inputs(
3549 dispatch,
3550 cli.coverage.as_deref(),
3551 cli.coverage_root.as_deref(),
3552 ) {
3553 Ok(inputs) => inputs,
3554 Err(code) => return code,
3555 }
3556 } else {
3557 ResolvedHealthCoverageInputs::default()
3558 };
3559 run_bare_combined(
3560 dispatch,
3561 production,
3562 &coverage_inputs,
3563 BareAnalyses {
3564 run_check,
3565 run_dupes,
3566 run_health,
3567 },
3568 )
3569}
3570
3571#[derive(Clone, Copy)]
3573struct BareAnalyses {
3574 run_check: bool,
3575 run_dupes: bool,
3576 run_health: bool,
3577}
3578
3579fn run_bare_combined(
3582 dispatch: &DispatchContext<'_>,
3583 production: ProductionModes,
3584 coverage_inputs: &ResolvedHealthCoverageInputs,
3585 analyses: BareAnalyses,
3586) -> ExitCode {
3587 let cli = dispatch.cli;
3588 let (output, quiet, fail_on_issues) =
3589 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3590 combined::run_combined(&combined::CombinedOptions {
3591 root: dispatch.root,
3592 config_path: &cli.config,
3593 output,
3594 json_style: dispatch.json_style,
3595 no_cache: cli.no_cache,
3596 threads: dispatch.threads,
3597 quiet,
3598 allow_remote_extends: cli.allow_remote_extends,
3599 fail_on_issues,
3600 sarif_file: cli.sarif_file.as_deref(),
3601 changed_since: cli.changed_since.as_deref(),
3602 churn_file: cli.churn_file.as_deref(),
3603 baseline: cli.baseline.as_deref(),
3604 save_baseline: cli.save_baseline.as_deref(),
3605 production: cli.production,
3606 production_dead_code: Some(production.dead_code),
3607 production_health: Some(production.health),
3608 production_dupes: Some(production.dupes),
3609 workspace: cli.workspace.as_deref(),
3610 changed_workspaces: cli.changed_workspaces.as_deref(),
3611 group_by: cli.group_by,
3612 type_aware: cli.type_aware_override(),
3613 type_aware_projects: &cli.type_aware_project,
3614 type_aware_require: cli.type_aware_require.map(Into::into),
3615 explain: cli.explain,
3616 explain_skipped: cli.explain_skipped,
3617 performance: cli.performance,
3618 summary: cli.summary,
3619 run_check: analyses.run_check,
3620 run_dupes: analyses.run_dupes,
3621 run_health: analyses.run_health,
3622 dupes_mode: cli.dupes_mode,
3623 dupes_near: cli.dupes_near,
3624 dupes_threshold: cli.dupes_threshold,
3625 dupes_min_tokens: cli.dupes_min_tokens,
3626 dupes_min_lines: cli.dupes_min_lines,
3627 dupes_min_occurrences: cli.dupes_min_occurrences,
3628 dupes_skip_local: cli.dupes_skip_local,
3629 dupes_cross_language: cli.dupes_cross_language,
3630 dupes_ignore_imports: resolve_ignore_imports(
3631 cli.dupes_ignore_imports,
3632 cli.dupes_no_ignore_imports,
3633 ),
3634 score: cli.score || cli.trend,
3635 trend: cli.trend,
3636 save_snapshot: cli.save_snapshot.as_ref(),
3637 coverage: coverage_inputs.coverage.as_deref(),
3638 coverage_root: coverage_inputs.coverage_root.as_deref(),
3639 include_entry_exports: cli.include_entry_exports,
3640 regression_opts: dispatch.regression_opts(
3641 cli.changed_since.is_some()
3642 || cli.workspace.is_some()
3643 || cli.changed_workspaces.is_some(),
3644 ),
3645 })
3646}
3647
3648#[allow(
3649 clippy::too_many_lines,
3650 reason = "the command router is intentionally an exhaustive top-level dispatch table"
3651)]
3652fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3653 let cli = dispatch.cli;
3654 let root = dispatch.root;
3655 let output = dispatch.output;
3656 let quiet = dispatch.quiet;
3657 match command {
3658 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3659 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3660 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3661 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
3662 Command::SimilarCode {
3663 subcommand,
3664 threshold,
3665 min_lines,
3666 top,
3667 file,
3668 } => similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
3669 root,
3670 config_path: cli.config.as_deref(),
3671 allow_remote_extends: cli.allow_remote_extends,
3672 no_cache: cli.no_cache,
3673 threads: dispatch.threads,
3674 changed_since: cli.changed_since.as_deref(),
3675 diff_file: cli.diff_file.as_deref(),
3676 workspace: cli.workspace.as_deref(),
3677 changed_workspaces: cli.changed_workspaces.as_deref(),
3678 explain: cli.explain,
3679 quiet,
3680 output,
3681 json_style: dispatch.json_style,
3682 threshold,
3683 min_lines,
3684 top,
3685 files: file,
3686 subcommand,
3687 }),
3688 Command::Inspect {
3689 file,
3690 symbol,
3691 symbol_chain,
3692 churn,
3693 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3694 Command::Trace {
3695 symbol,
3696 path,
3697 callers,
3698 callees,
3699 depth,
3700 } => dispatch_trace_command(dispatch, symbol, &path, callers, callees, depth),
3701 Command::TraceError { trace_file } => {
3702 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
3703 root: dispatch.root,
3704 config_path: &dispatch.cli.config,
3705 output: dispatch.output,
3706 json_style: dispatch.json_style,
3707 no_cache: dispatch.cli.no_cache,
3708 threads: dispatch.threads,
3709 quiet: dispatch.quiet,
3710 allow_remote_extends: dispatch.cli.allow_remote_extends,
3711 trace_file: trace_file.as_deref(),
3712 })
3713 }
3714 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3715 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3716 Command::Hooks { subcommand } => {
3717 run_hooks_command(root, subcommand, output, dispatch.json_style)
3718 }
3719 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
3720 Command::Ci { subcommand } => {
3721 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3722 }
3723 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3724 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3725 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3726 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3727 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3728 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3729 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3730 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3731 root,
3732 explicit_config: cli.config.as_deref(),
3733 path_only: path,
3734 output,
3735 quiet,
3736 json_style: dispatch.json_style,
3737 load_options: fallow_config::ConfigLoadOptions {
3738 allow_remote_extends: cli.allow_remote_extends,
3739 },
3740 }),
3741 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3742 list @ (Command::Workspaces | Command::List { .. }) => {
3743 dispatch_list_command(&list, dispatch)
3744 }
3745 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3746 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3747 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3748 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3749 Command::Explain { issue_type } => {
3750 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3751 }
3752 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3753 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3754 Command::DecisionSurface { max_decisions } => {
3755 dispatch_decision_surface(dispatch, max_decisions)
3756 }
3757 Command::Impact {
3758 subcommand,
3759 all,
3760 sort,
3761 limit,
3762 } => dispatch_impact(
3763 root,
3764 quiet,
3765 output,
3766 dispatch.json_style,
3767 subcommand,
3768 ImpactCrossRepoOpts { all, sort, limit },
3769 ),
3770 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3771 Command::Viz {
3772 output: viz_output,
3773 no_open,
3774 viz_format,
3775 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3776 Command::Report { from } => {
3777 cli_report::run_report(&from, output, root, cli.config.as_deref())
3778 }
3779 Command::Schema => unreachable!("handled above"),
3780 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3781 Command::License { subcommand } => {
3782 dispatch_license_command(subcommand, output, dispatch.json_style)
3783 }
3784 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3785 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3786 setup_hooks @ Command::SetupHooks { .. } => {
3787 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3788 }
3789 }
3790}
3791
3792fn dispatch_type_aware_command(
3793 dispatch: &DispatchContext<'_>,
3794 subcommand: TypeAwareCli,
3795) -> ExitCode {
3796 match subcommand {
3797 TypeAwareCli::Status => {
3798 let status = fallow_api::type_aware_status(dispatch.root);
3799 match dispatch.output {
3800 fallow_config::OutputFormat::Json => {
3801 let output = type_aware_status_output(dispatch.root, status);
3802 match fallow_output::serialize_type_aware_status_json_output(
3803 output,
3804 crate::output_runtime::current_root_envelope_mode(),
3805 ) {
3806 Ok(value) => match dispatch.json_style.serialize(&value) {
3807 Ok(json) => {
3808 crate::report::sink::outln!("{json}");
3809 ExitCode::SUCCESS
3810 }
3811 Err(error) => emit_error(
3812 &format!("failed to serialize type-aware status: {error}"),
3813 2,
3814 dispatch.output,
3815 ),
3816 },
3817 Err(error) => emit_error(
3818 &format!("failed to build type-aware status: {error}"),
3819 2,
3820 dispatch.output,
3821 ),
3822 }
3823 }
3824 fallow_config::OutputFormat::Human => {
3825 if status.available {
3826 crate::report::sink::outln!(
3827 "{}",
3828 report::human_status_line(
3829 report::HumanStatus::Ok,
3830 format_args!(
3831 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3832 status.package_version.as_deref().unwrap_or("unknown"),
3833 status.protocol_version,
3834 status.backend_version.as_deref().unwrap_or("unknown"),
3835 )
3836 )
3837 );
3838 } else {
3839 crate::report::sink::outln!(
3840 "{}",
3841 report::human_status_line(
3842 report::HumanStatus::Inactive,
3843 "Type-aware companion: unavailable"
3844 )
3845 );
3846 if let Some(remediation) = status.remediation {
3847 crate::report::sink::outln!(
3848 "{}",
3849 report::human_status_line(
3850 report::HumanStatus::Warning,
3851 format_args!("Action: {remediation}")
3852 )
3853 );
3854 }
3855 }
3856 ExitCode::SUCCESS
3857 }
3858 _ => emit_error(
3859 "type-aware status supports human and json output",
3860 2,
3861 dispatch.output,
3862 ),
3863 }
3864 }
3865 }
3866}
3867
3868fn type_aware_status_output(
3869 root: &Path,
3870 status: fallow_api::TypeAwareStatus,
3871) -> fallow_output::TypeAwareStatusOutput {
3872 let companion_path = status.companion_path.as_deref().map(|path| {
3873 if let Ok(relative) = path.strip_prefix(root)
3874 && !relative.as_os_str().is_empty()
3875 {
3876 relative.to_string_lossy().replace('\\', "/")
3877 } else {
3878 path.file_name()
3879 .unwrap_or(path.as_os_str())
3880 .to_string_lossy()
3881 .into_owned()
3882 }
3883 });
3884 let remediation = status.remediation.map(|message| {
3885 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3886 status.companion_path.as_deref().map_or_else(
3887 || without_root.clone(),
3888 |path| {
3889 without_root.replace(
3890 path.to_string_lossy().as_ref(),
3891 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3892 )
3893 },
3894 )
3895 });
3896 fallow_output::TypeAwareStatusOutput {
3897 schema_version: fallow_types::envelope::SchemaVersion(
3898 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
3899 ),
3900 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3901 available: status.available,
3902 discovery_source: status.discovery_source.map(str::to_string),
3903 companion_path,
3904 package_version: status.package_version,
3905 protocol_version: status.protocol_version,
3906 backend_family: status.backend_family,
3907 backend_version: status.backend_version,
3908 remediation,
3909 }
3910}
3911
3912fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3914 let filters = check_issue_filters(&command);
3915 let Command::Check {
3916 include_dupes,
3917 trace,
3918 trace_file,
3919 trace_dependency,
3920 impact_closure,
3921 symbol_impact,
3922 top,
3923 file,
3924 ..
3925 } = command
3926 else {
3927 unreachable!("check dispatcher only handles check commands");
3928 };
3929
3930 dispatch_check(
3931 dispatch,
3932 &CheckDispatchArgs {
3933 filters,
3934 trace_opts: TraceOptions {
3935 trace_export: trace,
3936 trace_file,
3937 trace_dependency,
3938 impact_closure,
3939 symbol_impact,
3940 performance: dispatch.cli.performance,
3941 },
3942 include_dupes,
3943 type_aware: dispatch.cli.type_aware_override(),
3944 type_aware_project: dispatch.cli.type_aware_project.clone(),
3945 type_aware_require: dispatch.cli.type_aware_require,
3946 top,
3947 file,
3948 },
3949 )
3950}
3951
3952fn check_issue_filters(command: &Command) -> IssueFilters {
3957 check_issue_filters_framework(command, &check_issue_filters_core(command))
3958}
3959
3960fn check_issue_filters_core(command: &Command) -> IssueFilters {
3963 let Command::Check {
3964 unused_files,
3965 unused_exports,
3966 unused_deps,
3967 unused_types,
3968 private_type_leaks,
3969 unused_enum_members,
3970 unused_class_members,
3971 unresolved_imports,
3972 unlisted_deps,
3973 duplicate_exports,
3974 circular_deps,
3975 re_export_cycles,
3976 boundary_violations,
3977 policy_violations,
3978 stale_suppressions,
3979 ..
3980 } = command
3981 else {
3982 unreachable!("check filter builder only handles check commands");
3983 };
3984
3985 let mut filters = IssueFilters::default();
3986 for (flag, active) in [
3987 ("--unused-files", *unused_files),
3988 ("--unused-exports", *unused_exports),
3989 ("--unused-deps", *unused_deps),
3990 ("--unused-types", *unused_types),
3991 ("--private-type-leaks", *private_type_leaks),
3992 ("--unused-enum-members", *unused_enum_members),
3993 ("--unused-class-members", *unused_class_members),
3994 ("--unresolved-imports", *unresolved_imports),
3995 ("--unlisted-deps", *unlisted_deps),
3996 ("--duplicate-exports", *duplicate_exports),
3997 ("--circular-deps", *circular_deps),
3998 ("--re-export-cycles", *re_export_cycles),
3999 ("--boundary-violations", *boundary_violations),
4000 ("--policy-violations", *policy_violations),
4001 ("--stale-suppressions", *stale_suppressions),
4002 ] {
4003 enable_check_filter(&mut filters, flag, active);
4004 }
4005 filters
4006}
4007
4008fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4011 let Command::Check {
4012 unused_store_members,
4013 unprovided_injects,
4014 unrendered_components,
4015 unused_component_props,
4016 unused_component_emits,
4017 unused_component_inputs,
4018 unused_component_outputs,
4019 unused_svelte_events,
4020 unused_server_actions,
4021 unused_load_data_keys,
4022 unused_catalog_entries,
4023 empty_catalog_groups,
4024 unresolved_catalog_references,
4025 unused_dependency_overrides,
4026 misconfigured_dependency_overrides,
4027 ..
4028 } = command
4029 else {
4030 unreachable!("check filter builder only handles check commands");
4031 };
4032
4033 let mut filters = base.clone();
4034 for (flag, active) in [
4035 ("--unused-store-members", *unused_store_members),
4036 ("--unprovided-injects", *unprovided_injects),
4037 ("--unrendered-components", *unrendered_components),
4038 ("--unused-component-props", *unused_component_props),
4039 ("--unused-component-emits", *unused_component_emits),
4040 ("--unused-component-inputs", *unused_component_inputs),
4041 ("--unused-component-outputs", *unused_component_outputs),
4042 ("--unused-svelte-events", *unused_svelte_events),
4043 ("--unused-server-actions", *unused_server_actions),
4044 ("--unused-load-data-keys", *unused_load_data_keys),
4045 ("--unused-catalog-entries", *unused_catalog_entries),
4046 ("--empty-catalog-groups", *empty_catalog_groups),
4047 (
4048 "--unresolved-catalog-references",
4049 *unresolved_catalog_references,
4050 ),
4051 (
4052 "--unused-dependency-overrides",
4053 *unused_dependency_overrides,
4054 ),
4055 (
4056 "--misconfigured-dependency-overrides",
4057 *misconfigured_dependency_overrides,
4058 ),
4059 ] {
4060 enable_check_filter(&mut filters, flag, active);
4061 }
4062 filters
4063}
4064
4065fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4066 if active {
4067 assert!(
4068 filters.enable_cli_filter_flag(flag),
4069 "check command uses unregistered dead-code filter flag {flag}"
4070 );
4071 }
4072}
4073
4074fn dispatch_inspect_command(
4075 dispatch: &DispatchContext<'_>,
4076 file: Option<String>,
4077 symbol: Option<String>,
4078 symbol_chain: bool,
4079 churn: bool,
4080) -> ExitCode {
4081 let target = match (file, symbol) {
4082 (Some(file), None) => inspect::InspectTarget::File { file },
4083 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4084 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4085 file: file.to_string(),
4086 export_name: export_name.to_string(),
4087 },
4088 None => {
4089 return emit_error(
4090 "--symbol must be formatted as FILE:EXPORT",
4091 2,
4092 dispatch.output,
4093 );
4094 }
4095 },
4096 _ => {
4097 return emit_error(
4098 "inspect requires exactly one of --file or --symbol",
4099 2,
4100 dispatch.output,
4101 );
4102 }
4103 };
4104
4105 let churn_config = if churn {
4106 match load_config_for_analysis(
4107 dispatch.root,
4108 &dispatch.cli.config,
4109 ConfigLoadOptions {
4110 output: dispatch.output,
4111 no_cache: dispatch.cli.no_cache,
4112 threads: dispatch.threads,
4113 production_override: None,
4114 quiet: dispatch.quiet,
4115 allow_remote_extends: dispatch.cli.allow_remote_extends,
4116 },
4117 fallow_config::ProductionAnalysis::Health,
4118 ) {
4119 Ok(config) => Some(config),
4120 Err(code) => return code,
4121 }
4122 } else {
4123 None
4124 };
4125
4126 inspect::run_inspect(&inspect::InspectOptions {
4127 root: dispatch.root,
4128 config_path: dispatch.cli.config.as_ref(),
4129 output: dispatch.output,
4130 json_style: dispatch.json_style,
4131 no_cache: dispatch.cli.no_cache,
4132 no_production: dispatch.cli.no_production,
4133 max_file_size: dispatch.cli.max_file_size,
4134 threads: dispatch.threads,
4135 quiet: dispatch.quiet,
4136 production: dispatch.cli.production,
4137 workspace: dispatch.cli.workspace.as_ref(),
4138 target,
4139 churn_cache_dir: churn_config
4140 .as_ref()
4141 .map(|config| config.cache_dir.as_path()),
4142 symbol_chain,
4143 type_aware: dispatch.cli.type_aware_override(),
4144 type_aware_projects: &dispatch.cli.type_aware_project,
4145 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4146 })
4147}
4148
4149fn dispatch_trace_command(
4150 dispatch: &DispatchContext<'_>,
4151 symbol: Option<String>,
4152 path: &[String],
4153 callers: bool,
4154 callees: bool,
4155 depth: Option<u32>,
4156) -> ExitCode {
4157 if let [from, to] = path {
4158 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4159 root: dispatch.root,
4160 config_path: &dispatch.cli.config,
4161 output: dispatch.output,
4162 json_style: dispatch.json_style,
4163 no_cache: dispatch.cli.no_cache,
4164 threads: dispatch.threads,
4165 quiet: dispatch.quiet,
4166 allow_remote_extends: dispatch.cli.allow_remote_extends,
4167 from,
4168 to,
4169 });
4170 }
4171 let Some(symbol) = symbol else {
4172 return emit_error(
4173 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4174 2,
4175 dispatch.output,
4176 );
4177 };
4178 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4179 root: dispatch.root,
4180 config_path: &dispatch.cli.config,
4181 output: dispatch.output,
4182 json_style: dispatch.json_style,
4183 no_cache: dispatch.cli.no_cache,
4184 threads: dispatch.threads,
4185 quiet: dispatch.quiet,
4186 allow_remote_extends: dispatch.cli.allow_remote_extends,
4187 target: symbol,
4188 callers,
4189 callees,
4190 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4191 })
4192}
4193
4194fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4195 let Command::Security {
4196 subcommand,
4197 runtime_coverage,
4198 min_invocations_hot,
4199 file,
4200 gate,
4201 surface,
4202 } = command
4203 else {
4204 unreachable!("security dispatcher only handles security commands");
4205 };
4206
4207 let gate = gate.map(security::SecurityGateArg::into_mode);
4208 let cli = dispatch.cli;
4209 let (output, _quiet, fail_on_issues) =
4210 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4211 let derived_flags = SecurityDerivedFlagState {
4212 output,
4213 json_style: dispatch.json_style,
4214 ci: cli.ci,
4215 fail_on_issues,
4216 sarif_file: cli.sarif_file.as_deref(),
4217 summary: cli.summary,
4218 explain: cli.explain,
4219 runtime_coverage: runtime_coverage.as_deref(),
4220 min_invocations_hot,
4221 file: file.as_slice(),
4222 gate,
4223 surface,
4224 };
4225 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4226 return code;
4227 }
4228
4229 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4230 run_security_blind_spots_or_default(
4231 dispatch,
4232 &SecurityRunInputs {
4233 scoped_files: &scoped_files,
4234 subcommand: &subcommand,
4235 runtime_coverage: runtime_coverage.as_deref(),
4236 min_invocations_hot,
4237 gate,
4238 surface,
4239 },
4240 &derived_flags,
4241 )
4242}
4243
4244struct SecurityRunInputs<'a> {
4247 scoped_files: &'a [PathBuf],
4248 subcommand: &'a Option<SecuritySubcommand>,
4249 runtime_coverage: Option<&'a Path>,
4250 min_invocations_hot: u64,
4251 gate: Option<security::SecurityGateMode>,
4252 surface: bool,
4253}
4254
4255fn run_security_blind_spots_or_default(
4257 dispatch: &DispatchContext<'_>,
4258 inputs: &SecurityRunInputs<'_>,
4259 derived_flags: &SecurityDerivedFlagState<'_>,
4260) -> ExitCode {
4261 let cli = dispatch.cli;
4262 let (output, quiet, fail_on_issues) =
4263 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4264 let opts = security::SecurityOptions {
4265 root: dispatch.root,
4266 config_path: &cli.config,
4267 output,
4268 json_style: dispatch.json_style,
4269 no_cache: cli.no_cache,
4270 threads: dispatch.threads,
4271 quiet,
4272 allow_remote_extends: cli.allow_remote_extends,
4273 fail_on_issues,
4274 sarif_file: cli.sarif_file.as_deref(),
4275 summary: cli.summary,
4276 changed_since: cli.changed_since.as_deref(),
4277 use_shared_diff_index: true,
4278 workspace: cli.workspace.as_deref(),
4279 changed_workspaces: cli.changed_workspaces.as_deref(),
4280 file: inputs.scoped_files,
4281 surface: inputs.surface,
4282 gate: inputs.gate,
4283 runtime_coverage: inputs.runtime_coverage,
4284 min_invocations_hot: inputs.min_invocations_hot,
4285 explain: cli.explain,
4286 };
4287 if matches!(
4288 inputs.subcommand,
4289 Some(SecuritySubcommand::BlindSpots { .. })
4290 ) {
4291 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4292 return code;
4293 }
4294 security::run_blind_spots(&opts)
4295 } else {
4296 security::run(&opts)
4297 }
4298}
4299
4300fn try_run_security_survivors(
4303 subcommand: Option<&SecuritySubcommand>,
4304 flags: &SecurityDerivedFlagState<'_>,
4305) -> Option<ExitCode> {
4306 let Some(SecuritySubcommand::Survivors {
4307 candidates,
4308 verdicts,
4309 require_verdict_for_each_candidate,
4310 }) = subcommand
4311 else {
4312 return None;
4313 };
4314 if let Some(code) = validate_security_survivors_flags(flags) {
4315 return Some(code);
4316 }
4317 Some(security::run_survivors(
4318 &security::SecuritySurvivorsOptions {
4319 output: flags.output,
4320 json_style: flags.json_style,
4321 candidates,
4322 verdicts,
4323 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4324 },
4325 ))
4326}
4327
4328fn scoped_security_files(
4330 file: &[PathBuf],
4331 subcommand: Option<&SecuritySubcommand>,
4332) -> Vec<PathBuf> {
4333 let mut scoped_files = file.to_vec();
4334 if let Some(SecuritySubcommand::BlindSpots {
4335 file: blind_spot_files,
4336 }) = subcommand
4337 {
4338 scoped_files.extend(blind_spot_files.iter().cloned());
4339 }
4340 scoped_files
4341}
4342
4343struct SecurityDerivedFlagState<'a> {
4344 output: fallow_config::OutputFormat,
4345 json_style: json_style::JsonStyle,
4346 ci: bool,
4347 fail_on_issues: bool,
4348 sarif_file: Option<&'a Path>,
4349 summary: bool,
4350 explain: bool,
4351 runtime_coverage: Option<&'a Path>,
4352 min_invocations_hot: u64,
4353 file: &'a [PathBuf],
4354 gate: Option<security::SecurityGateMode>,
4355 surface: bool,
4356}
4357
4358fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4359 let flag = if flags.ci {
4360 Some("--ci")
4361 } else if flags.fail_on_issues {
4362 Some("--fail-on-issues")
4363 } else if flags.sarif_file.is_some() {
4364 Some("--sarif-file")
4365 } else if flags.summary {
4366 Some("--summary")
4367 } else if flags.explain {
4368 Some("--explain")
4369 } else if flags.runtime_coverage.is_some() {
4370 Some("--runtime-coverage")
4371 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4372 Some("--min-invocations-hot")
4373 } else if !flags.file.is_empty() {
4374 Some("--file")
4375 } else if flags.gate.is_some() {
4376 Some("--gate")
4377 } else if flags.surface {
4378 Some("--surface")
4379 } else {
4380 None
4381 }?;
4382 Some(emit_error(
4383 &format!("{flag} is not valid with `fallow security survivors`."),
4384 2,
4385 flags.output,
4386 ))
4387}
4388
4389fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4390 let flag = if flags.ci {
4391 Some("--ci")
4392 } else if flags.fail_on_issues {
4393 Some("--fail-on-issues")
4394 } else if flags.sarif_file.is_some() {
4395 Some("--sarif-file")
4396 } else if flags.summary {
4397 Some("--summary")
4398 } else if flags.explain {
4399 Some("--explain")
4400 } else if flags.runtime_coverage.is_some() {
4401 Some("--runtime-coverage")
4402 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4403 Some("--min-invocations-hot")
4404 } else if flags.gate.is_some() {
4405 Some("--gate")
4406 } else if flags.surface {
4407 Some("--surface")
4408 } else {
4409 None
4410 }?;
4411 Some(emit_error(
4412 &format!("{flag} is not valid with `fallow security blind-spots`."),
4413 2,
4414 flags.output,
4415 ))
4416}
4417
4418fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4419 let Command::Dupes {
4420 mode,
4421 near,
4422 min_tokens,
4423 min_lines,
4424 min_occurrences,
4425 threshold,
4426 skip_local,
4427 cross_language,
4428 ignore_imports,
4429 no_ignore_imports,
4430 top,
4431 no_fragments,
4432 trace,
4433 } = command
4434 else {
4435 unreachable!("dupes dispatcher only handles dupes commands");
4436 };
4437
4438 dispatch_dupes(
4439 dispatch,
4440 &DupesDispatchArgs {
4441 mode,
4442 near,
4443 min_tokens,
4444 min_lines,
4445 min_occurrences,
4446 threshold,
4447 skip_local,
4448 cross_language,
4449 ignore_imports,
4450 no_ignore_imports,
4451 top,
4452 no_fragments,
4453 trace,
4454 },
4455 )
4456}
4457
4458fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4459 run_agent_command(
4460 dispatch.root,
4461 dispatch.cli.root.is_some(),
4462 subcommand,
4463 dispatch.output,
4464 dispatch.json_style,
4465 )
4466}
4467
4468fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4469 let Command::Init {
4470 toml,
4471 agents,
4472 hooks,
4473 branch,
4474 decline,
4475 } = command
4476 else {
4477 unreachable!("init dispatcher only handles init commands");
4478 };
4479
4480 init::run_init(&init::InitOptions {
4481 root,
4482 use_toml: toml,
4483 agents,
4484 hooks,
4485 branch: branch.as_deref(),
4486 decline,
4487 quiet,
4488 })
4489}
4490
4491fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4492 let Command::Fix {
4493 dry_run,
4494 yes,
4495 no_create_config,
4496 } = command
4497 else {
4498 unreachable!("fix dispatcher only handles fix commands");
4499 };
4500
4501 dispatch_fix(
4502 dispatch,
4503 FixDispatchArgs {
4504 dry_run: *dry_run,
4505 yes: *yes,
4506 no_create_config: *no_create_config,
4507 },
4508 )
4509}
4510
4511fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4512 match command {
4513 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
4514 Command::List {
4515 entry_points,
4516 files,
4517 plugins,
4518 boundaries,
4519 workspaces,
4520 } => dispatch_list(
4521 dispatch,
4522 ListDispatchArgs {
4523 entry_points: *entry_points,
4524 files: *files,
4525 plugins: *plugins,
4526 boundaries: *boundaries,
4527 workspaces: *workspaces,
4528 },
4529 ),
4530 _ => unreachable!("list dispatcher only handles list commands"),
4531 }
4532}
4533
4534fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4535 let Command::Migrate {
4536 toml,
4537 jsonc,
4538 dry_run,
4539 from,
4540 } = command
4541 else {
4542 unreachable!("migrate dispatcher only handles migrate commands");
4543 };
4544
4545 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4546}
4547
4548fn dispatch_license_command(
4549 subcommand: LicenseCli,
4550 output: fallow_config::OutputFormat,
4551 json_style: json_style::JsonStyle,
4552) -> ExitCode {
4553 license::run(&map_license_subcommand(subcommand), output, json_style)
4554}
4555
4556fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4557 match subcommand {
4558 CiTemplateCli::Gitlab { vendor, force } => {
4559 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4560 vendor_dir: vendor,
4561 force,
4562 })
4563 }
4564 }
4565}
4566
4567fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4568 let cli = dispatch.cli;
4569 coverage::run(
4570 map_coverage_subcommand(subcommand, cli.explain),
4571 &coverage::RunContext {
4572 root: dispatch.root,
4573 config_path: &cli.config,
4574 output: dispatch.output,
4575 json_style: dispatch.json_style,
4576 quiet: dispatch.quiet,
4577 no_cache: cli.no_cache,
4578 threads: dispatch.threads,
4579 explain: cli.explain,
4580 allow_remote_extends: cli.allow_remote_extends,
4581 },
4582 )
4583}
4584
4585fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4586 let Command::Health {
4587 max_cyclomatic,
4588 max_cognitive,
4589 max_crap,
4590 top,
4591 sort,
4592 complexity,
4593 complexity_breakdown,
4594 file_scores,
4595 coverage_gaps,
4596 hotspots,
4597 ownership,
4598 ownership_emails,
4599 targets,
4600 type_coupling,
4601 css,
4602 effort,
4603 score,
4604 min_score,
4605 min_severity,
4606 report_only,
4607 since,
4608 min_commits,
4609 save_snapshot,
4610 trend,
4611 coverage,
4612 coverage_root,
4613 runtime_coverage,
4614 min_invocations_hot,
4615 min_observation_volume,
4616 low_traffic_threshold,
4617 } = command
4618 else {
4619 unreachable!("health dispatcher only handles health commands");
4620 };
4621
4622 let ownership = ownership || ownership_emails.is_some();
4623 let hotspots = hotspots || ownership;
4624 let args = HealthDispatchArgs {
4625 max_cyclomatic,
4626 max_cognitive,
4627 max_crap,
4628 top,
4629 sort,
4630 complexity,
4631 complexity_breakdown,
4632 file_scores,
4633 coverage_gaps,
4634 hotspots,
4635 ownership,
4636 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4637 targets,
4638 type_coupling,
4639 css,
4640 effort,
4641 score,
4642 min_score,
4643 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4644 report_only,
4645 since: since.as_deref(),
4646 min_commits,
4647 save_snapshot: save_snapshot.as_ref(),
4648 trend,
4649 coverage: coverage.as_deref(),
4650 coverage_root: coverage_root.as_deref(),
4651 runtime_coverage: runtime_coverage.as_deref(),
4652 min_invocations_hot,
4653 min_observation_volume,
4654 low_traffic_threshold,
4655 };
4656 dispatch_health(dispatch, &args)
4657}
4658
4659fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4660 let Command::SetupHooks {
4661 agent,
4662 dry_run,
4663 force,
4664 user,
4665 gitignore_claude,
4666 uninstall,
4667 } = command
4668 else {
4669 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4670 };
4671
4672 eprintln!(
4673 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
4674 );
4675 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4676 root: dispatch.root,
4677 agent: *agent,
4678 dry_run: *dry_run,
4679 force: *force,
4680 user: *user,
4681 gitignore_claude: *gitignore_claude,
4682 uninstall: *uninstall,
4683 })
4684}
4685
4686fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4687 let Command::Audit {
4688 production_dead_code,
4689 production_health,
4690 production_dupes,
4691 dead_code_baseline,
4692 health_baseline,
4693 dupes_baseline,
4694 max_crap,
4695 coverage,
4696 coverage_root,
4697 no_css,
4698 css_deep,
4699 no_css_deep,
4700 gate,
4701 runtime_coverage,
4702 min_invocations_hot,
4703 gate_marker,
4704 brief,
4705 max_decisions,
4706 walkthrough_guide,
4707 walkthrough_file,
4708 walkthrough,
4709 mark_viewed,
4710 show_cleared,
4711 show_deprioritized,
4712 } = command
4713 else {
4714 unreachable!("audit dispatcher only handles audit commands");
4715 };
4716
4717 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4720
4721 dispatch_audit(
4722 dispatch,
4723 &AuditDispatchArgs {
4724 production_dead_code,
4725 production_health,
4726 production_dupes,
4727 dead_code_baseline,
4728 health_baseline,
4729 dupes_baseline,
4730 max_crap,
4731 coverage,
4732 coverage_root,
4733 no_css,
4734 css_deep,
4735 no_css_deep,
4736 gate,
4737 runtime_coverage,
4738 min_invocations_hot,
4739 gate_marker,
4740 brief,
4741 max_decisions,
4742 walkthrough_guide,
4743 walkthrough_file,
4744 walkthrough,
4745 mark_viewed,
4746 show_cleared,
4747 show_deprioritized,
4748 },
4749 )
4750}
4751
4752fn dispatch_audit_cache_command(
4753 dispatch: &DispatchContext<'_>,
4754 subcommand: &AuditCacheCli,
4755) -> ExitCode {
4756 match subcommand {
4757 AuditCacheCli::Remove { dry_run, yes } => {
4758 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4759 return emit_error(
4760 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4761 2,
4762 dispatch.output,
4763 );
4764 }
4765 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4766 Ok(report) => {
4767 let action = if *dry_run { "would remove" } else { "removed" };
4768 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4769 let value = serde_json::json!({
4770 "kind": "audit-cache-remove",
4771 "schema_version": 1,
4772 "command": "audit-cache remove",
4773 "root": dispatch.root,
4774 "dry_run": report.dry_run,
4775 "found": report.found,
4776 "would_remove": report.found.saturating_sub(report.skipped),
4777 "removed": report.removed,
4778 "skipped": report.skipped,
4779 "complete": report.skipped == 0,
4780 });
4781 let output_code = report::emit_report_json(
4782 &value,
4783 "audit cache removal",
4784 dispatch.json_style,
4785 );
4786 if output_code != ExitCode::SUCCESS {
4787 return output_code;
4788 }
4789 } else if !dispatch.quiet {
4790 println!(
4791 "audit cache: {action} {}, skipped {} for {}",
4792 if *dry_run {
4793 report.found.saturating_sub(report.skipped)
4794 } else {
4795 report.removed
4796 },
4797 report.skipped,
4798 dispatch.root.display(),
4799 );
4800 }
4801 if report.skipped == 0 {
4802 ExitCode::SUCCESS
4803 } else {
4804 ExitCode::from(2)
4805 }
4806 }
4807 Err(error) => emit_error(
4808 &format!(
4809 "failed to remove audit caches for {}: {error}",
4810 dispatch.root.display()
4811 ),
4812 2,
4813 dispatch.output,
4814 ),
4815 }
4816 }
4817 AuditCacheCli::Prune {
4818 dry_run,
4819 max_age_days,
4820 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
4821 root: dispatch.root,
4822 config_path: dispatch.cli.config.as_ref(),
4823 allow_remote_extends: dispatch.cli.allow_remote_extends,
4824 dry_run: *dry_run,
4825 max_age_days: *max_age_days,
4826 output: dispatch.output,
4827 json_style: dispatch.json_style,
4828 quiet: dispatch.quiet,
4829 }),
4830 }
4831}
4832
4833fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4834 let cli = dispatch.cli;
4835 let root = dispatch.root;
4836 let output = dispatch.output;
4837 let quiet = dispatch.quiet;
4838 let threads = dispatch.threads;
4839 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4840 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4841 Err(code) => return code,
4842 };
4843 flags::run_flags(&flags::FlagsOptions {
4844 root,
4845 config_path: &cli.config,
4846 output,
4847 json_style: dispatch.json_style,
4848 no_cache: cli.no_cache,
4849 threads,
4850 quiet,
4851 allow_remote_extends: cli.allow_remote_extends,
4852 production,
4853 workspace: cli.workspace.as_deref(),
4854 changed_workspaces: cli.changed_workspaces.as_deref(),
4855 changed_since: cli.changed_since.as_deref(),
4856 explain: cli.explain,
4857 top,
4858 })
4859}
4860
4861fn dispatch_suppressions_command(
4862 dispatch: &DispatchContext<'_>,
4863 file: &[std::path::PathBuf],
4864) -> ExitCode {
4865 let cli = dispatch.cli;
4866 let root = dispatch.root;
4867 let output = dispatch.output;
4868 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4869 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4870 Err(code) => return code,
4871 };
4872 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
4873 root,
4874 config_path: &cli.config,
4875 output,
4876 json_style: dispatch.json_style,
4877 no_cache: cli.no_cache,
4878 threads: dispatch.threads,
4879 quiet: dispatch.quiet,
4880 allow_remote_extends: cli.allow_remote_extends,
4881 production,
4882 workspace: cli.workspace.as_deref(),
4883 changed_workspaces: cli.changed_workspaces.as_deref(),
4884 changed_since: cli.changed_since.as_deref(),
4885 file,
4886 })
4887}
4888
4889fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
4890 guard::run_guard(&guard::GuardOptions {
4891 root: dispatch.root,
4892 config_path: &dispatch.cli.config,
4893 output: dispatch.output,
4894 json_style: dispatch.json_style,
4895 quiet: dispatch.quiet,
4896 allow_remote_extends: dispatch.cli.allow_remote_extends,
4897 files,
4898 })
4899}
4900
4901fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
4902 let ctx = rule_pack::RulePackContext {
4903 root: dispatch.root,
4904 config_path: &dispatch.cli.config,
4905 output: dispatch.output,
4906 json_style: dispatch.json_style,
4907 quiet: dispatch.quiet,
4908 no_cache: dispatch.cli.no_cache,
4909 threads: Some(dispatch.threads),
4910 allow_remote_extends: dispatch.cli.allow_remote_extends,
4911 };
4912 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
4913}
4914
4915fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
4916 match subcommand {
4917 RulePackCli::Init {
4918 name,
4919 template,
4920 dir,
4921 no_config,
4922 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
4923 name,
4924 template,
4925 dir,
4926 no_config,
4927 }),
4928 RulePackCli::List => rule_pack::RulePackSubcommand::List,
4929 RulePackCli::Test { pack } => {
4930 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
4931 }
4932 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
4933 }
4934}
4935
4936fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
4937 match sub {
4938 LicenseCli::Activate {
4939 jwt,
4940 from_file,
4941 stdin,
4942 trial,
4943 email,
4944 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
4945 raw_jwt: jwt,
4946 from_file,
4947 from_stdin: stdin,
4948 trial,
4949 email,
4950 }),
4951 LicenseCli::Status => license::LicenseSubcommand::Status,
4952 LicenseCli::Refresh { api_key } => {
4953 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
4954 }
4955 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
4956 }
4957}
4958
4959fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
4960 match sub {
4961 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
4962 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
4963 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
4964 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
4965 }
4966}
4967
4968fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
4969 match sub {
4970 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
4971 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
4972 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
4973 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
4974 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
4975 }
4976}
4977
4978fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
4979 let CiCli::PlanPrComment {
4980 body,
4981 marker_id,
4982 clean,
4983 existing_comment_id,
4984 existing_body,
4985 } = command
4986 else {
4987 unreachable!("ci plan-pr-comment mapper called with different variant");
4988 };
4989
4990 ci::CiCommand::PlanPrComment {
4991 body,
4992 marker_id,
4993 clean,
4994 existing_comment_id,
4995 existing_body,
4996 }
4997}
4998
4999fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5000 let CiCli::PostPrComment {
5001 provider,
5002 pr,
5003 mr,
5004 body,
5005 envelope,
5006 marker_id,
5007 clean,
5008 repo,
5009 project_id,
5010 api_url,
5011 dry_run,
5012 } = command
5013 else {
5014 unreachable!("ci post-pr-comment mapper called with different variant");
5015 };
5016
5017 ci::CiCommand::PostPrComment {
5018 provider: map_ci_provider(provider),
5019 target: pr.or(mr),
5020 body,
5021 envelope,
5022 marker_id,
5023 clean,
5024 repo,
5025 project_id,
5026 api_url,
5027 dry_run,
5028 }
5029}
5030
5031fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5032 let CiCli::PostReview {
5033 provider,
5034 pr,
5035 mr,
5036 envelope,
5037 repo,
5038 project_id,
5039 api_url,
5040 dry_run,
5041 } = command
5042 else {
5043 unreachable!("ci post-review mapper called with different variant");
5044 };
5045
5046 ci::CiCommand::PostReview {
5047 provider: map_ci_provider(provider),
5048 target: pr.or(mr),
5049 envelope,
5050 repo,
5051 project_id,
5052 api_url,
5053 dry_run,
5054 }
5055}
5056
5057fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5058 let CiCli::PostCheckRun {
5059 provider,
5060 decision,
5061 repo,
5062 head_sha,
5063 api_url,
5064 split_gates,
5065 dry_run,
5066 } = command
5067 else {
5068 unreachable!("ci post-check-run mapper called with different variant");
5069 };
5070
5071 ci::CiCommand::PostCheckRun {
5072 provider: map_ci_provider(provider),
5073 decision,
5074 repo,
5075 head_sha,
5076 api_url,
5077 split_gates,
5078 dry_run,
5079 }
5080}
5081
5082fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5083 let CiCli::ReconcileReview {
5084 provider,
5085 pr,
5086 mr,
5087 envelope,
5088 repo,
5089 project_id,
5090 api_url,
5091 dry_run,
5092 } = command
5093 else {
5094 unreachable!("ci reconcile-review mapper called with different variant");
5095 };
5096
5097 ci::CiCommand::ReconcileReview {
5098 provider: map_ci_provider(provider),
5099 target: pr.or(mr),
5100 envelope,
5101 repo,
5102 project_id,
5103 api_url,
5104 dry_run,
5105 }
5106}
5107
5108fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5109 match provider {
5110 CiProviderArg::Github => ci::CiProvider::Github,
5111 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5112 }
5113}
5114
5115fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
5116 match sub {
5117 CoverageCli::Setup {
5118 yes,
5119 non_interactive,
5120 json,
5121 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5122 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5123 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5124 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5125 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5126 }
5127}
5128
5129fn map_coverage_setup(
5130 yes: bool,
5131 non_interactive: bool,
5132 json: bool,
5133 explain: bool,
5134) -> coverage::CoverageSubcommand {
5135 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5136 yes,
5137 non_interactive: non_interactive || json,
5138 json,
5139 explain,
5140 })
5141}
5142
5143fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5144 let CoverageCli::Analyze {
5145 runtime_coverage,
5146 cloud,
5147 api_key,
5148 api_endpoint,
5149 repo,
5150 project_id,
5151 coverage_period,
5152 environment,
5153 commit_sha,
5154 production,
5155 min_invocations_hot,
5156 min_observation_volume,
5157 low_traffic_threshold,
5158 top,
5159 blast_radius,
5160 importance,
5161 debug_unmatched,
5162 } = sub
5163 else {
5164 unreachable!("coverage analyze mapper called with non-analyze variant");
5165 };
5166 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5167 runtime_coverage: runtime_coverage.clone(),
5168 cloud: *cloud,
5169 api_key: api_key.clone(),
5170 api_endpoint: api_endpoint.clone(),
5171 repo: repo.clone(),
5172 project_id: project_id.clone(),
5173 coverage_period: *coverage_period,
5174 environment: environment.clone(),
5175 commit_sha: commit_sha.clone(),
5176 production: *production,
5177 min_invocations_hot: *min_invocations_hot,
5178 min_observation_volume: *min_observation_volume,
5179 low_traffic_threshold: *low_traffic_threshold,
5180 top: *top,
5181 blast_radius: *blast_radius,
5182 importance: *importance,
5183 debug_unmatched: *debug_unmatched,
5184 })
5185}
5186
5187fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5188 let CoverageCli::UploadInventory {
5189 api_key,
5190 api_endpoint,
5191 project_id,
5192 git_sha,
5193 allow_dirty,
5194 exclude_paths,
5195 path_prefix,
5196 dry_run,
5197 with_callers,
5198 ignore_upload_errors,
5199 } = sub
5200 else {
5201 unreachable!("coverage inventory mapper called with non-inventory variant");
5202 };
5203 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5204 api_key: api_key.clone(),
5205 api_endpoint: api_endpoint.clone(),
5206 project_id: project_id.clone(),
5207 git_sha: git_sha.clone(),
5208 allow_dirty: *allow_dirty,
5209 exclude_paths: exclude_paths.clone(),
5210 path_prefix: path_prefix.clone(),
5211 dry_run: *dry_run,
5212 with_callers: *with_callers,
5213 ignore_upload_errors: *ignore_upload_errors,
5214 })
5215}
5216
5217fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5218 let CoverageCli::UploadSourceMaps {
5219 dir,
5220 include,
5221 exclude,
5222 repo,
5223 git_sha,
5224 endpoint,
5225 strip_path,
5226 dry_run,
5227 concurrency,
5228 fail_fast,
5229 } = sub
5230 else {
5231 unreachable!("coverage source-map mapper called with non-source-map variant");
5232 };
5233 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5234 dir: dir.clone(),
5235 include: include.clone(),
5236 exclude: exclude.clone(),
5237 repo: repo.clone(),
5238 git_sha: git_sha.clone(),
5239 endpoint: endpoint.clone(),
5240 strip_path: *strip_path,
5241 dry_run: *dry_run,
5242 concurrency: *concurrency,
5243 fail_fast: *fail_fast,
5244 })
5245}
5246
5247fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5248 let CoverageCli::UploadStaticFindings {
5249 api_key,
5250 api_endpoint,
5251 project_id,
5252 git_sha,
5253 allow_dirty,
5254 dry_run,
5255 ignore_upload_errors,
5256 } = sub
5257 else {
5258 unreachable!("coverage static-findings mapper called with non-static variant");
5259 };
5260 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5261 api_key: api_key.clone(),
5262 api_endpoint: api_endpoint.clone(),
5263 project_id: project_id.clone(),
5264 git_sha: git_sha.clone(),
5265 allow_dirty: *allow_dirty,
5266 dry_run: *dry_run,
5267 ignore_upload_errors: *ignore_upload_errors,
5268 })
5269}
5270
5271struct CheckDispatchArgs {
5272 filters: IssueFilters,
5273 trace_opts: TraceOptions,
5274 include_dupes: bool,
5275 type_aware: Option<bool>,
5276 type_aware_project: Vec<std::path::PathBuf>,
5277 type_aware_require: Option<TypeAwareRequireArg>,
5278 top: Option<usize>,
5279 file: Vec<std::path::PathBuf>,
5280}
5281
5282#[derive(Clone, Copy)]
5283struct ListDispatchArgs {
5284 entry_points: bool,
5285 files: bool,
5286 plugins: bool,
5287 boundaries: bool,
5288 workspaces: bool,
5289}
5290
5291impl ListDispatchArgs {
5292 fn workspaces() -> Self {
5293 Self {
5294 entry_points: false,
5295 files: false,
5296 plugins: false,
5297 boundaries: false,
5298 workspaces: true,
5299 }
5300 }
5301}
5302
5303fn dispatch_viz(
5304 dispatch: &DispatchContext<'_>,
5305 output_path: Option<&std::path::Path>,
5306 no_open: bool,
5307 format: viz::VizFormat,
5308) -> ExitCode {
5309 let cli = dispatch.cli;
5310 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5311 Ok(production) => production,
5312 Err(code) => return code,
5313 };
5314 viz::run_viz(&viz::VizOptions {
5315 root: dispatch.root,
5316 config_path: &cli.config,
5317 no_cache: cli.no_cache,
5318 threads: dispatch.threads,
5319 quiet: dispatch.quiet,
5320 production,
5321 allow_remote_extends: cli.allow_remote_extends,
5322 output_path,
5323 no_open,
5324 format,
5325 })
5326}
5327
5328fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5329 let cli = dispatch.cli;
5330 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5331 Ok(production) => production,
5332 Err(code) => return code,
5333 };
5334 watch::run_watch(&watch::WatchOptions {
5335 root: dispatch.root,
5336 config_path: &cli.config,
5337 output: dispatch.output,
5338 json_style: dispatch.json_style,
5339 no_cache: cli.no_cache,
5340 threads: dispatch.threads,
5341 quiet: dispatch.quiet,
5342 allow_remote_extends: cli.allow_remote_extends,
5343 production,
5344 clear_screen: !no_clear,
5345 explain: cli.explain,
5346 include_entry_exports: cli.include_entry_exports,
5347 type_aware: cli.type_aware_override(),
5348 type_aware_projects: &cli.type_aware_project,
5349 type_aware_require: cli.type_aware_require.map(Into::into),
5350 })
5351}
5352
5353#[derive(Clone, Copy)]
5354struct FixDispatchArgs {
5355 dry_run: bool,
5356 yes: bool,
5357 no_create_config: bool,
5358}
5359
5360fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
5361 let cli = dispatch.cli;
5362 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5363 Ok(production) => production,
5364 Err(code) => return code,
5365 };
5366 fix::run_fix(&fix::FixOptions {
5367 root: dispatch.root,
5368 config_path: &cli.config,
5369 output: dispatch.output,
5370 json_style: dispatch.json_style,
5371 no_cache: cli.no_cache,
5372 threads: dispatch.threads,
5373 quiet: dispatch.quiet,
5374 emit_output: true,
5375 allow_remote_extends: cli.allow_remote_extends,
5376 dry_run: args.dry_run,
5377 yes: args.yes,
5378 production,
5379 no_create_config: args.no_create_config,
5380 type_aware: cli.type_aware_override(),
5381 type_aware_projects: &cli.type_aware_project,
5382 type_aware_require: cli.type_aware_require.map(Into::into),
5383 })
5384}
5385
5386fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
5387 let cli = dispatch.cli;
5388 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5389 Ok(production) => production,
5390 Err(code) => return code,
5391 };
5392 list::run_list(&ListOptions {
5393 root: dispatch.root,
5394 config_path: &cli.config,
5395 output: dispatch.output,
5396 json_style: dispatch.json_style,
5397 threads: dispatch.threads,
5398 no_cache: cli.no_cache,
5399 entry_points: args.entry_points,
5400 files: args.files,
5401 plugins: args.plugins,
5402 boundaries: args.boundaries,
5403 workspaces: args.workspaces,
5404 production,
5405 allow_remote_extends: cli.allow_remote_extends,
5406 })
5407}
5408
5409fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5410 let cli = dispatch.cli;
5411 let (output, quiet, fail_on_issues) =
5412 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5413 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5414 Ok(production) => production,
5415 Err(code) => return code,
5416 };
5417 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5418 return code;
5419 }
5420 check::run_check(&CheckOptions {
5421 root: dispatch.root,
5422 config_path: &cli.config,
5423 output,
5424 json_style: dispatch.json_style,
5425 no_cache: cli.no_cache,
5426 threads: dispatch.threads,
5427 quiet,
5428 allow_remote_extends: cli.allow_remote_extends,
5429 fail_on_issues,
5430 filters: &args.filters,
5431 changed_since: cli.changed_since.as_deref(),
5432 diff_index: None,
5433 use_shared_diff_index: true,
5434 baseline: cli.baseline.as_deref(),
5435 save_baseline: cli.save_baseline.as_deref(),
5436 sarif_file: cli.sarif_file.as_deref(),
5437 production,
5438 production_override: Some(production),
5439 workspace: cli.workspace.as_deref(),
5440 changed_workspaces: cli.changed_workspaces.as_deref(),
5441 group_by: cli.group_by,
5442 include_dupes: args.include_dupes,
5443 type_aware: args.type_aware,
5444 type_aware_config_override: None,
5445 type_aware_projects: &args.type_aware_project,
5446 type_aware_require: args.type_aware_require.map(Into::into),
5447 trace_opts: &args.trace_opts,
5448 explain: cli.explain,
5449 top: args.top,
5450 file: &args.file,
5451 include_entry_exports: cli.include_entry_exports,
5452 summary: cli.summary,
5453 regression_opts: dispatch.regression_opts(
5454 cli.changed_since.is_some()
5455 || cli.workspace.is_some()
5456 || cli.changed_workspaces.is_some()
5457 || !args.file.is_empty(),
5458 ),
5459 retain_modules_for_health: false,
5460 defer_performance: false,
5461 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5462 })
5463}
5464
5465fn validate_type_aware_check_options(
5466 dispatch: &DispatchContext<'_>,
5467 args: &CheckDispatchArgs,
5468) -> Option<ExitCode> {
5469 let output = dispatch.output;
5470 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5471 return Some(emit_error(
5472 "--type-aware-project requires --type-aware",
5473 2,
5474 output,
5475 ));
5476 }
5477 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5478 return Some(emit_error(
5479 "--type-aware-require requires --type-aware",
5480 2,
5481 output,
5482 ));
5483 }
5484 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5485 return Some(emit_error(
5486 "--symbol-impact requires --type-aware",
5487 2,
5488 output,
5489 ));
5490 }
5491 let focused_output = args.trace_opts.trace_export.is_some()
5492 || args.trace_opts.trace_file.is_some()
5493 || args.trace_opts.trace_dependency.is_some()
5494 || args.trace_opts.impact_closure.is_some()
5495 || args.trace_opts.symbol_impact.is_some();
5496 if focused_output
5497 && !matches!(
5498 output,
5499 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5500 )
5501 {
5502 return Some(emit_error(
5503 "focused trace and impact queries support human and JSON output",
5504 2,
5505 output,
5506 ));
5507 }
5508 if args.type_aware == Some(true)
5509 && !matches!(
5510 output,
5511 fallow_config::OutputFormat::Human
5512 | fallow_config::OutputFormat::Json
5513 | fallow_config::OutputFormat::Sarif
5514 | fallow_config::OutputFormat::Compact
5515 | fallow_config::OutputFormat::Markdown
5516 | fallow_config::OutputFormat::CodeClimate
5517 | fallow_config::OutputFormat::PrCommentGithub
5518 | fallow_config::OutputFormat::PrCommentGitlab
5519 | fallow_config::OutputFormat::ReviewGithub
5520 | fallow_config::OutputFormat::ReviewGitlab
5521 )
5522 {
5523 return Some(emit_error(
5524 "--type-aware supports human, JSON, SARIF, compact, markdown, CodeClimate, PR-comment, and review output; pair presentation formats with the JSON artifact to preserve semantic provenance",
5525 2,
5526 output,
5527 ));
5528 }
5529 None
5530}
5531
5532fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5538 if no_ignore_imports {
5539 Some(false)
5540 } else if ignore_imports {
5541 Some(true)
5542 } else {
5543 None
5544 }
5545}
5546
5547struct DupesDispatchArgs {
5548 mode: Option<DupesMode>,
5549 near: bool,
5550 min_tokens: Option<usize>,
5551 min_lines: Option<usize>,
5552 min_occurrences: Option<usize>,
5553 threshold: Option<f64>,
5554 skip_local: bool,
5555 cross_language: bool,
5556 ignore_imports: bool,
5557 no_ignore_imports: bool,
5558 top: Option<usize>,
5559 no_fragments: bool,
5560 trace: Option<String>,
5561}
5562
5563fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5564 let cli = dispatch.cli;
5565 let (output, quiet, _fail_on_issues) =
5566 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5567 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5568 Ok(production) => production,
5569 Err(code) => return code,
5570 };
5571 dupes::run_dupes(&DupesOptions {
5572 root: dispatch.root,
5573 config_path: &cli.config,
5574 output,
5575 json_style: dispatch.json_style,
5576 no_cache: cli.no_cache,
5577 threads: dispatch.threads,
5578 quiet,
5579 allow_remote_extends: cli.allow_remote_extends,
5580 mode: args.mode,
5581 near: args.near,
5582 min_tokens: args.min_tokens,
5583 min_lines: args.min_lines,
5584 min_occurrences: args.min_occurrences,
5585 threshold: args.threshold,
5586 skip_local: args.skip_local,
5587 cross_language: args.cross_language,
5588 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
5589 top: args.top,
5590 baseline_path: cli.baseline.as_deref(),
5591 save_baseline_path: cli.save_baseline.as_deref(),
5592 production,
5593 production_override: Some(production),
5594 trace: args.trace.as_deref(),
5595 changed_since: cli.changed_since.as_deref(),
5596 diff_index: None,
5597 use_shared_diff_index: true,
5598 changed_files: None,
5599 workspace: cli.workspace.as_deref(),
5600 changed_workspaces: cli.changed_workspaces.as_deref(),
5601 explain: cli.explain,
5602 explain_skipped: cli.explain_skipped,
5603 summary: cli.summary,
5604 group_by: cli.group_by,
5605 performance: cli.performance,
5606 include_fragments: !args.no_fragments,
5607 })
5608}
5609
5610struct AuditDispatchArgs {
5611 production_dead_code: bool,
5612 production_health: bool,
5613 production_dupes: bool,
5614 dead_code_baseline: Option<PathBuf>,
5615 health_baseline: Option<PathBuf>,
5616 dupes_baseline: Option<PathBuf>,
5617 max_crap: Option<f64>,
5618 coverage: Option<PathBuf>,
5619 coverage_root: Option<PathBuf>,
5620 no_css: bool,
5621 css_deep: bool,
5622 no_css_deep: bool,
5623 gate: Option<AuditGateArg>,
5624 runtime_coverage: Option<PathBuf>,
5625 min_invocations_hot: u64,
5626 gate_marker: Option<String>,
5627 brief: bool,
5628 max_decisions: usize,
5629 walkthrough_guide: bool,
5631 walkthrough_file: Option<PathBuf>,
5634 walkthrough: bool,
5636 mark_viewed: Vec<PathBuf>,
5638 show_cleared: bool,
5640 show_deprioritized: bool,
5642}
5643
5644struct ResolvedAuditInputs {
5645 audit_cfg: fallow_config::AuditConfig,
5646 cache_dir: PathBuf,
5647 production: ProductionModes,
5648 dead_code_baseline: Option<PathBuf>,
5649 health_baseline: Option<PathBuf>,
5650 dupes_baseline: Option<PathBuf>,
5651 coverage: Option<PathBuf>,
5655 coverage_root: Option<PathBuf>,
5656}
5657
5658fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
5659 let cli = dispatch.cli;
5660 let output = dispatch.output;
5661
5662 if cli.baseline.is_some() || cli.save_baseline.is_some() {
5663 return emit_error(
5664 "audit uses per-analysis baselines. Use --dead-code-baseline, --health-baseline, or --dupes-baseline (or save them with `fallow dead-code|health|dupes --save-baseline <file>`)",
5665 2,
5666 output,
5667 );
5668 }
5669
5670 let inputs = match resolve_audit_inputs(dispatch, args) {
5671 Ok(inputs) => inputs,
5672 Err(code) => return code,
5673 };
5674
5675 run_resolved_audit(dispatch, args, &inputs)
5676}
5677
5678fn resolve_audit_inputs(
5679 dispatch: &DispatchContext<'_>,
5680 args: &AuditDispatchArgs,
5681) -> Result<ResolvedAuditInputs, ExitCode> {
5682 let cli = dispatch.cli;
5683 let root = dispatch.root;
5684 let output = dispatch.output;
5685 let config = load_config(
5686 root,
5687 &cli.config,
5688 LoadConfigArgs {
5689 output,
5690 no_cache: cli.no_cache,
5691 threads: dispatch.threads,
5692 production: cli.production,
5693 quiet: dispatch.quiet,
5694 allow_remote_extends: cli.allow_remote_extends,
5695 },
5696 )?;
5697 let cache_dir = config.cache_dir.clone();
5698 let audit_cfg = config.audit;
5699 let production = resolve_production_modes(
5700 cli,
5701 root,
5702 output,
5703 args.production_dead_code,
5704 args.production_health,
5705 args.production_dupes,
5706 )?;
5707 let resolved_dead_code_baseline = resolve_audit_baseline_path(
5708 root,
5709 args.dead_code_baseline.as_deref(),
5710 audit_cfg.dead_code_baseline.as_deref(),
5711 );
5712 let resolved_health_baseline = resolve_audit_baseline_path(
5713 root,
5714 args.health_baseline.as_deref(),
5715 audit_cfg.health_baseline.as_deref(),
5716 );
5717 let resolved_dupes_baseline = resolve_audit_baseline_path(
5718 root,
5719 args.dupes_baseline.as_deref(),
5720 audit_cfg.dupes_baseline.as_deref(),
5721 );
5722 let coverage_inputs = resolve_coverage_inputs(
5723 args.coverage.as_deref(),
5724 args.coverage_root.as_deref(),
5725 output,
5726 || Ok(config.health),
5727 )?;
5728
5729 Ok(ResolvedAuditInputs {
5730 audit_cfg,
5731 cache_dir,
5732 production,
5733 dead_code_baseline: resolved_dead_code_baseline,
5734 health_baseline: resolved_health_baseline,
5735 dupes_baseline: resolved_dupes_baseline,
5736 coverage: coverage_inputs.coverage,
5737 coverage_root: coverage_inputs.coverage_root,
5738 })
5739}
5740
5741fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5742 !args.no_css && config.css.unwrap_or(true)
5743}
5744
5745fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5746 audit_css_enabled(config, args)
5747 && !args.no_css_deep
5748 && (args.css_deep || config.css_deep.unwrap_or(true))
5749}
5750
5751fn run_resolved_audit(
5752 dispatch: &DispatchContext<'_>,
5753 args: &AuditDispatchArgs,
5754 inputs: &ResolvedAuditInputs,
5755) -> ExitCode {
5756 let cli = dispatch.cli;
5757 audit::run_audit_with_type_aware(
5758 &audit::AuditOptions {
5759 root: dispatch.root,
5760 config_path: &cli.config,
5761 cache_dir: &inputs.cache_dir,
5762 output: dispatch.output,
5763 json_style: dispatch.json_style,
5764 no_cache: cli.no_cache,
5765 threads: dispatch.threads,
5766 quiet: dispatch.quiet,
5767 allow_remote_extends: cli.allow_remote_extends,
5768 changed_since: cli.changed_since.as_deref(),
5769 production: cli.production,
5770 production_dead_code: Some(inputs.production.dead_code),
5771 production_health: Some(inputs.production.health),
5772 production_dupes: Some(inputs.production.dupes),
5773 workspace: cli.workspace.as_deref(),
5774 changed_workspaces: cli.changed_workspaces.as_deref(),
5775 explain: cli.explain,
5776 explain_skipped: cli.explain_skipped,
5777 performance: cli.performance,
5778 group_by: cli.group_by,
5779 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5780 health_baseline: inputs.health_baseline.as_deref(),
5781 dupes_baseline: inputs.dupes_baseline.as_deref(),
5782 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5783 max_crap: args.max_crap,
5784 coverage: inputs.coverage.as_deref(),
5785 coverage_root: inputs.coverage_root.as_deref(),
5786 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5787 include_entry_exports: cli.include_entry_exports,
5788 css: audit_css_enabled(&inputs.audit_cfg, args),
5792 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5793 runtime_coverage: args.runtime_coverage.as_deref(),
5794 min_invocations_hot: args.min_invocations_hot,
5795 brief: args.brief,
5796 max_decisions: args.max_decisions,
5797 walkthrough_guide: args.walkthrough_guide,
5798 walkthrough: args.walkthrough,
5799 mark_viewed: &args.mark_viewed,
5800 show_cleared: args.show_cleared,
5801 walkthrough_file: args.walkthrough_file.as_deref(),
5802 show_deprioritized: args.show_deprioritized,
5803 },
5804 args.gate_marker.as_deref(),
5805 audit::AuditTypeAwareOptions {
5806 enabled: cli.type_aware_override(),
5807 config_default: inputs.audit_cfg.type_aware,
5808 projects: &cli.type_aware_project,
5809 require: cli.type_aware_require.map(Into::into),
5810 },
5811 )
5812}
5813
5814fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5818 let args = decision_surface_audit_args(max_decisions);
5819 let inputs = match resolve_audit_inputs(dispatch, &args) {
5820 Ok(inputs) => inputs,
5821 Err(code) => return code,
5822 };
5823 audit::run_decision_surface(&decision_surface_audit_options(
5824 dispatch,
5825 &inputs,
5826 max_decisions,
5827 ))
5828}
5829
5830fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
5831 AuditDispatchArgs {
5832 production_dead_code: false,
5833 production_health: false,
5834 production_dupes: false,
5835 dead_code_baseline: None,
5836 health_baseline: None,
5837 dupes_baseline: None,
5838 max_crap: None,
5839 coverage: None,
5840 coverage_root: None,
5841 no_css: true,
5842 css_deep: false,
5843 no_css_deep: false,
5844 gate: None,
5845 runtime_coverage: None,
5846 min_invocations_hot: 0,
5847 gate_marker: None,
5848 brief: true,
5849 max_decisions,
5850 walkthrough_guide: false,
5851 walkthrough_file: None,
5852 walkthrough: false,
5853 mark_viewed: Vec::new(),
5854 show_cleared: false,
5855 show_deprioritized: false,
5856 }
5857}
5858
5859fn decision_surface_audit_options<'a>(
5860 dispatch: &'a DispatchContext<'a>,
5861 inputs: &'a ResolvedAuditInputs,
5862 max_decisions: usize,
5863) -> audit::AuditOptions<'a> {
5864 let cli = dispatch.cli;
5865 audit::AuditOptions {
5866 root: dispatch.root,
5867 config_path: &cli.config,
5868 cache_dir: &inputs.cache_dir,
5869 output: dispatch.output,
5870 json_style: dispatch.json_style,
5871 no_cache: cli.no_cache,
5872 threads: dispatch.threads,
5873 quiet: dispatch.quiet,
5874 allow_remote_extends: cli.allow_remote_extends,
5875 changed_since: cli.changed_since.as_deref(),
5876 production: cli.production,
5877 production_dead_code: Some(inputs.production.dead_code),
5878 production_health: Some(inputs.production.health),
5879 production_dupes: Some(inputs.production.dupes),
5880 workspace: cli.workspace.as_deref(),
5881 changed_workspaces: cli.changed_workspaces.as_deref(),
5882 explain: cli.explain,
5883 explain_skipped: cli.explain_skipped,
5884 performance: cli.performance,
5885 group_by: cli.group_by,
5886 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5887 health_baseline: inputs.health_baseline.as_deref(),
5888 dupes_baseline: inputs.dupes_baseline.as_deref(),
5889 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5890 max_crap: None,
5891 coverage: None,
5892 coverage_root: None,
5893 gate: inputs.audit_cfg.gate,
5894 include_entry_exports: cli.include_entry_exports,
5895 css: false,
5897 css_deep: false,
5898 runtime_coverage: None,
5899 min_invocations_hot: 0,
5900 brief: true,
5901 max_decisions,
5902 walkthrough_guide: false,
5903 walkthrough: false,
5904 mark_viewed: &[],
5905 show_cleared: false,
5906 walkthrough_file: None,
5907 show_deprioritized: false,
5908 }
5909}
5910
5911struct HealthDispatchArgs<'a> {
5912 max_cyclomatic: Option<u16>,
5913 max_cognitive: Option<u16>,
5914 max_crap: Option<f64>,
5915 top: Option<usize>,
5916 sort: health::SortBy,
5917 complexity: bool,
5918 complexity_breakdown: bool,
5919 file_scores: bool,
5920 coverage_gaps: bool,
5921 hotspots: bool,
5922 ownership: bool,
5923 ownership_emails: Option<fallow_config::EmailMode>,
5924 targets: bool,
5925 type_coupling: bool,
5926 css: bool,
5927 effort: Option<EffortFilter>,
5928 score: bool,
5929 min_score: Option<f64>,
5930 min_severity: Option<fallow_output::FindingSeverity>,
5931 report_only: bool,
5932 since: Option<&'a str>,
5933 min_commits: Option<u32>,
5934 save_snapshot: Option<&'a Option<String>>,
5935 trend: bool,
5936 coverage: Option<&'a std::path::Path>,
5937 coverage_root: Option<&'a std::path::Path>,
5938 runtime_coverage: Option<&'a std::path::Path>,
5939 min_invocations_hot: u64,
5940 min_observation_volume: Option<u32>,
5941 low_traffic_threshold: Option<f64>,
5942}
5943
5944type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
5945
5946fn resolve_coverage_inputs(
5957 cli_coverage: Option<&std::path::Path>,
5958 cli_coverage_root: Option<&std::path::Path>,
5959 output: fallow_config::OutputFormat,
5960 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
5961) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5962 let explicit = fallow_api::CoverageInputs {
5963 coverage: cli_coverage.map(std::path::Path::to_path_buf),
5964 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
5965 };
5966 let env = fallow_api::CoverageInputs {
5967 coverage: path_from_env("FALLOW_COVERAGE"),
5968 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
5969 };
5970 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
5971 Some(config_health()?)
5972 } else {
5973 None
5974 };
5975
5976 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
5977 .map_err(|err| emit_error(&err.to_string(), 2, output))
5978}
5979
5980fn resolve_health_coverage_inputs(
5983 dispatch: &DispatchContext<'_>,
5984 cli_coverage: Option<&std::path::Path>,
5985 cli_coverage_root: Option<&std::path::Path>,
5986) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5987 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
5988 Ok(load_config(
5989 dispatch.root,
5990 &dispatch.cli.config,
5991 LoadConfigArgs {
5992 output: dispatch.output,
5993 no_cache: dispatch.cli.no_cache,
5994 threads: dispatch.threads,
5995 production: dispatch.cli.production,
5996 quiet: dispatch.quiet,
5997 allow_remote_extends: dispatch.cli.allow_remote_extends,
5998 },
5999 )?
6000 .health)
6001 })
6002}
6003
6004fn path_from_env(name: &str) -> Option<PathBuf> {
6005 std::env::var_os(name)
6006 .filter(|value| !value.is_empty())
6007 .map(PathBuf::from)
6008}
6009
6010fn validate_health_report_only_gate(
6011 report_only: bool,
6012 min_score: Option<f64>,
6013 min_severity: Option<fallow_output::FindingSeverity>,
6014 output: fallow_config::OutputFormat,
6015) -> Result<(), ExitCode> {
6016 if report_only && (min_score.is_some() || min_severity.is_some()) {
6017 return Err(emit_error(
6018 "--report-only cannot be combined with --min-score or --min-severity. \
6019 --report-only always exits 0; drop it to gate on score/severity, or \
6020 drop the gate flags to stay advisory.",
6021 2,
6022 output,
6023 ));
6024 }
6025
6026 Ok(())
6027}
6028
6029fn resolve_runtime_coverage_options(
6030 runtime_coverage: Option<&std::path::Path>,
6031 min_invocations_hot: u64,
6032 min_observation_volume: Option<u32>,
6033 low_traffic_threshold: Option<f64>,
6034 output: fallow_config::OutputFormat,
6035) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6036 let Some(path) = runtime_coverage else {
6037 return Ok(None);
6038 };
6039
6040 health::coverage::prepare_options(
6041 path,
6042 min_invocations_hot,
6043 min_observation_volume,
6044 low_traffic_threshold,
6045 output,
6046 )
6047 .map(Some)
6048}
6049
6050fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6051 let cli = dispatch.cli;
6052 let root = dispatch.root;
6053 let (output, _quiet, _fail_on_issues) =
6054 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6055 if let Err(code) = validate_health_report_only_gate(
6056 args.report_only,
6057 args.min_score,
6058 args.min_severity,
6059 output,
6060 ) {
6061 return code;
6062 }
6063 let runtime_coverage = match resolve_runtime_coverage_options(
6064 args.runtime_coverage,
6065 args.min_invocations_hot,
6066 args.min_observation_volume,
6067 args.low_traffic_threshold,
6068 output,
6069 ) {
6070 Ok(options) => options,
6071 Err(code) => return code,
6072 };
6073 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6074 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6075 Err(code) => return code,
6076 };
6077 let coverage_inputs =
6078 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6079 Ok(inputs) => inputs,
6080 Err(code) => return code,
6081 };
6082 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6083 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
6084}
6085
6086fn derive_health_dispatch_run<'a>(
6087 args: &'a HealthDispatchArgs<'a>,
6088 output: fallow_config::OutputFormat,
6089 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6090 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6091) -> fallow_engine::health::HealthRunOptions<'a> {
6092 let mut run = fallow_engine::health::derive_health_run_options(
6093 fallow_engine::health::HealthRunOptionsInput {
6094 output,
6095 thresholds: health_threshold_overrides(args),
6096 top: args.top,
6097 sort: args.sort.clone().into(),
6098 complexity: args.complexity,
6099 file_scores: args.file_scores,
6100 coverage_gaps: args.coverage_gaps,
6101 hotspots: args.hotspots,
6102 ownership: args.ownership,
6103 ownership_emails: args.ownership_emails,
6104 targets: args.targets,
6105 css: args.css,
6106 effort: args.effort.map(EffortFilter::to_estimate),
6107 score: args.score,
6108 gates: health_gate_options(args),
6109 snapshot_requested: args.save_snapshot.is_some(),
6110 trend: args.trend,
6111 since: args.since,
6112 min_commits: args.min_commits,
6113 coverage_inputs: health_coverage_inputs(coverage_inputs),
6114 runtime_coverage,
6115 },
6116 );
6117 if args.type_coupling && !run.sections.any_section {
6118 run.sections = fallow_engine::health::DerivedHealthSections {
6119 any_section: true,
6120 complexity: false,
6121 file_scores: false,
6122 coverage_gaps: false,
6123 hotspots: false,
6124 targets: false,
6125 css: false,
6126 score: false,
6127 force_full: false,
6128 score_only_output: false,
6129 };
6130 }
6131 run
6132}
6133
6134fn health_threshold_overrides(
6135 args: &HealthDispatchArgs<'_>,
6136) -> fallow_engine::health::HealthThresholdOverrides {
6137 fallow_engine::health::HealthThresholdOverrides {
6138 max_cyclomatic: args.max_cyclomatic,
6139 max_cognitive: args.max_cognitive,
6140 max_crap: args.max_crap,
6141 }
6142}
6143
6144fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
6145 fallow_engine::health::HealthGateOptions {
6146 min_score: args.min_score,
6147 min_severity: args.min_severity,
6148 report_only: args.report_only,
6149 }
6150}
6151
6152fn health_coverage_inputs(
6153 coverage_inputs: &ResolvedHealthCoverageInputs,
6154) -> fallow_engine::health::HealthCoverageInputs<'_> {
6155 fallow_engine::health::HealthCoverageInputs {
6156 coverage: coverage_inputs.coverage.as_deref(),
6157 coverage_root: coverage_inputs.coverage_root.as_deref(),
6158 coverage_relocated: false,
6159 }
6160}
6161
6162struct ResolvedHealthDispatch<'a> {
6166 run: fallow_engine::health::HealthRunOptions<'a>,
6167 production: bool,
6168}
6169
6170fn run_health_dispatch(
6173 dispatch: &DispatchContext<'_>,
6174 args: &HealthDispatchArgs<'_>,
6175 resolved: ResolvedHealthDispatch<'_>,
6176) -> ExitCode {
6177 let cli = dispatch.cli;
6178 let (output, quiet, _fail_on_issues) =
6179 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6180 let run = resolved.run;
6181 let sections = run.sections;
6182 let production = resolved.production;
6183 health::run_health(
6184 &HealthOptions {
6185 root: dispatch.root,
6186 config_path: &cli.config,
6187 output,
6188 no_cache: cli.no_cache,
6189 threads: dispatch.threads,
6190 quiet,
6191 thresholds: run.thresholds,
6192 top: run.top,
6193 sort: run.sort,
6194 production,
6195 production_override: Some(production),
6196 allow_remote_extends: cli.allow_remote_extends,
6197 changed_since: cli.changed_since.as_deref(),
6198 diff_index: None,
6199 use_shared_diff_index: true,
6200 workspace: cli.workspace.as_deref(),
6201 changed_workspaces: cli.changed_workspaces.as_deref(),
6202 baseline: cli.baseline.as_deref(),
6203 save_baseline: cli.save_baseline.as_deref(),
6204 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6205 baseline_mode_explicit: cli.baseline_mode.is_some(),
6206 complexity: sections.complexity,
6207 file_scores: sections.file_scores,
6208 coverage_gaps: sections.coverage_gaps,
6209 config_activates_coverage_gaps: !sections.any_section,
6210 hotspots: sections.hotspots,
6211 ownership: run.ownership,
6212 ownership_emails: run.ownership_emails,
6213 targets: sections.targets,
6214 css: sections.css,
6215 css_deep: false,
6216 force_full: sections.force_full,
6217 score_only_output: sections.score_only_output,
6218 enforce_coverage_gap_gate: true,
6219 effort: run.effort,
6220 score: sections.score,
6221 gates: run.gates,
6222 since: run.since,
6223 min_commits: run.min_commits,
6224 explain: cli.explain,
6225 summary: cli.summary,
6226 save_snapshot: args
6227 .save_snapshot
6228 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
6229 trend: args.trend,
6230 coverage_inputs: run.coverage_inputs,
6231 performance: cli.performance,
6232 runtime_coverage: run.runtime_coverage,
6233 churn_file: cli.churn_file.as_deref(),
6234 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
6235 complexity_breakdown: args.complexity_breakdown,
6236 group_by: cli.group_by.map(Into::into),
6237 },
6238 dispatch.json_style,
6239 &health::TypeAwareHealthOptions {
6240 enabled: cli.type_aware_override(),
6241 requested: args.type_coupling,
6242 unfiltered: health_type_coupling_is_default_section(args),
6243 projects: &cli.type_aware_project,
6244 require: cli.type_aware_require.map(Into::into),
6245 },
6246 )
6247}
6248
6249fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
6250 !args.complexity
6251 && !args.file_scores
6252 && !args.coverage_gaps
6253 && !args.hotspots
6254 && !args.ownership
6255 && !args.targets
6256 && !args.css
6257 && !args.score
6258 && args.min_score.is_none()
6259 && args.min_severity.is_none()
6260 && args.runtime_coverage.is_none()
6261}
6262
6263#[cfg(test)]
6264mod tests {
6265 use super::*;
6266
6267 #[test]
6271 fn cli_definition_has_no_flag_collisions() {
6272 use clap::CommandFactory;
6273 Cli::command().debug_assert();
6274 }
6275
6276 #[test]
6277 fn impact_statusline_subcommand_parses() {
6278 use clap::Parser;
6279
6280 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6281 assert!(matches!(
6282 cli.command,
6283 Some(Command::Impact {
6284 subcommand: Some(ImpactCli::Statusline),
6285 ..
6286 })
6287 ));
6288 }
6289
6290 #[test]
6291 fn impact_statusline_bypasses_command_epilogue() {
6292 use clap::Parser;
6293
6294 let statusline =
6295 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6296 assert!(is_impact_statusline(&statusline));
6297
6298 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
6299 assert!(!is_impact_statusline(&status));
6300
6301 let all_statusline =
6302 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
6303 assert!(!is_impact_statusline(&all_statusline));
6304 }
6305
6306 #[test]
6307 fn regression_baseline_help_explains_the_default_destination() {
6308 use clap::CommandFactory;
6309 let help = Cli::command().render_long_help().to_string();
6310
6311 assert!(help.contains("Omit PATH to update regression.baseline"));
6312 assert!(help.contains("discovered fallow config"));
6313 assert!(help.contains("create .fallowrc.json when none exists"));
6314 }
6315
6316 #[test]
6320 fn after_help_lists_every_task_matrix_command() {
6321 for row in crate::task_matrix::TASK_MATRIX {
6322 assert!(
6323 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
6324 "root --help cheat sheet is missing task-matrix command '{}'; \
6325 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
6326 row.command
6327 );
6328 }
6329 }
6330
6331 #[test]
6338 fn after_help_lists_every_visible_subcommand() {
6339 use clap::CommandFactory;
6340
6341 for sub in Cli::command().get_subcommands() {
6342 if sub.is_hide_set() {
6343 continue;
6344 }
6345 let name = sub.get_name();
6346 let listed = TOP_LEVEL_AFTER_LONG_HELP
6347 .lines()
6348 .any(|line| line.split_whitespace().next() == Some(name));
6349 assert!(
6350 listed,
6351 "root --help command list is missing subcommand '{name}'; \
6352 add it to a top_level_*_command_groups! section"
6353 );
6354 }
6355 }
6356
6357 #[test]
6361 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6362 use clap::CommandFactory;
6363
6364 let help = Cli::command().render_help().to_string();
6365 let lines = help.lines().count();
6366 assert!(
6367 lines < 90,
6368 "root -h grew to {lines} lines; keep the short surface under 90 \
6369 (curate hide_short_help and the short after-help instead)"
6370 );
6371 assert!(help.contains("When the agent is about to..."));
6372 assert!(help.contains("Run fallow --help for the complete command list."));
6373 }
6374
6375 #[test]
6379 fn high_value_commands_route_to_distinct_workflows() {
6380 use clap::Parser;
6381 use fallow_config::OutputFormat;
6382
6383 let distinct = [
6384 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6385 (vec!["fallow", "security"], telemetry::Workflow::Security),
6386 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6387 (
6388 vec!["fallow", "explain", "unused-exports"],
6389 telemetry::Workflow::Explain,
6390 ),
6391 (
6392 vec!["fallow", "watch"],
6393 telemetry::Workflow::CodeQualityReview,
6394 ),
6395 (
6396 vec!["fallow", "list"],
6397 telemetry::Workflow::ProjectInventory,
6398 ),
6399 (
6400 vec!["fallow", "workspaces"],
6401 telemetry::Workflow::ProjectInventory,
6402 ),
6403 (
6404 vec!["fallow", "schema"],
6405 telemetry::Workflow::ProjectInventory,
6406 ),
6407 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6408 (
6409 vec!["fallow", "hooks", "install", "--target", "git"],
6410 telemetry::Workflow::Setup,
6411 ),
6412 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6413 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6414 (
6415 vec!["fallow", "rule-pack-schema"],
6416 telemetry::Workflow::Setup,
6417 ),
6418 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6419 (
6420 vec!["fallow", "ci-template", "gitlab"],
6421 telemetry::Workflow::Setup,
6422 ),
6423 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6424 (
6425 vec!["fallow", "telemetry", "status"],
6426 telemetry::Workflow::Setup,
6427 ),
6428 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6429 (
6430 vec!["fallow", "audit-cache", "remove", "--root", "."],
6431 telemetry::Workflow::Setup,
6432 ),
6433 (
6434 vec!["fallow", "license", "status"],
6435 telemetry::Workflow::License,
6436 ),
6437 ];
6438 for (argv, expected) in distinct {
6439 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6440 assert_eq!(
6441 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6442 expected,
6443 "{argv:?} should map to {expected:?}"
6444 );
6445 }
6446 }
6447
6448 #[test]
6453 fn version_flag_accepts_lower_v_upper_v_and_long() {
6454 use clap::CommandFactory;
6455 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6456 let err = Cli::command()
6457 .try_get_matches_from(argv)
6458 .expect_err("version flag should short-circuit parsing");
6459 assert_eq!(
6460 err.kind(),
6461 clap::error::ErrorKind::DisplayVersion,
6462 "{argv:?} should trigger the Version action"
6463 );
6464 }
6465 }
6466
6467 #[test]
6472 fn cli_help_text_contains_no_implementation_status_wording() {
6473 use clap::CommandFactory;
6474 let mut root = Cli::command();
6475 let mut violations: Vec<(String, String)> = Vec::new();
6476 visit_help(&mut root, "fallow", &mut violations);
6477 assert!(
6478 violations.is_empty(),
6479 "found implementation-status wording in --help output:\n{}",
6480 violations
6481 .iter()
6482 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6483 .collect::<Vec<_>>()
6484 .join("\n")
6485 );
6486 }
6487
6488 #[test]
6489 fn dependency_override_help_is_package_manager_neutral() {
6490 use clap::CommandFactory;
6491 let help = Cli::command()
6492 .find_subcommand_mut("dead-code")
6493 .expect("dead-code command")
6494 .render_long_help()
6495 .to_string();
6496
6497 assert!(help.contains("Only report unused package-manager dependency overrides"));
6498 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6499 assert!(!help.contains("unused pnpm dependency overrides"));
6500 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6501 }
6502
6503 #[test]
6504 fn top_level_help_groups_commands_by_workflow() {
6505 use clap::CommandFactory;
6506 let help = Cli::command().render_long_help().to_string();
6507 let expected_order = [
6508 "Analysis:",
6509 " dead-code",
6510 " dupes",
6511 " health",
6512 " flags",
6513 " security",
6514 " audit",
6515 "Workflow:",
6516 " watch",
6517 " fix",
6518 "Project inspection:",
6519 " list",
6520 " workspaces",
6521 " explain",
6522 " impact",
6523 " viz",
6524 "Setup and configuration:",
6525 " init",
6526 " recommend",
6527 " migrate",
6528 " config",
6529 " config-schema",
6530 " plugin-schema",
6531 " plugin-check",
6532 " rule-pack-schema",
6533 "Automation and CI:",
6534 " ci",
6535 " ci-template",
6536 " hooks",
6537 " setup-hooks",
6538 "Runtime coverage:",
6539 " coverage",
6540 " license",
6541 "Reference:",
6542 " schema",
6543 " help",
6544 "Options:",
6545 ];
6546 let mut cursor = 0;
6547 for needle in expected_order {
6548 let Some(offset) = help[cursor..].find(needle) else {
6549 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
6550 };
6551 cursor += offset + needle.len();
6552 }
6553 }
6554
6555 #[test]
6556 fn security_help_hides_globals_rejected_by_security_validator() {
6557 let help = render_security_help(SecurityHelpTarget::Parent);
6558
6559 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
6560 assert!(
6561 !help_contains_long_flag(&help, long),
6562 "security help must hide unsupported --{long}:\n{help}"
6563 );
6564 }
6565
6566 for long in [
6567 "root",
6568 "config",
6569 "format",
6570 "quiet",
6571 "no-cache",
6572 "threads",
6573 "changed-since",
6574 "diff-file",
6575 "diff-stdin",
6576 "workspace",
6577 "changed-workspaces",
6578 "ci",
6579 "fail-on-issues",
6580 "sarif-file",
6581 "summary",
6582 "output-file",
6583 "max-file-size",
6584 "explain",
6585 "surface",
6586 ] {
6587 assert!(
6588 help_contains_long_flag(&help, long),
6589 "security help must keep supported --{long}:\n{help}"
6590 );
6591 }
6592 }
6593
6594 #[test]
6595 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
6596 assert_eq!(
6597 security_help_target(["security", "--help"]),
6598 Some(SecurityHelpTarget::Parent)
6599 );
6600 assert_eq!(
6601 security_help_target(["security", "-h"]),
6602 Some(SecurityHelpTarget::Parent)
6603 );
6604 assert_eq!(
6605 security_help_target(["--format", "json", "security", "--help"]),
6606 Some(SecurityHelpTarget::Parent)
6607 );
6608 assert_eq!(
6609 security_help_target(["help", "security"]),
6610 Some(SecurityHelpTarget::Parent)
6611 );
6612 assert_eq!(
6613 security_help_target(["security", "survivors", "--help"]),
6614 Some(SecurityHelpTarget::Survivors)
6615 );
6616 assert_eq!(
6617 security_help_target(["security", "survivors", "-h"]),
6618 Some(SecurityHelpTarget::Survivors)
6619 );
6620 assert_eq!(
6621 security_help_target(["help", "security", "survivors"]),
6622 Some(SecurityHelpTarget::Survivors)
6623 );
6624 assert_eq!(
6625 security_help_target(["security", "blind-spots", "--help"]),
6626 Some(SecurityHelpTarget::BlindSpots)
6627 );
6628 assert_eq!(
6629 security_help_target(["help", "security", "blind-spots"]),
6630 Some(SecurityHelpTarget::BlindSpots)
6631 );
6632 assert_eq!(security_help_target(["health", "--help"]), None);
6633 assert_eq!(security_help_target(["help", "health"]), None);
6634 }
6635
6636 #[test]
6637 fn security_unsupported_global_validator_matches_hidden_help_contract() {
6638 for (argv, expected) in [
6639 (vec!["fallow", "security", "--performance"], "--performance"),
6640 (
6641 vec!["fallow", "security", "--baseline", "base.json"],
6642 "--baseline",
6643 ),
6644 (
6645 vec!["fallow", "security", "--dupes-mode", "weak"],
6646 "--dupes-mode",
6647 ),
6648 ] {
6649 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
6650 assert_eq!(unsupported_security_global(&cli), Some(expected));
6651 }
6652
6653 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
6654 .expect("security --explain parses");
6655 assert_eq!(unsupported_security_global(&explain), None);
6656 }
6657
6658 #[test]
6659 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
6660 use clap::CommandFactory;
6661
6662 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
6663 .get_arguments()
6664 .filter(|arg| arg.is_global_set())
6665 .filter_map(|arg| arg.get_long().map(str::to_owned))
6666 .filter(|name| {
6667 matches!(
6668 name.as_str(),
6669 "root"
6670 | "config"
6671 | "allow-remote-extends"
6672 | "no-cache"
6673 | "threads"
6674 | "changed-since"
6675 | "diff-file"
6676 | "production"
6677 | "workspace"
6678 | "changed-workspaces"
6679 | "explain"
6680 )
6681 })
6682 .collect();
6683 let programmatic_flags: std::collections::BTreeSet<String> =
6684 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
6685 .iter()
6686 .map(|flag| (*flag).to_owned())
6687 .collect();
6688
6689 assert_eq!(programmatic_flags, cli_flags);
6690 }
6691
6692 #[test]
6693 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
6694 use clap::CommandFactory;
6695
6696 let cli = Cli::command();
6697 let dead_code = cli
6698 .get_subcommands()
6699 .find(|command| command.get_name() == "dead-code")
6700 .expect("dead-code subcommand is registered");
6701 let cli_flags: std::collections::BTreeSet<String> = dead_code
6702 .get_arguments()
6703 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
6704 .collect();
6705
6706 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
6707 assert!(
6708 cli_flags.contains(*flag),
6709 "registry filter flag {flag} is missing from dead-code clap args"
6710 );
6711 }
6712 }
6713
6714 fn help_contains_long_flag(help: &str, long: &str) -> bool {
6715 let flag = format!("--{long}");
6716 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
6717 .any(|token| token == flag)
6718 }
6719
6720 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
6721 let help = cmd.render_long_help().to_string();
6722 for line in scan_forbidden(&help) {
6723 violations.push((path.to_owned(), line));
6724 }
6725 let names: Vec<String> = cmd
6726 .get_subcommands()
6727 .map(|sub| sub.get_name().to_owned())
6728 .collect();
6729 for name in names {
6730 if name == "help" {
6731 continue;
6732 }
6733 if let Some(sub) = cmd.find_subcommand_mut(&name) {
6734 let sub_path = format!("{path} {name}");
6735 visit_help(sub, &sub_path, violations);
6736 }
6737 }
6738 }
6739
6740 fn scan_forbidden(s: &str) -> Vec<String> {
6741 let lower = s.to_ascii_lowercase();
6742 let mut out = Vec::new();
6743 for word in ["stub", "placeholder"] {
6744 if let Some(idx) = find_whole_word(&lower, word) {
6745 out.push(extract_line(s, idx));
6746 }
6747 }
6748 if let Some(idx) = lower.find("not yet") {
6749 out.push(extract_line(s, idx));
6750 }
6751 out
6752 }
6753
6754 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
6755 let bytes = haystack.as_bytes();
6756 let mut start = 0;
6757 while let Some(rel) = haystack[start..].find(word) {
6758 let abs = start + rel;
6759 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
6760 let after_idx = abs + word.len();
6761 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
6762 if before_ok && after_ok {
6763 return Some(abs);
6764 }
6765 start = abs + word.len();
6766 }
6767 None
6768 }
6769
6770 fn extract_line(s: &str, byte_idx: usize) -> String {
6771 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
6772 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
6773 s[line_start..line_end].trim().to_owned()
6774 }
6775
6776 #[test]
6777 fn emit_error_returns_given_exit_code() {
6778 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
6779 assert_eq!(code, ExitCode::from(2));
6780 }
6781
6782 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
6783 TelemetryRun {
6784 workflow: telemetry::Workflow::Health,
6785 output: fallow_config::OutputFormat::Json,
6786 quiet: true,
6787 start: std::time::Instant::now(),
6788 context: telemetry::WorkflowContext {
6789 run_scope: telemetry::RunScope::FullProject,
6790 config_shape: telemetry::ConfigShape::Default,
6791 output_destination: telemetry::OutputDestination::Stdout,
6792 analysis_mode: mode,
6793 },
6794 }
6795 }
6796
6797 #[test]
6798 fn fallback_failure_reason_skips_success_and_findings() {
6799 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6800
6801 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
6802 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
6803 }
6804
6805 #[test]
6806 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
6807 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6808 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
6809
6810 assert_eq!(
6811 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
6812 Some(telemetry::FailureReason::Network),
6813 );
6814 assert_eq!(
6815 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
6816 Some(telemetry::FailureReason::Auth),
6817 );
6818 assert_eq!(
6819 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
6820 Some(telemetry::FailureReason::Auth),
6821 );
6822 assert_eq!(
6823 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
6824 Some(telemetry::FailureReason::Analysis),
6825 );
6826 }
6827
6828 #[test]
6829 fn bare_coverage_flags_parse_without_subcommand() {
6830 let cli = Cli::try_parse_from([
6831 "fallow",
6832 "--coverage",
6833 "coverage/coverage-final.json",
6834 "--coverage-root",
6835 "/ci/workspace",
6836 ])
6837 .expect("bare combined coverage flags should parse");
6838 assert!(cli.command.is_none());
6839 assert_eq!(
6840 cli.coverage.as_deref(),
6841 Some(std::path::Path::new("coverage/coverage-final.json"))
6842 );
6843 assert_eq!(
6844 cli.coverage_root.as_deref(),
6845 Some(std::path::Path::new("/ci/workspace"))
6846 );
6847 }
6848
6849 #[test]
6850 fn bare_coverage_before_subcommand_is_detectable() {
6851 let cli = Cli::try_parse_from([
6852 "fallow",
6853 "--coverage",
6854 "coverage/coverage-final.json",
6855 "dead-code",
6856 ])
6857 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
6858 assert!(cli.command.is_some());
6859 assert!(cli_has_bare_coverage_input(&cli));
6860 let message = bare_coverage_subcommand_error_message();
6861 assert!(message.contains("bare combined-mode flags"));
6862 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
6863 }
6864
6865 #[test]
6866 fn subcommand_coverage_flag_keeps_regular_clap_error() {
6867 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
6868 panic!("dead-code --coverage should fail to parse");
6869 };
6870 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
6871 }
6872
6873 #[test]
6874 fn type_aware_flags_parse_for_semantic_analysis() {
6875 let cli = Cli::try_parse_from([
6876 "fallow",
6877 "dead-code",
6878 "--unused-class-members",
6879 "--type-aware",
6880 "--type-aware-project",
6881 "tsconfig.json",
6882 "--type-aware-project",
6883 "packages/web/tsconfig.json",
6884 ])
6885 .expect("type-aware flag should parse");
6886 assert!(cli.type_aware);
6887 assert_eq!(
6888 cli.type_aware_project,
6889 [
6890 PathBuf::from("tsconfig.json"),
6891 PathBuf::from("packages/web/tsconfig.json")
6892 ]
6893 );
6894 let Some(Command::Check {
6895 unused_class_members,
6896 ..
6897 }) = cli.command
6898 else {
6899 panic!("dead-code should parse as the check command");
6900 };
6901 assert!(unused_class_members);
6902 }
6903
6904 #[test]
6905 fn no_type_aware_conflicts_with_type_aware() {
6906 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
6907 else {
6908 panic!("--no-type-aware must conflict with --type-aware");
6909 };
6910 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
6911 }
6912
6913 #[test]
6914 fn no_type_aware_forces_semantic_analysis_off() {
6915 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
6916 .expect("--no-type-aware should parse on audit");
6917 assert_eq!(cli.type_aware_override(), Some(false));
6918
6919 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
6920 .expect("--type-aware should parse");
6921 assert_eq!(cli.type_aware_override(), Some(true));
6922
6923 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
6924 assert_eq!(cli.type_aware_override(), None);
6925 }
6926
6927 #[test]
6928 fn type_aware_status_output_hides_host_paths() {
6929 let root = Path::new("/private/work/project");
6930 let output = type_aware_status_output(
6931 root,
6932 fallow_api::TypeAwareStatus {
6933 available: false,
6934 discovery_source: Some("environment-override"),
6935 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
6936 package_version: None,
6937 protocol_version: 7,
6938 backend_family: None,
6939 backend_version: None,
6940 remediation: Some(
6941 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
6942 .to_string(),
6943 ),
6944 },
6945 );
6946
6947 assert_eq!(
6948 output.schema_version.0,
6949 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
6950 );
6951 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
6952 let remediation = output.remediation.expect("remediation");
6953 assert!(!remediation.contains("/private/"));
6954 assert!(remediation.contains("fallow-type-aware"));
6955 }
6956
6957 #[test]
6958 fn format_parsing_covers_all_variants() {
6959 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
6960 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
6961 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
6962 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
6963 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
6964 assert!(matches!(
6965 parse_format_arg("markdown"),
6966 Some(Format::Markdown)
6967 ));
6968 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
6969 assert!(matches!(
6970 parse_format_arg("codeclimate"),
6971 Some(Format::CodeClimate)
6972 ));
6973 assert!(matches!(
6974 parse_format_arg("gitlab-codequality"),
6975 Some(Format::CodeClimate)
6976 ));
6977 assert!(matches!(
6978 parse_format_arg("gitlab-code-quality"),
6979 Some(Format::CodeClimate)
6980 ));
6981 assert!(matches!(
6982 parse_format_arg("pr-comment-github"),
6983 Some(Format::PrCommentGithub)
6984 ));
6985 assert!(matches!(
6986 parse_format_arg("pr-comment-gitlab"),
6987 Some(Format::PrCommentGitlab)
6988 ));
6989 assert!(matches!(
6990 parse_format_arg("review-github"),
6991 Some(Format::ReviewGithub)
6992 ));
6993 assert!(matches!(
6994 parse_format_arg("review-gitlab"),
6995 Some(Format::ReviewGitlab)
6996 ));
6997 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
6998 assert!(parse_format_arg("xml").is_none());
6999 assert!(parse_format_arg("").is_none());
7000 }
7001
7002 #[test]
7003 fn quiet_parsing_logic() {
7004 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7005 assert!(parse("1"));
7006 assert!(parse("true"));
7007 assert!(parse("TRUE"));
7008 assert!(parse("True"));
7009 assert!(!parse("0"));
7010 assert!(!parse("false"));
7011 assert!(!parse("yes"));
7012 }
7013
7014 #[test]
7015 fn tracing_filter_defaults_to_warn_without_env() {
7016 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7017 }
7018
7019 #[test]
7020 fn tracing_filter_respects_explicit_env_directives() {
7021 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7022 }
7023
7024 #[test]
7025 fn tracing_filter_treats_empty_env_as_off() {
7026 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7027 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7028 }
7029}