1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::path::{Path, PathBuf};
16use std::process::ExitCode;
17
18use clap::{Parser, Subcommand};
19
20mod api;
21#[cfg(test)]
22mod architecture_boundaries;
23mod audit;
24mod audit_brief;
25mod audit_decision_surface;
26mod audit_focus;
27mod audit_walkthrough;
28mod base_worktree;
29mod walkthrough_state;
30use fallow_engine::baseline;
31mod cache_notice;
32mod check;
33mod ci;
34mod ci_template;
35mod cli_format;
36mod cli_hooks;
37mod cli_impact;
38mod cli_production;
39mod cli_report;
40mod cli_startup;
41pub use fallow_engine::codeowners;
42mod combined;
43mod config;
44mod coverage;
45mod dupes;
46pub mod explain;
47mod fix;
48mod flags;
49mod guard;
50mod health;
51mod impact;
52mod init;
53mod inspect;
54mod license;
55mod list;
56mod migrate;
57mod onboarding;
58#[cfg(test)]
59mod output_envelope;
60mod output_runtime;
61mod path_util;
62mod plugin_check;
63mod rayon_pool;
64mod regression;
65pub mod report;
66mod rule_pack;
67mod runtime_support;
68mod schema;
69mod security;
70mod security_help;
71mod setup_hooks;
72mod signal;
73mod suppressions;
74mod task_matrix;
75mod telemetry;
76mod trace_chain;
77mod update_check;
78use fallow_engine::validate;
79use fallow_engine::vital_signs;
80mod cli_telemetry;
81mod watch;
82
83use check::{CheckOptions, IssueFilters, TraceOptions};
84pub mod error;
86#[cfg(test)]
87use cli_format::parse_format_arg;
88use cli_format::{Format, FormatConfig, apply_ci_defaults};
89use cli_hooks::{HooksCli, run_hooks_command};
90use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
91use cli_production::{ProductionModes, resolve_production_modes};
92#[cfg(test)]
93use cli_startup::build_tracing_filter;
94use cli_startup::{
95 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
96 run_pre_dispatch_checks, setup_tracing, validate_inputs,
97};
98#[cfg(test)]
99use cli_telemetry::TelemetryRun;
100#[cfg(test)]
101use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
102use cli_telemetry::{record_run_epilogue, start_telemetry_run};
103use dupes::{DupesMode, DupesOptions};
104use error::emit_error;
105use health::{HealthOptions, SortBy};
106use list::ListOptions;
107pub use runtime_support::{AnalysisKind, GroupBy};
108pub(crate) use runtime_support::{
109 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
110 load_config_for_analysis,
111};
112#[cfg(test)]
113use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
114use security_help::{render_security_help, security_help_target};
115
116const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
117
118const TOP_LEVEL_HELP_TEMPLATE: &str =
119 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
120
121const TOP_LEVEL_AFTER_HELP: &str = "\
122Analysis:
123 dead-code Analyze unused code, dependency hygiene, and architecture cycles
124 dupes Find copy-paste and structural code duplication
125 health Analyze complexity, maintainability, hotspots, and coverage gaps
126 flags Detect feature flag usage patterns
127 security Surface local security candidates for agent verification (opt-in)
128 audit Review changed files for dead code, complexity, duplication, and styling
129
130Workflow:
131 watch Re-run analysis as files change
132 fix Auto-fix safe unused-code findings
133
134Project inspection:
135 list List discovered files, entry points, plugins, boundaries, and workspaces
136 inspect Inspect one file or exported symbol as a bundled evidence query
137 workspaces Show monorepo workspace discovery diagnostics
138 explain Explain one issue type without running analysis
139 suppressions List active fallow-ignore suppression markers
140 impact Show what fallow has done for you (opt-in, local-only)
141
142Setup and configuration:
143 init Create a fallow config, optionally with a Git hook
144 recommend Recommend a project-tailored config for an agent to author
145 migrate Migrate knip, jscpd, or stylelint config to fallow
146 config Show the resolved config and loaded config file
147 config-schema Print the fallow config JSON Schema
148 plugin-schema Print the external plugin JSON Schema
149 plugin-check Dry-run external plugins and report what they seed
150 rule-pack-schema Print the rule pack JSON Schema
151
152Automation and CI:
153 ci Build PR/MR feedback envelopes
154 ci-template Print or vendor CI integration templates
155 report Re-render a saved --format json results file (GitHub formats)
156 hooks Install or remove fallow-managed Git and agent hooks
157 setup-hooks Legacy agent-hook installer
158
159Runtime coverage:
160 coverage Set up or analyze runtime coverage data
161 license Manage the paid-feature license
162 telemetry Manage opt-in product telemetry
163
164Reference:
165 schema Dump the CLI interface as machine-readable JSON
166 help Print this message or the help of a command
167
168When no command is given, fallow runs dead-code + dupes + health together.
169Use --only/--skip to select specific analyses.
170
171When the agent is about to...
172 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
173 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
174 commit or open a PR fallow audit --base <ref>
175 prioritize refactoring fallow health --hotspots --targets
176 ask who owns code fallow health --ownership
177 check untested-but-reachable code fallow health --coverage-gaps
178 consolidate duplication fallow dupes --trace dup:<fingerprint>
179 find feature flags fallow flags
180 check architecture rules before editing fallow guard <files>
181 surface security candidates fallow security
182 inspect a target before editing fallow inspect --file <path>
183 understand a finding fallow explain <issue-type>
184 scope a monorepo --workspace <glob> / --changed-workspaces <ref>";
185
186#[derive(Parser)]
187#[command(
188 name = "fallow",
189 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
190 version,
191 disable_version_flag = true,
192 help_template = TOP_LEVEL_HELP_TEMPLATE,
193 after_help = TOP_LEVEL_AFTER_HELP
194)]
195struct Cli {
196 #[command(subcommand)]
197 command: Option<Command>,
198
199 #[arg(
203 short = 'v',
204 visible_short_alias = 'V',
205 long = "version",
206 action = clap::ArgAction::Version
207 )]
208 version: Option<bool>,
209
210 #[arg(short, long, global = true)]
212 root: Option<PathBuf>,
213
214 #[arg(short, long, global = true)]
216 config: Option<PathBuf>,
217
218 #[arg(long, global = true)]
220 allow_remote_extends: bool,
221
222 #[arg(
224 short,
225 long,
226 visible_alias = "output",
227 global = true,
228 default_value = "human"
229 )]
230 format: Format,
231
232 #[arg(short, long, global = true)]
234 quiet: bool,
235
236 #[arg(long, global = true)]
238 no_cache: bool,
239
240 #[arg(long, global = true)]
242 threads: Option<usize>,
243
244 #[arg(long, visible_alias = "base", global = true)]
246 changed_since: Option<String>,
247
248 #[arg(long = "diff-file", value_name = "PATH", global = true)]
253 diff_file: Option<PathBuf>,
254
255 #[arg(long = "diff-stdin", global = true)]
258 diff_stdin: bool,
259
260 #[arg(long = "churn-file", value_name = "PATH", global = true)]
267 churn_file: Option<PathBuf>,
268
269 #[arg(long = "max-file-size", value_name = "MB", global = true)]
276 max_file_size: Option<u32>,
277
278 #[arg(long, global = true)]
280 baseline: Option<PathBuf>,
281
282 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
288 parent_run: Option<String>,
289
290 #[arg(long, global = true)]
292 save_baseline: Option<PathBuf>,
293
294 #[arg(long, global = true)]
297 production: bool,
298
299 #[arg(long = "no-production", global = true, conflicts_with = "production")]
303 no_production: bool,
304
305 #[arg(long = "production-dead-code")]
307 production_dead_code: bool,
308
309 #[arg(long = "production-health")]
311 production_health: bool,
312
313 #[arg(long = "production-dupes")]
315 production_dupes: bool,
316
317 #[arg(short, long, global = true, value_delimiter = ',')]
321 workspace: Option<Vec<String>>,
322
323 #[arg(long, global = true, value_name = "REF")]
326 changed_workspaces: Option<String>,
327
328 #[arg(long, global = true)]
330 group_by: Option<GroupBy>,
331
332 #[arg(long, global = true)]
334 performance: bool,
335
336 #[arg(long, global = true)]
338 explain: bool,
339
340 #[arg(long, global = true)]
342 explain_skipped: bool,
343
344 #[arg(long, global = true)]
346 summary: bool,
347
348 #[arg(long, global = true)]
350 ci: bool,
351
352 #[arg(long, global = true)]
354 fail_on_issues: bool,
355
356 #[arg(long, global = true, value_name = "PATH")]
358 sarif_file: Option<PathBuf>,
359
360 #[arg(short = 'o', long, global = true, value_name = "PATH")]
364 output_file: Option<PathBuf>,
365
366 #[arg(
375 long = "report-path-prefix",
376 visible_alias = "annotations-path-prefix",
377 global = true,
378 value_name = "PREFIX"
379 )]
380 report_path_prefix: Option<String>,
381
382 #[arg(long, global = true)]
384 fail_on_regression: bool,
385
386 #[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
388 tolerance: String,
389
390 #[arg(long, global = true, value_name = "PATH")]
392 regression_baseline: Option<PathBuf>,
393
394 #[expect(
398 clippy::option_option,
399 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
400 )]
401 #[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
402 save_regression_baseline: Option<Option<String>>,
403
404 #[arg(long, value_delimiter = ',')]
406 only: Vec<AnalysisKind>,
407
408 #[arg(long, value_delimiter = ',')]
410 skip: Vec<AnalysisKind>,
411
412 #[arg(long = "dupes-mode", global = true)]
414 dupes_mode: Option<DupesMode>,
415
416 #[arg(long = "dupes-threshold", global = true)]
418 dupes_threshold: Option<f64>,
419
420 #[arg(long = "dupes-min-tokens", global = true)]
422 dupes_min_tokens: Option<usize>,
423
424 #[arg(long = "dupes-min-lines", global = true)]
426 dupes_min_lines: Option<usize>,
427
428 #[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
430 dupes_min_occurrences: Option<usize>,
431
432 #[arg(long = "dupes-skip-local", global = true)]
434 dupes_skip_local: bool,
435
436 #[arg(long = "dupes-cross-language", global = true)]
438 dupes_cross_language: bool,
439
440 #[arg(long = "dupes-ignore-imports", global = true)]
443 dupes_ignore_imports: bool,
444
445 #[arg(
448 long = "dupes-no-ignore-imports",
449 global = true,
450 conflicts_with = "dupes_ignore_imports"
451 )]
452 dupes_no_ignore_imports: bool,
453
454 #[arg(long)]
456 score: bool,
457
458 #[arg(long)]
460 trend: bool,
461
462 #[expect(
465 clippy::option_option,
466 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
467 )]
468 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
469 save_snapshot: Option<Option<String>>,
470
471 #[arg(long, value_name = "PATH")]
474 coverage: Option<PathBuf>,
475
476 #[arg(long = "coverage-root", value_name = "PATH")]
479 coverage_root: Option<PathBuf>,
480
481 #[arg(long, global = true)]
483 include_entry_exports: bool,
484}
485
486#[derive(Subcommand)]
487enum Command {
488 #[command(name = "dead-code", alias = "check")]
490 Check {
491 #[arg(long)]
493 unused_files: bool,
494
495 #[arg(long)]
497 unused_exports: bool,
498
499 #[arg(long)]
501 unused_deps: bool,
502
503 #[arg(long)]
505 unused_types: bool,
506
507 #[arg(long)]
509 private_type_leaks: bool,
510
511 #[arg(long)]
513 unused_enum_members: bool,
514
515 #[arg(long)]
517 unused_class_members: bool,
518
519 #[arg(long)]
521 unused_store_members: bool,
522
523 #[arg(long)]
525 unprovided_injects: bool,
526
527 #[arg(long)]
529 unrendered_components: bool,
530
531 #[arg(long)]
533 unused_component_props: bool,
534
535 #[arg(long)]
537 unused_component_emits: bool,
538
539 #[arg(long)]
541 unused_component_inputs: bool,
542
543 #[arg(long)]
545 unused_component_outputs: bool,
546
547 #[arg(long)]
549 unused_svelte_events: bool,
550
551 #[arg(long)]
553 unused_server_actions: bool,
554
555 #[arg(long)]
557 unused_load_data_keys: bool,
558
559 #[arg(long)]
561 unresolved_imports: bool,
562
563 #[arg(long)]
565 unlisted_deps: bool,
566
567 #[arg(long)]
569 duplicate_exports: bool,
570
571 #[arg(long)]
573 circular_deps: bool,
574
575 #[arg(long)]
577 re_export_cycles: bool,
578
579 #[arg(long)]
581 boundary_violations: bool,
582
583 #[arg(long)]
585 policy_violations: bool,
586
587 #[arg(long)]
589 stale_suppressions: bool,
590
591 #[arg(long)]
593 unused_catalog_entries: bool,
594
595 #[arg(long)]
597 empty_catalog_groups: bool,
598
599 #[arg(long)]
601 unresolved_catalog_references: bool,
602
603 #[arg(long)]
605 unused_dependency_overrides: bool,
606
607 #[arg(long)]
609 misconfigured_dependency_overrides: bool,
610
611 #[arg(long)]
613 include_dupes: bool,
614
615 #[arg(long, value_name = "FILE:EXPORT")]
617 trace: Option<String>,
618
619 #[arg(long, value_name = "PATH")]
621 trace_file: Option<String>,
622
623 #[arg(long, value_name = "PACKAGE")]
625 trace_dependency: Option<String>,
626
627 #[arg(long, value_name = "PATH")]
631 impact_closure: Option<String>,
632
633 #[arg(long)]
635 top: Option<usize>,
636
637 #[arg(long, value_name = "PATH")]
641 file: Vec<std::path::PathBuf>,
642 },
643
644 Watch {
646 #[arg(long)]
648 no_clear: bool,
649 },
650
651 Inspect {
653 #[arg(
655 long,
656 value_name = "PATH",
657 conflicts_with = "symbol",
658 required_unless_present = "symbol"
659 )]
660 file: Option<String>,
661
662 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
664 symbol: Option<String>,
665
666 #[arg(long)]
671 symbol_chain: bool,
672
673 #[arg(long)]
676 churn: bool,
677 },
678
679 Trace {
688 #[arg(value_name = "FILE:SYMBOL")]
690 symbol: String,
691
692 #[arg(long)]
695 callers: bool,
696
697 #[arg(long)]
700 callees: bool,
701
702 #[arg(long, value_name = "N")]
705 depth: Option<u32>,
706 },
707
708 Fix {
723 #[arg(long)]
725 dry_run: bool,
726
727 #[arg(long, alias = "force")]
729 yes: bool,
730
731 #[arg(long)]
738 no_create_config: bool,
739 },
740
741 Init {
750 #[arg(long)]
752 toml: bool,
753
754 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
756 agents: bool,
757
758 #[arg(long)]
762 hooks: bool,
763
764 #[arg(long, requires = "hooks")]
766 branch: Option<String>,
767
768 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
772 decline: bool,
773 },
774
775 Hooks {
782 #[command(subcommand)]
783 subcommand: HooksCli,
784 },
785
786 Ci {
788 #[command(subcommand)]
789 subcommand: CiCli,
790 },
791
792 ConfigSchema,
794
795 PluginSchema,
797
798 PluginCheck,
800
801 RulePackSchema,
803
804 RulePack {
806 #[command(subcommand)]
807 subcommand: RulePackCli,
808 },
809
810 Guard {
812 #[arg(required = true, num_args = 1..)]
814 files: Vec<String>,
815 },
816
817 Config {
835 #[arg(long)]
837 path: bool,
838 },
839
840 Recommend,
848
849 List {
851 #[arg(long)]
853 entry_points: bool,
854
855 #[arg(long)]
857 files: bool,
858
859 #[arg(long)]
861 plugins: bool,
862
863 #[arg(long)]
865 boundaries: bool,
866
867 #[arg(long)]
871 workspaces: bool,
872 },
873
874 Workspaces,
880
881 Dupes {
883 #[arg(long)]
886 mode: Option<DupesMode>,
887
888 #[arg(long)]
891 min_tokens: Option<usize>,
892
893 #[arg(long)]
896 min_lines: Option<usize>,
897
898 #[arg(long, value_parser = parse_min_occurrences)]
903 min_occurrences: Option<usize>,
904
905 #[arg(long)]
908 threshold: Option<f64>,
909
910 #[arg(long)]
912 skip_local: bool,
913
914 #[arg(long)]
916 cross_language: bool,
917
918 #[arg(long)]
922 ignore_imports: bool,
923
924 #[arg(long, conflicts_with = "ignore_imports")]
927 no_ignore_imports: bool,
928
929 #[arg(long)]
932 top: Option<usize>,
933
934 #[arg(long, value_name = "FILE:LINE")]
936 trace: Option<String>,
937 },
938
939 Health {
945 #[arg(long)]
947 max_cyclomatic: Option<u16>,
948
949 #[arg(long)]
951 max_cognitive: Option<u16>,
952
953 #[arg(long)]
957 max_crap: Option<f64>,
958
959 #[arg(long)]
961 top: Option<usize>,
962
963 #[arg(long, default_value = "cyclomatic")]
965 sort: SortBy,
966
967 #[arg(long)]
970 complexity: bool,
971
972 #[arg(long)]
979 complexity_breakdown: bool,
980
981 #[arg(long)]
986 file_scores: bool,
987
988 #[arg(long)]
991 coverage_gaps: bool,
992
993 #[arg(long)]
996 hotspots: bool,
997
998 #[arg(long)]
1002 ownership: bool,
1003
1004 #[arg(long, value_name = "MODE", value_enum)]
1009 ownership_emails: Option<EmailModeArg>,
1010
1011 #[arg(long)]
1014 targets: bool,
1015
1016 #[arg(long)]
1021 css: bool,
1022
1023 #[arg(long, value_enum)]
1026 effort: Option<EffortFilter>,
1027
1028 #[arg(long)]
1031 score: bool,
1032
1033 #[arg(long, value_name = "N")]
1042 min_score: Option<f64>,
1043
1044 #[arg(long, value_name = "LEVEL", value_enum)]
1048 min_severity: Option<HealthSeverityCli>,
1049
1050 #[arg(long)]
1054 report_only: bool,
1055
1056 #[arg(long, value_name = "DURATION")]
1059 since: Option<String>,
1060
1061 #[arg(long, value_name = "N")]
1063 min_commits: Option<u32>,
1064
1065 #[expect(
1069 clippy::option_option,
1070 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1071 )]
1072 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1073 save_snapshot: Option<Option<String>>,
1074
1075 #[arg(long)]
1079 trend: bool,
1080
1081 #[arg(long, value_name = "PATH")]
1090 coverage: Option<PathBuf>,
1091
1092 #[arg(long, value_name = "PATH")]
1098 coverage_root: Option<PathBuf>,
1099
1100 #[arg(long, value_name = "PATH")]
1104 runtime_coverage: Option<PathBuf>,
1105
1106 #[arg(long, default_value_t = 100)]
1108 min_invocations_hot: u64,
1109
1110 #[arg(long, value_name = "N")]
1116 min_observation_volume: Option<u32>,
1117
1118 #[arg(long, value_name = "RATIO")]
1123 low_traffic_threshold: Option<f64>,
1124 },
1125
1126 Flags {
1133 #[arg(long)]
1135 top: Option<usize>,
1136 },
1137
1138 Suppressions {
1148 #[arg(long, value_name = "PATH")]
1150 file: Vec<std::path::PathBuf>,
1151 },
1152
1153 Explain {
1159 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1161 issue_type: Vec<String>,
1162 },
1163
1164 #[command(visible_alias = "review")]
1189 Audit {
1190 #[arg(long = "production-dead-code")]
1192 production_dead_code: bool,
1193
1194 #[arg(long = "production-health")]
1196 production_health: bool,
1197
1198 #[arg(long = "production-dupes")]
1200 production_dupes: bool,
1201
1202 #[arg(long)]
1205 dead_code_baseline: Option<PathBuf>,
1206
1207 #[arg(long)]
1210 health_baseline: Option<PathBuf>,
1211
1212 #[arg(long)]
1215 dupes_baseline: Option<PathBuf>,
1216
1217 #[arg(long)]
1221 max_crap: Option<f64>,
1222
1223 #[arg(long, value_name = "PATH")]
1227 coverage: Option<PathBuf>,
1228
1229 #[arg(long, value_name = "PATH")]
1232 coverage_root: Option<PathBuf>,
1233
1234 #[arg(long = "no-css")]
1236 no_css: bool,
1237
1238 #[arg(long)]
1242 css_deep: bool,
1243
1244 #[arg(long = "no-css-deep")]
1246 no_css_deep: bool,
1247
1248 #[arg(long, value_enum)]
1254 gate: Option<AuditGateArg>,
1255
1256 #[arg(long, value_name = "PATH")]
1265 runtime_coverage: Option<PathBuf>,
1266
1267 #[arg(long, default_value_t = 100)]
1270 min_invocations_hot: u64,
1271
1272 #[arg(long, value_name = "MARKER", hide = true)]
1277 gate_marker: Option<String>,
1278
1279 #[arg(long)]
1285 brief: bool,
1286
1287 #[arg(
1292 long,
1293 value_name = "N",
1294 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1295 )]
1296 max_decisions: usize,
1297
1298 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1306 walkthrough_guide: bool,
1307
1308 #[arg(long, value_name = "PATH")]
1316 walkthrough_file: Option<PathBuf>,
1317
1318 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1324 walkthrough: bool,
1325
1326 #[arg(long, value_name = "PATH")]
1332 mark_viewed: Vec<PathBuf>,
1333
1334 #[arg(long)]
1338 show_cleared: bool,
1339
1340 #[arg(long)]
1346 show_deprioritized: bool,
1347 },
1348
1349 DecisionSurface {
1361 #[arg(
1364 long,
1365 value_name = "N",
1366 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1367 )]
1368 max_decisions: usize,
1369 },
1370
1371 Impact {
1381 #[command(subcommand)]
1382 subcommand: Option<ImpactCli>,
1383 #[arg(long)]
1387 all: bool,
1388 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1390 sort: ImpactSortCli,
1391 #[arg(long)]
1394 limit: Option<usize>,
1395 },
1396
1397 Security {
1428 #[command(subcommand)]
1429 subcommand: Option<SecuritySubcommand>,
1430 #[arg(long, value_name = "PATH")]
1435 runtime_coverage: Option<PathBuf>,
1436 #[arg(long, default_value_t = 100)]
1439 min_invocations_hot: u64,
1440 #[arg(long, value_name = "PATH")]
1444 file: Vec<std::path::PathBuf>,
1445 #[arg(long, value_name = "MODE")]
1451 gate: Option<security::SecurityGateArg>,
1452 #[arg(long)]
1454 surface: bool,
1455 },
1456
1457 Report {
1462 #[arg(long, value_name = "PATH")]
1465 from: PathBuf,
1466 },
1467 Schema,
1469
1470 CiTemplate {
1477 #[command(subcommand)]
1478 subcommand: CiTemplateCli,
1479 },
1480
1481 Migrate {
1483 #[arg(long, conflicts_with = "jsonc")]
1485 toml: bool,
1486
1487 #[arg(long)]
1495 jsonc: bool,
1496
1497 #[arg(long)]
1499 dry_run: bool,
1500
1501 #[arg(long, value_name = "PATH")]
1503 from: Option<PathBuf>,
1504 },
1505
1506 License {
1513 #[command(subcommand)]
1514 subcommand: LicenseCli,
1515 },
1516
1517 Telemetry {
1525 #[command(subcommand)]
1526 subcommand: TelemetryCli,
1527 },
1528
1529 Coverage {
1535 #[command(subcommand)]
1536 subcommand: CoverageCli,
1537 },
1538
1539 SetupHooks {
1554 #[arg(long, value_enum)]
1556 agent: Option<setup_hooks::HookAgentArg>,
1557
1558 #[arg(long)]
1560 dry_run: bool,
1561
1562 #[arg(long)]
1565 force: bool,
1566
1567 #[arg(long)]
1569 user: bool,
1570
1571 #[arg(long)]
1573 gitignore_claude: bool,
1574
1575 #[arg(long)]
1579 uninstall: bool,
1580 },
1581}
1582
1583#[derive(Subcommand)]
1584enum SecuritySubcommand {
1585 Survivors {
1587 #[arg(long, value_name = "PATH")]
1589 candidates: PathBuf,
1590 #[arg(long, value_name = "PATH")]
1592 verdicts: PathBuf,
1593 #[arg(long)]
1595 require_verdict_for_each_candidate: bool,
1596 },
1597 #[command(name = "blind-spots")]
1599 BlindSpots {
1600 #[arg(long, value_name = "PATH")]
1602 file: Vec<PathBuf>,
1603 },
1604}
1605
1606#[derive(clap::Subcommand)]
1607enum LicenseCli {
1608 Activate {
1613 #[arg(value_name = "JWT")]
1615 jwt: Option<String>,
1616
1617 #[arg(long, value_name = "PATH")]
1619 from_file: Option<PathBuf>,
1620
1621 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1623 stdin: bool,
1624
1625 #[arg(long, requires = "email")]
1632 trial: bool,
1633
1634 #[arg(long, value_name = "ADDR")]
1636 email: Option<String>,
1637 },
1638 Status,
1640 Refresh,
1642 Deactivate,
1644}
1645
1646#[derive(Clone, Copy, clap::Subcommand)]
1647enum TelemetryCli {
1648 Status,
1650 Enable,
1652 Disable,
1654 Inspect {
1656 #[arg(long)]
1658 example: bool,
1659 },
1660}
1661
1662#[derive(clap::Subcommand)]
1663enum CiTemplateCli {
1664 Gitlab {
1666 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1670 vendor: Option<PathBuf>,
1671
1672 #[arg(long)]
1674 force: bool,
1675 },
1676}
1677
1678#[derive(clap::Subcommand)]
1679enum CoverageCli {
1680 Setup {
1682 #[arg(short = 'y', long)]
1684 yes: bool,
1685
1686 #[arg(long)]
1688 non_interactive: bool,
1689
1690 #[arg(long)]
1692 json: bool,
1693 },
1694 Analyze {
1700 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1702 runtime_coverage: Option<PathBuf>,
1703
1704 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1706 cloud: bool,
1707
1708 #[arg(long, value_name = "KEY")]
1710 api_key: Option<String>,
1711
1712 #[arg(long, value_name = "URL")]
1714 api_endpoint: Option<String>,
1715
1716 #[arg(long, value_name = "OWNER/REPO")]
1722 repo: Option<String>,
1723
1724 #[arg(long, value_name = "ID")]
1726 project_id: Option<String>,
1727
1728 #[arg(long, value_name = "DAYS", default_value_t = 30)]
1730 coverage_period: u16,
1731
1732 #[arg(long, value_name = "ENV")]
1734 environment: Option<String>,
1735
1736 #[arg(long, value_name = "SHA")]
1738 commit_sha: Option<String>,
1739
1740 #[arg(long)]
1742 production: bool,
1743
1744 #[arg(long, default_value_t = 100)]
1746 min_invocations_hot: u64,
1747
1748 #[arg(long, value_name = "N")]
1750 min_observation_volume: Option<u32>,
1751
1752 #[arg(long, value_name = "RATIO")]
1754 low_traffic_threshold: Option<f64>,
1755
1756 #[arg(long)]
1758 top: Option<usize>,
1759
1760 #[arg(long)]
1762 blast_radius: bool,
1763
1764 #[arg(long)]
1766 importance: bool,
1767 },
1768 UploadInventory {
1779 #[arg(long, value_name = "KEY")]
1788 api_key: Option<String>,
1789
1790 #[arg(long, value_name = "URL")]
1795 api_endpoint: Option<String>,
1796
1797 #[arg(long, value_name = "PROJECT_ID")]
1802 project_id: Option<String>,
1803
1804 #[arg(long, value_name = "SHA")]
1809 git_sha: Option<String>,
1810
1811 #[arg(long)]
1817 allow_dirty: bool,
1818
1819 #[arg(long, value_name = "GLOB", num_args = 0..)]
1823 exclude_paths: Vec<String>,
1824
1825 #[arg(long, value_name = "PREFIX")]
1838 path_prefix: Option<String>,
1839
1840 #[arg(long)]
1842 dry_run: bool,
1843
1844 #[arg(long)]
1850 with_callers: bool,
1851
1852 #[arg(long)]
1856 ignore_upload_errors: bool,
1857 },
1858 UploadSourceMaps {
1871 #[arg(long, value_name = "PATH", default_value = "dist")]
1873 dir: PathBuf,
1874
1875 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
1877 include: String,
1878
1879 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
1883 exclude: Vec<String>,
1884
1885 #[arg(long, value_name = "NAME")]
1889 repo: Option<String>,
1890
1891 #[arg(long, value_name = "SHA")]
1896 git_sha: Option<String>,
1897
1898 #[arg(long, value_name = "URL")]
1900 endpoint: Option<String>,
1901
1902 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
1907 strip_path: bool,
1908
1909 #[arg(long)]
1911 dry_run: bool,
1912
1913 #[arg(long, value_name = "N", default_value_t = 4)]
1915 concurrency: usize,
1916
1917 #[arg(long)]
1919 fail_fast: bool,
1920 },
1921 UploadStaticFindings {
1928 #[arg(long, value_name = "KEY")]
1938 api_key: Option<String>,
1939
1940 #[arg(long, value_name = "URL")]
1945 api_endpoint: Option<String>,
1946
1947 #[arg(long, value_name = "PROJECT_ID")]
1952 project_id: Option<String>,
1953
1954 #[arg(long, value_name = "SHA")]
1959 git_sha: Option<String>,
1960
1961 #[arg(long)]
1967 allow_dirty: bool,
1968
1969 #[arg(long)]
1971 dry_run: bool,
1972
1973 #[arg(long)]
1977 ignore_upload_errors: bool,
1978 },
1979}
1980
1981#[derive(Subcommand)]
1982enum CiCli {
1983 PlanPrComment {
1985 #[arg(long)]
1987 body: PathBuf,
1988
1989 #[arg(long)]
1991 marker_id: String,
1992
1993 #[arg(long)]
1995 clean: bool,
1996
1997 #[arg(long)]
1999 existing_comment_id: Option<String>,
2000
2001 #[arg(long)]
2003 existing_body: Option<PathBuf>,
2004 },
2005
2006 PostPrComment {
2008 #[arg(long, value_enum)]
2010 provider: CiProviderArg,
2011
2012 #[arg(long)]
2014 pr: Option<String>,
2015
2016 #[arg(long)]
2018 mr: Option<String>,
2019
2020 #[arg(long)]
2022 body: PathBuf,
2023
2024 #[arg(long)]
2026 envelope: Option<PathBuf>,
2027
2028 #[arg(long)]
2030 marker_id: String,
2031
2032 #[arg(long)]
2034 clean: bool,
2035
2036 #[arg(long)]
2038 repo: Option<String>,
2039
2040 #[arg(long = "project-id")]
2042 project_id: Option<String>,
2043
2044 #[arg(long = "api-url")]
2046 api_url: Option<String>,
2047
2048 #[arg(long)]
2050 dry_run: bool,
2051 },
2052
2053 PostReview {
2055 #[arg(long, value_enum)]
2057 provider: CiProviderArg,
2058
2059 #[arg(long)]
2061 pr: Option<String>,
2062
2063 #[arg(long)]
2065 mr: Option<String>,
2066
2067 #[arg(long)]
2069 envelope: PathBuf,
2070
2071 #[arg(long)]
2073 repo: Option<String>,
2074
2075 #[arg(long = "project-id")]
2077 project_id: Option<String>,
2078
2079 #[arg(long = "api-url")]
2081 api_url: Option<String>,
2082
2083 #[arg(long)]
2085 dry_run: bool,
2086 },
2087
2088 PostCheckRun {
2090 #[arg(long, value_enum)]
2092 provider: CiProviderArg,
2093
2094 #[arg(long)]
2096 decision: PathBuf,
2097
2098 #[arg(long)]
2100 repo: String,
2101
2102 #[arg(long = "head-sha")]
2104 head_sha: String,
2105
2106 #[arg(long = "api-url")]
2108 api_url: Option<String>,
2109
2110 #[arg(long = "split-gates")]
2112 split_gates: bool,
2113
2114 #[arg(long)]
2116 dry_run: bool,
2117 },
2118
2119 ReconcileReview {
2121 #[arg(long, value_enum)]
2123 provider: CiProviderArg,
2124
2125 #[arg(long)]
2127 pr: Option<String>,
2128
2129 #[arg(long)]
2131 mr: Option<String>,
2132
2133 #[arg(long)]
2135 envelope: PathBuf,
2136
2137 #[arg(long)]
2139 repo: Option<String>,
2140
2141 #[arg(long = "project-id")]
2143 project_id: Option<String>,
2144
2145 #[arg(long = "api-url")]
2147 api_url: Option<String>,
2148
2149 #[arg(long)]
2151 dry_run: bool,
2152 },
2153}
2154
2155#[derive(Subcommand)]
2156enum RulePackCli {
2157 Init {
2159 name: Option<String>,
2161
2162 #[arg(long, default_value = "starter")]
2164 template: String,
2165
2166 #[arg(long, default_value = "rule-packs")]
2168 dir: String,
2169
2170 #[arg(long)]
2172 no_config: bool,
2173 },
2174
2175 List,
2177
2178 Test {
2180 pack: Option<PathBuf>,
2182 },
2183
2184 Schema,
2186}
2187
2188#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2189enum CiProviderArg {
2190 Github,
2191 Gitlab,
2192}
2193
2194#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2196pub enum EffortFilter {
2197 Low,
2198 Medium,
2199 High,
2200}
2201
2202impl EffortFilter {
2203 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2205 match self {
2206 Self::Low => fallow_output::EffortEstimate::Low,
2207 Self::Medium => fallow_output::EffortEstimate::Medium,
2208 Self::High => fallow_output::EffortEstimate::High,
2209 }
2210 }
2211}
2212
2213#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2215pub enum HealthSeverityCli {
2216 Moderate,
2217 High,
2218 Critical,
2219}
2220
2221impl HealthSeverityCli {
2222 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2224 match self {
2225 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2226 Self::High => fallow_output::FindingSeverity::High,
2227 Self::Critical => fallow_output::FindingSeverity::Critical,
2228 }
2229 }
2230}
2231
2232#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2238pub enum EmailModeArg {
2239 Raw,
2241 Handle,
2243 Anonymized,
2245 #[value(hide = true)]
2247 Hash,
2248}
2249
2250impl EmailModeArg {
2251 const fn to_config(self) -> fallow_config::EmailMode {
2253 match self {
2254 Self::Raw => fallow_config::EmailMode::Raw,
2255 Self::Handle => fallow_config::EmailMode::Handle,
2256 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2257 Self::Hash => fallow_config::EmailMode::Hash,
2258 }
2259 }
2260}
2261
2262#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2264pub enum AuditGateArg {
2265 NewOnly,
2267 All,
2269}
2270
2271impl From<AuditGateArg> for fallow_config::AuditGate {
2272 fn from(value: AuditGateArg) -> Self {
2273 match value {
2274 AuditGateArg::NewOnly => Self::NewOnly,
2275 AuditGateArg::All => Self::All,
2276 }
2277 }
2278}
2279
2280fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2284 let value: usize = s
2285 .parse()
2286 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2287 if value < 2 {
2288 return Err(format!(
2289 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2290 ));
2291 }
2292 Ok(value)
2293}
2294
2295fn resolve_audit_baseline_path(
2301 root: &std::path::Path,
2302 cli: Option<&std::path::Path>,
2303 config: Option<&str>,
2304) -> Option<PathBuf> {
2305 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2306 config.map(|p| {
2307 let path = PathBuf::from(p);
2308 if path_util::is_absolute_path_any_platform(&path) {
2309 path
2310 } else {
2311 root.join(path)
2312 }
2313 })
2314 })?;
2315 if path_util::is_absolute_path_any_platform(&path) {
2316 Some(path)
2317 } else {
2318 Some(root.join(path))
2319 }
2320}
2321
2322fn emit_known_failure(
2323 message: &str,
2324 exit_code: u8,
2325 output: fallow_config::OutputFormat,
2326 reason: telemetry::FailureReason,
2327) -> ExitCode {
2328 telemetry::note_failure_reason(reason);
2329 emit_error(message, exit_code, output)
2330}
2331
2332fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2333 if cli.baseline.is_some() {
2334 Some("--baseline")
2335 } else if cli.save_baseline.is_some() {
2336 Some("--save-baseline")
2337 } else if cli.production {
2338 Some("--production")
2339 } else if cli.no_production {
2340 Some("--no-production")
2341 } else if cli.group_by.is_some() {
2342 Some("--group-by")
2343 } else if cli.performance {
2344 Some("--performance")
2345 } else if cli.explain_skipped {
2346 Some("--explain-skipped")
2347 } else if cli.fail_on_regression {
2348 Some("--fail-on-regression")
2349 } else if cli.regression_baseline.is_some() {
2350 Some("--regression-baseline")
2351 } else if cli.save_regression_baseline.is_some() {
2352 Some("--save-regression-baseline")
2353 } else if cli.dupes_mode.is_some() {
2354 Some("--dupes-mode")
2355 } else if cli.dupes_threshold.is_some() {
2356 Some("--dupes-threshold")
2357 } else if cli.dupes_min_tokens.is_some() {
2358 Some("--dupes-min-tokens")
2359 } else if cli.dupes_min_lines.is_some() {
2360 Some("--dupes-min-lines")
2361 } else if cli.dupes_min_occurrences.is_some() {
2362 Some("--dupes-min-occurrences")
2363 } else if cli.dupes_skip_local {
2364 Some("--dupes-skip-local")
2365 } else if cli.dupes_cross_language {
2366 Some("--dupes-cross-language")
2367 } else if cli.dupes_ignore_imports {
2368 Some("--dupes-ignore-imports")
2369 } else if cli.dupes_no_ignore_imports {
2370 Some("--dupes-no-ignore-imports")
2371 } else if cli.include_entry_exports {
2372 Some("--include-entry-exports")
2373 } else {
2374 None
2375 }
2376}
2377
2378struct DispatchContext<'a> {
2379 cli: &'a Cli,
2380 root: &'a std::path::Path,
2381 output: fallow_config::OutputFormat,
2382 quiet: bool,
2383 cli_format_was_explicit: bool,
2384 threads: usize,
2385 tolerance: regression::Tolerance,
2386 save_regression_file: Option<&'a std::path::PathBuf>,
2387 save_to_config: bool,
2388}
2389
2390impl DispatchContext<'_> {
2391 fn ci_defaults(&self) -> (fallow_config::OutputFormat, bool, bool) {
2392 apply_ci_defaults(
2393 self.cli.ci,
2394 self.cli.fail_on_issues,
2395 self.output,
2396 self.quiet,
2397 self.cli_format_was_explicit,
2398 )
2399 }
2400
2401 fn production_modes(
2402 &self,
2403 dead_code: bool,
2404 health: bool,
2405 dupes: bool,
2406 ) -> Result<ProductionModes, ExitCode> {
2407 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2408 }
2409
2410 fn production_for(
2411 &self,
2412 analysis: fallow_config::ProductionAnalysis,
2413 ) -> Result<bool, ExitCode> {
2414 self.production_modes(false, false, false)
2415 .map(|modes| modes.for_analysis(analysis))
2416 }
2417
2418 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2419 regression::RegressionOpts {
2420 fail_on_regression: self.cli.fail_on_regression,
2421 tolerance: self.tolerance,
2422 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2423 save_target: if let Some(path) = self.save_regression_file {
2424 regression::SaveRegressionTarget::File(path)
2425 } else if self.save_to_config {
2426 regression::SaveRegressionTarget::Config
2427 } else {
2428 regression::SaveRegressionTarget::None
2429 },
2430 scoped,
2431 quiet: self.quiet,
2432 output: self.output,
2433 }
2434 }
2435}
2436
2437#[cfg(unix)]
2452fn signal_test_helper() -> ExitCode {
2453 use std::io::Write as _;
2454 use std::process::Command;
2455
2456 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2457 signal::set_graceful_mode();
2458 }
2459
2460 let mut command = Command::new("sleep");
2461 command.arg("30");
2462 let child = match signal::ScopedChild::spawn(&mut command) {
2463 Ok(c) => c,
2464 Err(err) => {
2465 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2466 return ExitCode::from(2);
2467 }
2468 };
2469 let pid = child.id();
2470 let stdout = std::io::stdout();
2471 let mut lock = stdout.lock();
2472 let _ = writeln!(lock, "{pid}");
2473 let _ = lock.flush();
2474 drop(lock);
2475 let _ = child.wait_with_output();
2476 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2477 return ExitCode::SUCCESS;
2478 }
2479 std::thread::sleep(std::time::Duration::from_secs(5));
2480 ExitCode::SUCCESS
2481}
2482
2483#[cfg(not(unix))]
2484fn signal_test_helper() -> ExitCode {
2485 ExitCode::from(2)
2486}
2487
2488fn install_spawn_hooks() {
2489 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2490 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2491}
2492
2493fn install_signal_handlers() {
2494 if let Err(err) = signal::install_handlers() {
2495 use std::io::Write as _;
2496 let stderr = std::io::stderr();
2497 let mut lock = stderr.lock();
2498 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2499 }
2500}
2501
2502fn redirect_report_to_file(
2507 path: &std::path::Path,
2508 output: fallow_config::OutputFormat,
2509) -> Result<(), ExitCode> {
2510 if let Some(parent) = path.parent()
2511 && !parent.as_os_str().is_empty()
2512 && let Err(e) = std::fs::create_dir_all(parent)
2513 {
2514 return Err(emit_error(
2515 &format!(
2516 "failed to create {} for --output-file: {e}",
2517 parent.display()
2518 ),
2519 2,
2520 output,
2521 ));
2522 }
2523 match std::fs::File::create(path) {
2524 Ok(file) => {
2525 report::sink::set_file_sink(file);
2526 colored::control::set_override(false);
2527 Ok(())
2528 }
2529 Err(e) => Err(emit_error(
2530 &format!("failed to open {} for --output-file: {e}", path.display()),
2531 2,
2532 output,
2533 )),
2534 }
2535}
2536
2537fn finalize_report_file(
2540 path: &std::path::Path,
2541 quiet: bool,
2542 output: fallow_config::OutputFormat,
2543) -> Result<(), ExitCode> {
2544 if let Err(e) = report::sink::flush() {
2545 return Err(emit_error(
2546 &format!("failed to write {}: {e}", path.display()),
2547 2,
2548 output,
2549 ));
2550 }
2551 if !quiet && report::sink::wrote() {
2555 eprintln!("Report written to {}", path.display());
2556 }
2557 Ok(())
2558}
2559
2560pub fn run() -> ExitCode {
2565 install_signal_handlers();
2566 install_spawn_hooks();
2567
2568 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2569 return signal_test_helper();
2570 }
2571
2572 let (mut cli, fmt) = match parse_cli_args() {
2573 Ok(parsed) => parsed,
2574 Err(code) => return code,
2575 };
2576
2577 if let Some(code) = run_schema_command_if_requested(&cli) {
2578 return code;
2579 }
2580
2581 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output) {
2582 return code;
2583 }
2584 let telemetry_run = start_telemetry_run(&cli, &fmt);
2585
2586 let (root, threads) = match validate_inputs(&cli, fmt.output) {
2587 Ok(v) => v,
2588 Err(code) => {
2589 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2590 }
2591 };
2592
2593 let FormatConfig {
2594 output,
2595 quiet,
2596 cli_format_was_explicit,
2597 } = fmt;
2598
2599 let tolerance = match run_pre_dispatch_checks(&cli, &root, output, quiet, telemetry_run) {
2600 Ok(tolerance) => tolerance,
2601 Err(code) => return code,
2602 };
2603
2604 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2605
2606 let command = cli.command.take();
2607 let dispatch = DispatchContext {
2608 cli: &cli,
2609 root: &root,
2610 output,
2611 quiet,
2612 cli_format_was_explicit,
2613 threads,
2614 tolerance,
2615 save_regression_file: save_regression_file.as_ref(),
2616 save_to_config,
2617 };
2618 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2619 Ok(code) => code,
2620 Err(code) => return code,
2621 };
2622 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2623}
2624
2625fn dispatch_and_finalize(
2629 dispatch: &DispatchContext<'_>,
2630 command: Option<Command>,
2631) -> Result<ExitCode, ExitCode> {
2632 let cli = dispatch.cli;
2633 let output = dispatch.output;
2634 let quiet = dispatch.quiet;
2635
2636 if let Some(path) = cli.output_file.as_deref()
2639 && let Err(code) = redirect_report_to_file(path, output)
2640 {
2641 return Err(code);
2642 }
2643
2644 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2645 emit_error(bare_coverage_subcommand_error_message(), 2, output)
2646 } else {
2647 match command {
2648 None => dispatch_bare_command(dispatch),
2649 Some(cmd) => dispatch_subcommand(cmd, dispatch),
2650 }
2651 };
2652
2653 if let Some(path) = cli.output_file.as_deref()
2654 && let Err(code) = finalize_report_file(path, quiet, output)
2655 {
2656 return Err(code);
2657 }
2658 Ok(exit_code)
2659}
2660
2661fn run_telemetry_command_if_requested(
2662 cli: &mut Cli,
2663 output: fallow_config::OutputFormat,
2664) -> Option<ExitCode> {
2665 if matches!(cli.command, Some(Command::Telemetry { .. }))
2666 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2667 {
2668 return Some(telemetry::run(map_telemetry_subcommand(subcommand), output));
2669 }
2670 None
2671}
2672
2673fn run_schema_command_if_requested(cli: &Cli) -> Option<ExitCode> {
2674 match cli.command {
2675 Some(Command::Schema) => Some(schema::run_schema()),
2676 Some(Command::ConfigSchema) => Some(init::run_config_schema()),
2677 Some(Command::PluginSchema) => Some(init::run_plugin_schema()),
2678 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema()),
2679 _ => None,
2680 }
2681}
2682
2683fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2684 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2685 opt.as_ref()
2686 .filter(|path| !path.is_empty())
2687 .map(std::path::PathBuf::from)
2688 });
2689 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2690 (save_file, save_to_config)
2691}
2692
2693fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2694 let cli = dispatch.cli;
2695 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2696 let production = match dispatch.production_modes(
2697 cli.production_dead_code,
2698 cli.production_health,
2699 cli.production_dupes,
2700 ) {
2701 Ok(production) => production,
2702 Err(code) => return code,
2703 };
2704 let coverage_inputs = match resolve_health_coverage_inputs(
2705 dispatch,
2706 cli.coverage.as_deref(),
2707 cli.coverage_root.as_deref(),
2708 ) {
2709 Ok(inputs) => inputs,
2710 Err(code) => return code,
2711 };
2712 run_bare_combined(
2713 dispatch,
2714 production,
2715 &coverage_inputs,
2716 BareAnalyses {
2717 run_check,
2718 run_dupes,
2719 run_health,
2720 },
2721 )
2722}
2723
2724#[derive(Clone, Copy)]
2726struct BareAnalyses {
2727 run_check: bool,
2728 run_dupes: bool,
2729 run_health: bool,
2730}
2731
2732fn run_bare_combined(
2735 dispatch: &DispatchContext<'_>,
2736 production: ProductionModes,
2737 coverage_inputs: &ResolvedHealthCoverageInputs,
2738 analyses: BareAnalyses,
2739) -> ExitCode {
2740 let cli = dispatch.cli;
2741 let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
2742 combined::run_combined(&combined::CombinedOptions {
2743 root: dispatch.root,
2744 config_path: &cli.config,
2745 output,
2746 no_cache: cli.no_cache,
2747 threads: dispatch.threads,
2748 quiet,
2749 allow_remote_extends: cli.allow_remote_extends,
2750 fail_on_issues,
2751 sarif_file: cli.sarif_file.as_deref(),
2752 changed_since: cli.changed_since.as_deref(),
2753 churn_file: cli.churn_file.as_deref(),
2754 baseline: cli.baseline.as_deref(),
2755 save_baseline: cli.save_baseline.as_deref(),
2756 production: cli.production,
2757 production_dead_code: Some(production.dead_code),
2758 production_health: Some(production.health),
2759 production_dupes: Some(production.dupes),
2760 workspace: cli.workspace.as_deref(),
2761 changed_workspaces: cli.changed_workspaces.as_deref(),
2762 group_by: cli.group_by,
2763 explain: cli.explain,
2764 explain_skipped: cli.explain_skipped,
2765 performance: cli.performance,
2766 summary: cli.summary,
2767 run_check: analyses.run_check,
2768 run_dupes: analyses.run_dupes,
2769 run_health: analyses.run_health,
2770 dupes_mode: cli.dupes_mode,
2771 dupes_threshold: cli.dupes_threshold,
2772 dupes_min_tokens: cli.dupes_min_tokens,
2773 dupes_min_lines: cli.dupes_min_lines,
2774 dupes_min_occurrences: cli.dupes_min_occurrences,
2775 dupes_skip_local: cli.dupes_skip_local,
2776 dupes_cross_language: cli.dupes_cross_language,
2777 dupes_ignore_imports: resolve_ignore_imports(
2778 cli.dupes_ignore_imports,
2779 cli.dupes_no_ignore_imports,
2780 ),
2781 score: cli.score || cli.trend,
2782 trend: cli.trend,
2783 save_snapshot: cli.save_snapshot.as_ref(),
2784 coverage: coverage_inputs.coverage.as_deref(),
2785 coverage_root: coverage_inputs.coverage_root.as_deref(),
2786 include_entry_exports: cli.include_entry_exports,
2787 regression_opts: dispatch.regression_opts(
2788 cli.changed_since.is_some()
2789 || cli.workspace.is_some()
2790 || cli.changed_workspaces.is_some(),
2791 ),
2792 })
2793}
2794
2795fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2796 let cli = dispatch.cli;
2797 let root = dispatch.root;
2798 let output = dispatch.output;
2799 let quiet = dispatch.quiet;
2800 match command {
2801 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
2802 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
2803 Command::Inspect {
2804 file,
2805 symbol,
2806 symbol_chain,
2807 churn,
2808 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
2809 Command::Trace {
2810 symbol,
2811 callers,
2812 callees,
2813 depth,
2814 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
2815 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
2816 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
2817 Command::Hooks { subcommand } => run_hooks_command(root, subcommand, output),
2818 Command::Ci { subcommand } => ci::run(map_ci_subcommand(subcommand), output),
2819 Command::ConfigSchema => init::run_config_schema(),
2820 Command::PluginSchema => init::run_plugin_schema(),
2821 Command::PluginCheck => plugin_check::run_plugin_check(root, output),
2822 Command::RulePackSchema => init::run_rule_pack_schema(),
2823 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
2824 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
2825 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
2826 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
2827 root,
2828 explicit_config: cli.config.as_deref(),
2829 path_only: path,
2830 output,
2831 quiet,
2832 load_options: fallow_config::ConfigLoadOptions {
2833 allow_remote_extends: cli.allow_remote_extends,
2834 },
2835 }),
2836 Command::Recommend => onboarding::run_recommend(root, output),
2837 list @ (Command::Workspaces | Command::List { .. }) => {
2838 dispatch_list_command(&list, dispatch)
2839 }
2840 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
2841 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
2842 Command::Flags { top } => dispatch_flags_command(dispatch, top),
2843 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
2844 Command::Explain { issue_type } => explain::run_explain(&issue_type.join(" "), output),
2845 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
2846 Command::DecisionSurface { max_decisions } => {
2847 dispatch_decision_surface(dispatch, max_decisions)
2848 }
2849 Command::Impact {
2850 subcommand,
2851 all,
2852 sort,
2853 limit,
2854 } => dispatch_impact(
2855 root,
2856 quiet,
2857 output,
2858 subcommand,
2859 ImpactCrossRepoOpts { all, sort, limit },
2860 ),
2861 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
2862 Command::Report { from } => cli_report::run_report(&from, output, root),
2863 Command::Schema => unreachable!("handled above"),
2864 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
2865 Command::License { subcommand } => dispatch_license_command(subcommand, output),
2866 Command::Telemetry { .. } => unreachable!("handled before root validation"),
2867 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
2868 setup_hooks @ Command::SetupHooks { .. } => {
2869 dispatch_setup_hooks_command(&setup_hooks, dispatch)
2870 }
2871 }
2872}
2873
2874fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2876 let filters = check_issue_filters(&command);
2877 let Command::Check {
2878 include_dupes,
2879 trace,
2880 trace_file,
2881 trace_dependency,
2882 impact_closure,
2883 top,
2884 file,
2885 ..
2886 } = command
2887 else {
2888 unreachable!("check dispatcher only handles check commands");
2889 };
2890
2891 dispatch_check(
2892 dispatch,
2893 &CheckDispatchArgs {
2894 filters,
2895 trace_opts: TraceOptions {
2896 trace_export: trace,
2897 trace_file,
2898 trace_dependency,
2899 impact_closure,
2900 performance: dispatch.cli.performance,
2901 },
2902 include_dupes,
2903 top,
2904 file,
2905 },
2906 )
2907}
2908
2909fn check_issue_filters(command: &Command) -> IssueFilters {
2914 check_issue_filters_framework(command, &check_issue_filters_core(command))
2915}
2916
2917fn check_issue_filters_core(command: &Command) -> IssueFilters {
2920 let Command::Check {
2921 unused_files,
2922 unused_exports,
2923 unused_deps,
2924 unused_types,
2925 private_type_leaks,
2926 unused_enum_members,
2927 unused_class_members,
2928 unresolved_imports,
2929 unlisted_deps,
2930 duplicate_exports,
2931 circular_deps,
2932 re_export_cycles,
2933 boundary_violations,
2934 policy_violations,
2935 stale_suppressions,
2936 ..
2937 } = command
2938 else {
2939 unreachable!("check filter builder only handles check commands");
2940 };
2941
2942 let mut filters = IssueFilters::default();
2943 for (flag, active) in [
2944 ("--unused-files", *unused_files),
2945 ("--unused-exports", *unused_exports),
2946 ("--unused-deps", *unused_deps),
2947 ("--unused-types", *unused_types),
2948 ("--private-type-leaks", *private_type_leaks),
2949 ("--unused-enum-members", *unused_enum_members),
2950 ("--unused-class-members", *unused_class_members),
2951 ("--unresolved-imports", *unresolved_imports),
2952 ("--unlisted-deps", *unlisted_deps),
2953 ("--duplicate-exports", *duplicate_exports),
2954 ("--circular-deps", *circular_deps),
2955 ("--re-export-cycles", *re_export_cycles),
2956 ("--boundary-violations", *boundary_violations),
2957 ("--policy-violations", *policy_violations),
2958 ("--stale-suppressions", *stale_suppressions),
2959 ] {
2960 enable_check_filter(&mut filters, flag, active);
2961 }
2962 filters
2963}
2964
2965fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
2968 let Command::Check {
2969 unused_store_members,
2970 unprovided_injects,
2971 unrendered_components,
2972 unused_component_props,
2973 unused_component_emits,
2974 unused_component_inputs,
2975 unused_component_outputs,
2976 unused_svelte_events,
2977 unused_server_actions,
2978 unused_load_data_keys,
2979 unused_catalog_entries,
2980 empty_catalog_groups,
2981 unresolved_catalog_references,
2982 unused_dependency_overrides,
2983 misconfigured_dependency_overrides,
2984 ..
2985 } = command
2986 else {
2987 unreachable!("check filter builder only handles check commands");
2988 };
2989
2990 let mut filters = base.clone();
2991 for (flag, active) in [
2992 ("--unused-store-members", *unused_store_members),
2993 ("--unprovided-injects", *unprovided_injects),
2994 ("--unrendered-components", *unrendered_components),
2995 ("--unused-component-props", *unused_component_props),
2996 ("--unused-component-emits", *unused_component_emits),
2997 ("--unused-component-inputs", *unused_component_inputs),
2998 ("--unused-component-outputs", *unused_component_outputs),
2999 ("--unused-svelte-events", *unused_svelte_events),
3000 ("--unused-server-actions", *unused_server_actions),
3001 ("--unused-load-data-keys", *unused_load_data_keys),
3002 ("--unused-catalog-entries", *unused_catalog_entries),
3003 ("--empty-catalog-groups", *empty_catalog_groups),
3004 (
3005 "--unresolved-catalog-references",
3006 *unresolved_catalog_references,
3007 ),
3008 (
3009 "--unused-dependency-overrides",
3010 *unused_dependency_overrides,
3011 ),
3012 (
3013 "--misconfigured-dependency-overrides",
3014 *misconfigured_dependency_overrides,
3015 ),
3016 ] {
3017 enable_check_filter(&mut filters, flag, active);
3018 }
3019 filters
3020}
3021
3022fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3023 if active {
3024 assert!(
3025 filters.enable_cli_filter_flag(flag),
3026 "check command uses unregistered dead-code filter flag {flag}"
3027 );
3028 }
3029}
3030
3031fn dispatch_inspect_command(
3032 dispatch: &DispatchContext<'_>,
3033 file: Option<String>,
3034 symbol: Option<String>,
3035 symbol_chain: bool,
3036 churn: bool,
3037) -> ExitCode {
3038 let target = match (file, symbol) {
3039 (Some(file), None) => inspect::InspectTarget::File { file },
3040 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3041 Some((file, export_name))
3042 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3043 {
3044 inspect::InspectTarget::Symbol {
3045 file: file.to_string(),
3046 export_name: export_name.to_string(),
3047 }
3048 }
3049 _ => {
3050 return emit_error(
3051 "--symbol must be formatted as FILE:EXPORT",
3052 2,
3053 dispatch.output,
3054 );
3055 }
3056 },
3057 _ => {
3058 return emit_error(
3059 "inspect requires exactly one of --file or --symbol",
3060 2,
3061 dispatch.output,
3062 );
3063 }
3064 };
3065
3066 let churn_config = if churn {
3067 match load_config_for_analysis(
3068 dispatch.root,
3069 &dispatch.cli.config,
3070 ConfigLoadOptions {
3071 output: dispatch.output,
3072 no_cache: dispatch.cli.no_cache,
3073 threads: dispatch.threads,
3074 production_override: None,
3075 quiet: dispatch.quiet,
3076 allow_remote_extends: dispatch.cli.allow_remote_extends,
3077 },
3078 fallow_config::ProductionAnalysis::Health,
3079 ) {
3080 Ok(config) => Some(config),
3081 Err(code) => return code,
3082 }
3083 } else {
3084 None
3085 };
3086
3087 inspect::run_inspect(&inspect::InspectOptions {
3088 root: dispatch.root,
3089 config_path: dispatch.cli.config.as_ref(),
3090 output: dispatch.output,
3091 no_cache: dispatch.cli.no_cache,
3092 no_production: dispatch.cli.no_production,
3093 max_file_size: dispatch.cli.max_file_size,
3094 threads: dispatch.threads,
3095 quiet: dispatch.quiet,
3096 production: dispatch.cli.production,
3097 workspace: dispatch.cli.workspace.as_ref(),
3098 target,
3099 churn_cache_dir: churn_config
3100 .as_ref()
3101 .map(|config| config.cache_dir.as_path()),
3102 symbol_chain,
3103 })
3104}
3105
3106fn dispatch_trace_command(
3107 dispatch: &DispatchContext<'_>,
3108 symbol: String,
3109 callers: bool,
3110 callees: bool,
3111 depth: Option<u32>,
3112) -> ExitCode {
3113 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3114 root: dispatch.root,
3115 config_path: &dispatch.cli.config,
3116 output: dispatch.output,
3117 no_cache: dispatch.cli.no_cache,
3118 threads: dispatch.threads,
3119 quiet: dispatch.quiet,
3120 allow_remote_extends: dispatch.cli.allow_remote_extends,
3121 target: symbol,
3122 callers,
3123 callees,
3124 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3125 })
3126}
3127
3128fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3129 let Command::Security {
3130 subcommand,
3131 runtime_coverage,
3132 min_invocations_hot,
3133 file,
3134 gate,
3135 surface,
3136 } = command
3137 else {
3138 unreachable!("security dispatcher only handles security commands");
3139 };
3140
3141 let gate = gate.map(security::SecurityGateArg::into_mode);
3142 let cli = dispatch.cli;
3143 let (output, _quiet, fail_on_issues) = dispatch.ci_defaults();
3144 let derived_flags = SecurityDerivedFlagState {
3145 output,
3146 ci: cli.ci,
3147 fail_on_issues,
3148 sarif_file: cli.sarif_file.as_deref(),
3149 summary: cli.summary,
3150 explain: cli.explain,
3151 runtime_coverage: runtime_coverage.as_deref(),
3152 min_invocations_hot,
3153 file: file.as_slice(),
3154 gate,
3155 surface,
3156 };
3157 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3158 return code;
3159 }
3160
3161 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3162 run_security_blind_spots_or_default(
3163 dispatch,
3164 &SecurityRunInputs {
3165 scoped_files: &scoped_files,
3166 subcommand: &subcommand,
3167 runtime_coverage: runtime_coverage.as_deref(),
3168 min_invocations_hot,
3169 gate,
3170 surface,
3171 },
3172 &derived_flags,
3173 )
3174}
3175
3176struct SecurityRunInputs<'a> {
3179 scoped_files: &'a [PathBuf],
3180 subcommand: &'a Option<SecuritySubcommand>,
3181 runtime_coverage: Option<&'a Path>,
3182 min_invocations_hot: u64,
3183 gate: Option<security::SecurityGateMode>,
3184 surface: bool,
3185}
3186
3187fn run_security_blind_spots_or_default(
3189 dispatch: &DispatchContext<'_>,
3190 inputs: &SecurityRunInputs<'_>,
3191 derived_flags: &SecurityDerivedFlagState<'_>,
3192) -> ExitCode {
3193 let cli = dispatch.cli;
3194 let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
3195 let opts = security::SecurityOptions {
3196 root: dispatch.root,
3197 config_path: &cli.config,
3198 output,
3199 no_cache: cli.no_cache,
3200 threads: dispatch.threads,
3201 quiet,
3202 allow_remote_extends: cli.allow_remote_extends,
3203 fail_on_issues,
3204 sarif_file: cli.sarif_file.as_deref(),
3205 summary: cli.summary,
3206 changed_since: cli.changed_since.as_deref(),
3207 use_shared_diff_index: true,
3208 workspace: cli.workspace.as_deref(),
3209 changed_workspaces: cli.changed_workspaces.as_deref(),
3210 file: inputs.scoped_files,
3211 surface: inputs.surface,
3212 gate: inputs.gate,
3213 runtime_coverage: inputs.runtime_coverage,
3214 min_invocations_hot: inputs.min_invocations_hot,
3215 explain: cli.explain,
3216 };
3217 if matches!(
3218 inputs.subcommand,
3219 Some(SecuritySubcommand::BlindSpots { .. })
3220 ) {
3221 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3222 return code;
3223 }
3224 security::run_blind_spots(&opts)
3225 } else {
3226 security::run(&opts)
3227 }
3228}
3229
3230fn try_run_security_survivors(
3233 subcommand: Option<&SecuritySubcommand>,
3234 flags: &SecurityDerivedFlagState<'_>,
3235) -> Option<ExitCode> {
3236 let Some(SecuritySubcommand::Survivors {
3237 candidates,
3238 verdicts,
3239 require_verdict_for_each_candidate,
3240 }) = subcommand
3241 else {
3242 return None;
3243 };
3244 if let Some(code) = validate_security_survivors_flags(flags) {
3245 return Some(code);
3246 }
3247 Some(security::run_survivors(
3248 &security::SecuritySurvivorsOptions {
3249 output: flags.output,
3250 candidates,
3251 verdicts,
3252 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3253 },
3254 ))
3255}
3256
3257fn scoped_security_files(
3259 file: &[PathBuf],
3260 subcommand: Option<&SecuritySubcommand>,
3261) -> Vec<PathBuf> {
3262 let mut scoped_files = file.to_vec();
3263 if let Some(SecuritySubcommand::BlindSpots {
3264 file: blind_spot_files,
3265 }) = subcommand
3266 {
3267 scoped_files.extend(blind_spot_files.iter().cloned());
3268 }
3269 scoped_files
3270}
3271
3272struct SecurityDerivedFlagState<'a> {
3273 output: fallow_config::OutputFormat,
3274 ci: bool,
3275 fail_on_issues: bool,
3276 sarif_file: Option<&'a Path>,
3277 summary: bool,
3278 explain: bool,
3279 runtime_coverage: Option<&'a Path>,
3280 min_invocations_hot: u64,
3281 file: &'a [PathBuf],
3282 gate: Option<security::SecurityGateMode>,
3283 surface: bool,
3284}
3285
3286fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3287 let flag = if flags.ci {
3288 Some("--ci")
3289 } else if flags.fail_on_issues {
3290 Some("--fail-on-issues")
3291 } else if flags.sarif_file.is_some() {
3292 Some("--sarif-file")
3293 } else if flags.summary {
3294 Some("--summary")
3295 } else if flags.explain {
3296 Some("--explain")
3297 } else if flags.runtime_coverage.is_some() {
3298 Some("--runtime-coverage")
3299 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3300 Some("--min-invocations-hot")
3301 } else if !flags.file.is_empty() {
3302 Some("--file")
3303 } else if flags.gate.is_some() {
3304 Some("--gate")
3305 } else if flags.surface {
3306 Some("--surface")
3307 } else {
3308 None
3309 }?;
3310 Some(emit_error(
3311 &format!("{flag} is not valid with `fallow security survivors`."),
3312 2,
3313 flags.output,
3314 ))
3315}
3316
3317fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3318 let flag = if flags.ci {
3319 Some("--ci")
3320 } else if flags.fail_on_issues {
3321 Some("--fail-on-issues")
3322 } else if flags.sarif_file.is_some() {
3323 Some("--sarif-file")
3324 } else if flags.summary {
3325 Some("--summary")
3326 } else if flags.explain {
3327 Some("--explain")
3328 } else if flags.runtime_coverage.is_some() {
3329 Some("--runtime-coverage")
3330 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3331 Some("--min-invocations-hot")
3332 } else if flags.gate.is_some() {
3333 Some("--gate")
3334 } else if flags.surface {
3335 Some("--surface")
3336 } else {
3337 None
3338 }?;
3339 Some(emit_error(
3340 &format!("{flag} is not valid with `fallow security blind-spots`."),
3341 2,
3342 flags.output,
3343 ))
3344}
3345
3346fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3347 let Command::Dupes {
3348 mode,
3349 min_tokens,
3350 min_lines,
3351 min_occurrences,
3352 threshold,
3353 skip_local,
3354 cross_language,
3355 ignore_imports,
3356 no_ignore_imports,
3357 top,
3358 trace,
3359 } = command
3360 else {
3361 unreachable!("dupes dispatcher only handles dupes commands");
3362 };
3363
3364 dispatch_dupes(
3365 dispatch,
3366 &DupesDispatchArgs {
3367 mode,
3368 min_tokens,
3369 min_lines,
3370 min_occurrences,
3371 threshold,
3372 skip_local,
3373 cross_language,
3374 ignore_imports,
3375 no_ignore_imports,
3376 top,
3377 trace,
3378 },
3379 )
3380}
3381
3382fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3383 let Command::Init {
3384 toml,
3385 agents,
3386 hooks,
3387 branch,
3388 decline,
3389 } = command
3390 else {
3391 unreachable!("init dispatcher only handles init commands");
3392 };
3393
3394 init::run_init(&init::InitOptions {
3395 root,
3396 use_toml: toml,
3397 agents,
3398 hooks,
3399 branch: branch.as_deref(),
3400 decline,
3401 quiet,
3402 })
3403}
3404
3405fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3406 let Command::Fix {
3407 dry_run,
3408 yes,
3409 no_create_config,
3410 } = command
3411 else {
3412 unreachable!("fix dispatcher only handles fix commands");
3413 };
3414
3415 dispatch_fix(
3416 dispatch,
3417 FixDispatchArgs {
3418 dry_run: *dry_run,
3419 yes: *yes,
3420 no_create_config: *no_create_config,
3421 },
3422 )
3423}
3424
3425fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3426 match command {
3427 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3428 Command::List {
3429 entry_points,
3430 files,
3431 plugins,
3432 boundaries,
3433 workspaces,
3434 } => dispatch_list(
3435 dispatch,
3436 ListDispatchArgs {
3437 entry_points: *entry_points,
3438 files: *files,
3439 plugins: *plugins,
3440 boundaries: *boundaries,
3441 workspaces: *workspaces,
3442 },
3443 ),
3444 _ => unreachable!("list dispatcher only handles list commands"),
3445 }
3446}
3447
3448fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3449 let Command::Migrate {
3450 toml,
3451 jsonc,
3452 dry_run,
3453 from,
3454 } = command
3455 else {
3456 unreachable!("migrate dispatcher only handles migrate commands");
3457 };
3458
3459 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3460}
3461
3462fn dispatch_license_command(
3463 subcommand: LicenseCli,
3464 output: fallow_config::OutputFormat,
3465) -> ExitCode {
3466 license::run(&map_license_subcommand(subcommand), output)
3467}
3468
3469fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3470 match subcommand {
3471 CiTemplateCli::Gitlab { vendor, force } => {
3472 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3473 vendor_dir: vendor,
3474 force,
3475 })
3476 }
3477 }
3478}
3479
3480fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3481 let cli = dispatch.cli;
3482 coverage::run(
3483 map_coverage_subcommand(subcommand, cli.explain),
3484 &coverage::RunContext {
3485 root: dispatch.root,
3486 config_path: &cli.config,
3487 output: dispatch.output,
3488 quiet: dispatch.quiet,
3489 no_cache: cli.no_cache,
3490 threads: dispatch.threads,
3491 explain: cli.explain,
3492 allow_remote_extends: cli.allow_remote_extends,
3493 },
3494 )
3495}
3496
3497fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3498 let Command::Health {
3499 max_cyclomatic,
3500 max_cognitive,
3501 max_crap,
3502 top,
3503 sort,
3504 complexity,
3505 complexity_breakdown,
3506 file_scores,
3507 coverage_gaps,
3508 hotspots,
3509 ownership,
3510 ownership_emails,
3511 targets,
3512 css,
3513 effort,
3514 score,
3515 min_score,
3516 min_severity,
3517 report_only,
3518 since,
3519 min_commits,
3520 save_snapshot,
3521 trend,
3522 coverage,
3523 coverage_root,
3524 runtime_coverage,
3525 min_invocations_hot,
3526 min_observation_volume,
3527 low_traffic_threshold,
3528 } = command
3529 else {
3530 unreachable!("health dispatcher only handles health commands");
3531 };
3532
3533 let ownership = ownership || ownership_emails.is_some();
3534 let hotspots = hotspots || ownership;
3535 let args = HealthDispatchArgs {
3536 max_cyclomatic,
3537 max_cognitive,
3538 max_crap,
3539 top,
3540 sort,
3541 complexity,
3542 complexity_breakdown,
3543 file_scores,
3544 coverage_gaps,
3545 hotspots,
3546 ownership,
3547 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3548 targets,
3549 css,
3550 effort,
3551 score,
3552 min_score,
3553 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3554 report_only,
3555 since: since.as_deref(),
3556 min_commits,
3557 save_snapshot: save_snapshot.as_ref(),
3558 trend,
3559 coverage: coverage.as_deref(),
3560 coverage_root: coverage_root.as_deref(),
3561 runtime_coverage: runtime_coverage.as_deref(),
3562 min_invocations_hot,
3563 min_observation_volume,
3564 low_traffic_threshold,
3565 };
3566 dispatch_health(dispatch, &args)
3567}
3568
3569fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3570 let Command::SetupHooks {
3571 agent,
3572 dry_run,
3573 force,
3574 user,
3575 gitignore_claude,
3576 uninstall,
3577 } = command
3578 else {
3579 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3580 };
3581
3582 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3583 root: dispatch.root,
3584 agent: *agent,
3585 dry_run: *dry_run,
3586 force: *force,
3587 user: *user,
3588 gitignore_claude: *gitignore_claude,
3589 uninstall: *uninstall,
3590 })
3591}
3592
3593fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3594 let Command::Audit {
3595 production_dead_code,
3596 production_health,
3597 production_dupes,
3598 dead_code_baseline,
3599 health_baseline,
3600 dupes_baseline,
3601 max_crap,
3602 coverage,
3603 coverage_root,
3604 no_css,
3605 css_deep,
3606 no_css_deep,
3607 gate,
3608 runtime_coverage,
3609 min_invocations_hot,
3610 gate_marker,
3611 brief,
3612 max_decisions,
3613 walkthrough_guide,
3614 walkthrough_file,
3615 walkthrough,
3616 mark_viewed,
3617 show_cleared,
3618 show_deprioritized,
3619 } = command
3620 else {
3621 unreachable!("audit dispatcher only handles audit commands");
3622 };
3623
3624 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3627
3628 dispatch_audit(
3629 dispatch,
3630 &AuditDispatchArgs {
3631 production_dead_code,
3632 production_health,
3633 production_dupes,
3634 dead_code_baseline,
3635 health_baseline,
3636 dupes_baseline,
3637 max_crap,
3638 coverage,
3639 coverage_root,
3640 no_css,
3641 css_deep,
3642 no_css_deep,
3643 gate,
3644 runtime_coverage,
3645 min_invocations_hot,
3646 gate_marker,
3647 brief,
3648 max_decisions,
3649 walkthrough_guide,
3650 walkthrough_file,
3651 walkthrough,
3652 mark_viewed,
3653 show_cleared,
3654 show_deprioritized,
3655 },
3656 )
3657}
3658
3659fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
3660 let cli = dispatch.cli;
3661 let root = dispatch.root;
3662 let output = dispatch.output;
3663 let quiet = dispatch.quiet;
3664 let threads = dispatch.threads;
3665 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3666 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3667 Err(code) => return code,
3668 };
3669 flags::run_flags(&flags::FlagsOptions {
3670 root,
3671 config_path: &cli.config,
3672 output,
3673 no_cache: cli.no_cache,
3674 threads,
3675 quiet,
3676 allow_remote_extends: cli.allow_remote_extends,
3677 production,
3678 workspace: cli.workspace.as_deref(),
3679 changed_workspaces: cli.changed_workspaces.as_deref(),
3680 changed_since: cli.changed_since.as_deref(),
3681 explain: cli.explain,
3682 top,
3683 })
3684}
3685
3686fn dispatch_suppressions_command(
3687 dispatch: &DispatchContext<'_>,
3688 file: &[std::path::PathBuf],
3689) -> ExitCode {
3690 let cli = dispatch.cli;
3691 let root = dispatch.root;
3692 let output = dispatch.output;
3693 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3694 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3695 Err(code) => return code,
3696 };
3697 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
3698 root,
3699 config_path: &cli.config,
3700 output,
3701 no_cache: cli.no_cache,
3702 threads: dispatch.threads,
3703 quiet: dispatch.quiet,
3704 allow_remote_extends: cli.allow_remote_extends,
3705 production,
3706 workspace: cli.workspace.as_deref(),
3707 changed_workspaces: cli.changed_workspaces.as_deref(),
3708 changed_since: cli.changed_since.as_deref(),
3709 file,
3710 })
3711}
3712
3713fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
3714 guard::run_guard(&guard::GuardOptions {
3715 root: dispatch.root,
3716 config_path: &dispatch.cli.config,
3717 output: dispatch.output,
3718 quiet: dispatch.quiet,
3719 allow_remote_extends: dispatch.cli.allow_remote_extends,
3720 files,
3721 })
3722}
3723
3724fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
3725 let ctx = rule_pack::RulePackContext {
3726 root: dispatch.root,
3727 config_path: &dispatch.cli.config,
3728 output: dispatch.output,
3729 quiet: dispatch.quiet,
3730 no_cache: dispatch.cli.no_cache,
3731 threads: Some(dispatch.threads),
3732 allow_remote_extends: dispatch.cli.allow_remote_extends,
3733 };
3734 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
3735}
3736
3737fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
3738 match subcommand {
3739 RulePackCli::Init {
3740 name,
3741 template,
3742 dir,
3743 no_config,
3744 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
3745 name,
3746 template,
3747 dir,
3748 no_config,
3749 }),
3750 RulePackCli::List => rule_pack::RulePackSubcommand::List,
3751 RulePackCli::Test { pack } => {
3752 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
3753 }
3754 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
3755 }
3756}
3757
3758fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
3759 match sub {
3760 LicenseCli::Activate {
3761 jwt,
3762 from_file,
3763 stdin,
3764 trial,
3765 email,
3766 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
3767 raw_jwt: jwt,
3768 from_file,
3769 from_stdin: stdin,
3770 trial,
3771 email,
3772 }),
3773 LicenseCli::Status => license::LicenseSubcommand::Status,
3774 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
3775 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
3776 }
3777}
3778
3779fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
3780 match sub {
3781 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
3782 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
3783 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
3784 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
3785 }
3786}
3787
3788fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
3789 match sub {
3790 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
3791 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
3792 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
3793 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
3794 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
3795 }
3796}
3797
3798fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
3799 let CiCli::PlanPrComment {
3800 body,
3801 marker_id,
3802 clean,
3803 existing_comment_id,
3804 existing_body,
3805 } = command
3806 else {
3807 unreachable!("ci plan-pr-comment mapper called with different variant");
3808 };
3809
3810 ci::CiCommand::PlanPrComment {
3811 body,
3812 marker_id,
3813 clean,
3814 existing_comment_id,
3815 existing_body,
3816 }
3817}
3818
3819fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
3820 let CiCli::PostPrComment {
3821 provider,
3822 pr,
3823 mr,
3824 body,
3825 envelope,
3826 marker_id,
3827 clean,
3828 repo,
3829 project_id,
3830 api_url,
3831 dry_run,
3832 } = command
3833 else {
3834 unreachable!("ci post-pr-comment mapper called with different variant");
3835 };
3836
3837 ci::CiCommand::PostPrComment {
3838 provider: map_ci_provider(provider),
3839 target: pr.or(mr),
3840 body,
3841 envelope,
3842 marker_id,
3843 clean,
3844 repo,
3845 project_id,
3846 api_url,
3847 dry_run,
3848 }
3849}
3850
3851fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
3852 let CiCli::PostReview {
3853 provider,
3854 pr,
3855 mr,
3856 envelope,
3857 repo,
3858 project_id,
3859 api_url,
3860 dry_run,
3861 } = command
3862 else {
3863 unreachable!("ci post-review mapper called with different variant");
3864 };
3865
3866 ci::CiCommand::PostReview {
3867 provider: map_ci_provider(provider),
3868 target: pr.or(mr),
3869 envelope,
3870 repo,
3871 project_id,
3872 api_url,
3873 dry_run,
3874 }
3875}
3876
3877fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
3878 let CiCli::PostCheckRun {
3879 provider,
3880 decision,
3881 repo,
3882 head_sha,
3883 api_url,
3884 split_gates,
3885 dry_run,
3886 } = command
3887 else {
3888 unreachable!("ci post-check-run mapper called with different variant");
3889 };
3890
3891 ci::CiCommand::PostCheckRun {
3892 provider: map_ci_provider(provider),
3893 decision,
3894 repo,
3895 head_sha,
3896 api_url,
3897 split_gates,
3898 dry_run,
3899 }
3900}
3901
3902fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
3903 let CiCli::ReconcileReview {
3904 provider,
3905 pr,
3906 mr,
3907 envelope,
3908 repo,
3909 project_id,
3910 api_url,
3911 dry_run,
3912 } = command
3913 else {
3914 unreachable!("ci reconcile-review mapper called with different variant");
3915 };
3916
3917 ci::CiCommand::ReconcileReview {
3918 provider: map_ci_provider(provider),
3919 target: pr.or(mr),
3920 envelope,
3921 repo,
3922 project_id,
3923 api_url,
3924 dry_run,
3925 }
3926}
3927
3928fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
3929 match provider {
3930 CiProviderArg::Github => ci::CiProvider::Github,
3931 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
3932 }
3933}
3934
3935fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
3936 match sub {
3937 CoverageCli::Setup {
3938 yes,
3939 non_interactive,
3940 json,
3941 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
3942 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
3943 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
3944 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
3945 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
3946 }
3947}
3948
3949fn map_coverage_setup(
3950 yes: bool,
3951 non_interactive: bool,
3952 json: bool,
3953 explain: bool,
3954) -> coverage::CoverageSubcommand {
3955 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
3956 yes,
3957 non_interactive: non_interactive || json,
3958 json,
3959 explain,
3960 })
3961}
3962
3963fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
3964 let CoverageCli::Analyze {
3965 runtime_coverage,
3966 cloud,
3967 api_key,
3968 api_endpoint,
3969 repo,
3970 project_id,
3971 coverage_period,
3972 environment,
3973 commit_sha,
3974 production,
3975 min_invocations_hot,
3976 min_observation_volume,
3977 low_traffic_threshold,
3978 top,
3979 blast_radius,
3980 importance,
3981 } = sub
3982 else {
3983 unreachable!("coverage analyze mapper called with non-analyze variant");
3984 };
3985 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
3986 runtime_coverage: runtime_coverage.clone(),
3987 cloud: *cloud,
3988 api_key: api_key.clone(),
3989 api_endpoint: api_endpoint.clone(),
3990 repo: repo.clone(),
3991 project_id: project_id.clone(),
3992 coverage_period: *coverage_period,
3993 environment: environment.clone(),
3994 commit_sha: commit_sha.clone(),
3995 production: *production,
3996 min_invocations_hot: *min_invocations_hot,
3997 min_observation_volume: *min_observation_volume,
3998 low_traffic_threshold: *low_traffic_threshold,
3999 top: *top,
4000 blast_radius: *blast_radius,
4001 importance: *importance,
4002 })
4003}
4004
4005fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4006 let CoverageCli::UploadInventory {
4007 api_key,
4008 api_endpoint,
4009 project_id,
4010 git_sha,
4011 allow_dirty,
4012 exclude_paths,
4013 path_prefix,
4014 dry_run,
4015 with_callers,
4016 ignore_upload_errors,
4017 } = sub
4018 else {
4019 unreachable!("coverage inventory mapper called with non-inventory variant");
4020 };
4021 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4022 api_key: api_key.clone(),
4023 api_endpoint: api_endpoint.clone(),
4024 project_id: project_id.clone(),
4025 git_sha: git_sha.clone(),
4026 allow_dirty: *allow_dirty,
4027 exclude_paths: exclude_paths.clone(),
4028 path_prefix: path_prefix.clone(),
4029 dry_run: *dry_run,
4030 with_callers: *with_callers,
4031 ignore_upload_errors: *ignore_upload_errors,
4032 })
4033}
4034
4035fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4036 let CoverageCli::UploadSourceMaps {
4037 dir,
4038 include,
4039 exclude,
4040 repo,
4041 git_sha,
4042 endpoint,
4043 strip_path,
4044 dry_run,
4045 concurrency,
4046 fail_fast,
4047 } = sub
4048 else {
4049 unreachable!("coverage source-map mapper called with non-source-map variant");
4050 };
4051 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4052 dir: dir.clone(),
4053 include: include.clone(),
4054 exclude: exclude.clone(),
4055 repo: repo.clone(),
4056 git_sha: git_sha.clone(),
4057 endpoint: endpoint.clone(),
4058 strip_path: *strip_path,
4059 dry_run: *dry_run,
4060 concurrency: *concurrency,
4061 fail_fast: *fail_fast,
4062 })
4063}
4064
4065fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4066 let CoverageCli::UploadStaticFindings {
4067 api_key,
4068 api_endpoint,
4069 project_id,
4070 git_sha,
4071 allow_dirty,
4072 dry_run,
4073 ignore_upload_errors,
4074 } = sub
4075 else {
4076 unreachable!("coverage static-findings mapper called with non-static variant");
4077 };
4078 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4079 api_key: api_key.clone(),
4080 api_endpoint: api_endpoint.clone(),
4081 project_id: project_id.clone(),
4082 git_sha: git_sha.clone(),
4083 allow_dirty: *allow_dirty,
4084 dry_run: *dry_run,
4085 ignore_upload_errors: *ignore_upload_errors,
4086 })
4087}
4088
4089struct CheckDispatchArgs {
4090 filters: IssueFilters,
4091 trace_opts: TraceOptions,
4092 include_dupes: bool,
4093 top: Option<usize>,
4094 file: Vec<std::path::PathBuf>,
4095}
4096
4097#[derive(Clone, Copy)]
4098struct ListDispatchArgs {
4099 entry_points: bool,
4100 files: bool,
4101 plugins: bool,
4102 boundaries: bool,
4103 workspaces: bool,
4104}
4105
4106impl ListDispatchArgs {
4107 fn workspaces() -> Self {
4108 Self {
4109 entry_points: false,
4110 files: false,
4111 plugins: false,
4112 boundaries: false,
4113 workspaces: true,
4114 }
4115 }
4116}
4117
4118fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4119 let cli = dispatch.cli;
4120 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4121 Ok(production) => production,
4122 Err(code) => return code,
4123 };
4124 watch::run_watch(&watch::WatchOptions {
4125 root: dispatch.root,
4126 config_path: &cli.config,
4127 output: dispatch.output,
4128 no_cache: cli.no_cache,
4129 threads: dispatch.threads,
4130 quiet: dispatch.quiet,
4131 allow_remote_extends: cli.allow_remote_extends,
4132 production,
4133 clear_screen: !no_clear,
4134 explain: cli.explain,
4135 include_entry_exports: cli.include_entry_exports,
4136 })
4137}
4138
4139#[derive(Clone, Copy)]
4140struct FixDispatchArgs {
4141 dry_run: bool,
4142 yes: bool,
4143 no_create_config: bool,
4144}
4145
4146fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4147 let cli = dispatch.cli;
4148 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4149 Ok(production) => production,
4150 Err(code) => return code,
4151 };
4152 fix::run_fix(&fix::FixOptions {
4153 root: dispatch.root,
4154 config_path: &cli.config,
4155 output: dispatch.output,
4156 no_cache: cli.no_cache,
4157 threads: dispatch.threads,
4158 quiet: dispatch.quiet,
4159 allow_remote_extends: cli.allow_remote_extends,
4160 dry_run: args.dry_run,
4161 yes: args.yes,
4162 production,
4163 no_create_config: args.no_create_config,
4164 })
4165}
4166
4167fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4168 let cli = dispatch.cli;
4169 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4170 Ok(production) => production,
4171 Err(code) => return code,
4172 };
4173 list::run_list(&ListOptions {
4174 root: dispatch.root,
4175 config_path: &cli.config,
4176 output: dispatch.output,
4177 threads: dispatch.threads,
4178 no_cache: cli.no_cache,
4179 entry_points: args.entry_points,
4180 files: args.files,
4181 plugins: args.plugins,
4182 boundaries: args.boundaries,
4183 workspaces: args.workspaces,
4184 production,
4185 allow_remote_extends: cli.allow_remote_extends,
4186 })
4187}
4188
4189fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4190 let cli = dispatch.cli;
4191 let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
4192 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4193 Ok(production) => production,
4194 Err(code) => return code,
4195 };
4196 check::run_check(&CheckOptions {
4197 root: dispatch.root,
4198 config_path: &cli.config,
4199 output,
4200 no_cache: cli.no_cache,
4201 threads: dispatch.threads,
4202 quiet,
4203 allow_remote_extends: cli.allow_remote_extends,
4204 fail_on_issues,
4205 filters: &args.filters,
4206 changed_since: cli.changed_since.as_deref(),
4207 diff_index: None,
4208 use_shared_diff_index: true,
4209 baseline: cli.baseline.as_deref(),
4210 save_baseline: cli.save_baseline.as_deref(),
4211 sarif_file: cli.sarif_file.as_deref(),
4212 production,
4213 production_override: Some(production),
4214 workspace: cli.workspace.as_deref(),
4215 changed_workspaces: cli.changed_workspaces.as_deref(),
4216 group_by: cli.group_by,
4217 include_dupes: args.include_dupes,
4218 trace_opts: &args.trace_opts,
4219 explain: cli.explain,
4220 top: args.top,
4221 file: &args.file,
4222 include_entry_exports: cli.include_entry_exports,
4223 summary: cli.summary,
4224 regression_opts: dispatch.regression_opts(
4225 cli.changed_since.is_some()
4226 || cli.workspace.is_some()
4227 || cli.changed_workspaces.is_some()
4228 || !args.file.is_empty(),
4229 ),
4230 retain_modules_for_health: false,
4231 defer_performance: false,
4232 })
4233}
4234
4235fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4241 if no_ignore_imports {
4242 Some(false)
4243 } else if ignore_imports {
4244 Some(true)
4245 } else {
4246 None
4247 }
4248}
4249
4250struct DupesDispatchArgs {
4251 mode: Option<DupesMode>,
4252 min_tokens: Option<usize>,
4253 min_lines: Option<usize>,
4254 min_occurrences: Option<usize>,
4255 threshold: Option<f64>,
4256 skip_local: bool,
4257 cross_language: bool,
4258 ignore_imports: bool,
4259 no_ignore_imports: bool,
4260 top: Option<usize>,
4261 trace: Option<String>,
4262}
4263
4264fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4265 let cli = dispatch.cli;
4266 let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
4267 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4268 Ok(production) => production,
4269 Err(code) => return code,
4270 };
4271 dupes::run_dupes(&DupesOptions {
4272 root: dispatch.root,
4273 config_path: &cli.config,
4274 output,
4275 no_cache: cli.no_cache,
4276 threads: dispatch.threads,
4277 quiet,
4278 allow_remote_extends: cli.allow_remote_extends,
4279 mode: args.mode,
4280 min_tokens: args.min_tokens,
4281 min_lines: args.min_lines,
4282 min_occurrences: args.min_occurrences,
4283 threshold: args.threshold,
4284 skip_local: args.skip_local,
4285 cross_language: args.cross_language,
4286 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4287 top: args.top,
4288 baseline_path: cli.baseline.as_deref(),
4289 save_baseline_path: cli.save_baseline.as_deref(),
4290 production,
4291 production_override: Some(production),
4292 trace: args.trace.as_deref(),
4293 changed_since: cli.changed_since.as_deref(),
4294 diff_index: None,
4295 use_shared_diff_index: true,
4296 changed_files: None,
4297 workspace: cli.workspace.as_deref(),
4298 changed_workspaces: cli.changed_workspaces.as_deref(),
4299 explain: cli.explain,
4300 explain_skipped: cli.explain_skipped,
4301 summary: cli.summary,
4302 group_by: cli.group_by,
4303 performance: cli.performance,
4304 })
4305}
4306
4307struct AuditDispatchArgs {
4308 production_dead_code: bool,
4309 production_health: bool,
4310 production_dupes: bool,
4311 dead_code_baseline: Option<PathBuf>,
4312 health_baseline: Option<PathBuf>,
4313 dupes_baseline: Option<PathBuf>,
4314 max_crap: Option<f64>,
4315 coverage: Option<PathBuf>,
4316 coverage_root: Option<PathBuf>,
4317 no_css: bool,
4318 css_deep: bool,
4319 no_css_deep: bool,
4320 gate: Option<AuditGateArg>,
4321 runtime_coverage: Option<PathBuf>,
4322 min_invocations_hot: u64,
4323 gate_marker: Option<String>,
4324 brief: bool,
4325 max_decisions: usize,
4326 walkthrough_guide: bool,
4328 walkthrough_file: Option<PathBuf>,
4331 walkthrough: bool,
4333 mark_viewed: Vec<PathBuf>,
4335 show_cleared: bool,
4337 show_deprioritized: bool,
4339}
4340
4341struct ResolvedAuditInputs {
4342 audit_cfg: fallow_config::AuditConfig,
4343 cache_dir: PathBuf,
4344 production: ProductionModes,
4345 dead_code_baseline: Option<PathBuf>,
4346 health_baseline: Option<PathBuf>,
4347 dupes_baseline: Option<PathBuf>,
4348 coverage: Option<PathBuf>,
4349}
4350
4351fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4352 let cli = dispatch.cli;
4353 let output = dispatch.output;
4354
4355 if cli.baseline.is_some() || cli.save_baseline.is_some() {
4356 return emit_error(
4357 "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>`)",
4358 2,
4359 output,
4360 );
4361 }
4362
4363 let inputs = match resolve_audit_inputs(dispatch, args) {
4364 Ok(inputs) => inputs,
4365 Err(code) => return code,
4366 };
4367
4368 run_resolved_audit(dispatch, args, &inputs)
4369}
4370
4371fn resolve_audit_inputs(
4372 dispatch: &DispatchContext<'_>,
4373 args: &AuditDispatchArgs,
4374) -> Result<ResolvedAuditInputs, ExitCode> {
4375 let cli = dispatch.cli;
4376 let root = dispatch.root;
4377 let output = dispatch.output;
4378 let config = load_config(
4379 root,
4380 &cli.config,
4381 LoadConfigArgs {
4382 output,
4383 no_cache: cli.no_cache,
4384 threads: dispatch.threads,
4385 production: cli.production,
4386 quiet: dispatch.quiet,
4387 allow_remote_extends: cli.allow_remote_extends,
4388 },
4389 )?;
4390 let cache_dir = config.cache_dir.clone();
4391 let audit_cfg = config.audit;
4392 let production = resolve_production_modes(
4393 cli,
4394 root,
4395 output,
4396 args.production_dead_code,
4397 args.production_health,
4398 args.production_dupes,
4399 )?;
4400 let resolved_dead_code_baseline = resolve_audit_baseline_path(
4401 root,
4402 args.dead_code_baseline.as_deref(),
4403 audit_cfg.dead_code_baseline.as_deref(),
4404 );
4405 let resolved_health_baseline = resolve_audit_baseline_path(
4406 root,
4407 args.health_baseline.as_deref(),
4408 audit_cfg.health_baseline.as_deref(),
4409 );
4410 let resolved_dupes_baseline = resolve_audit_baseline_path(
4411 root,
4412 args.dupes_baseline.as_deref(),
4413 audit_cfg.dupes_baseline.as_deref(),
4414 );
4415 let coverage = args
4416 .coverage
4417 .clone()
4418 .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4419
4420 Ok(ResolvedAuditInputs {
4421 audit_cfg,
4422 cache_dir,
4423 production,
4424 dead_code_baseline: resolved_dead_code_baseline,
4425 health_baseline: resolved_health_baseline,
4426 dupes_baseline: resolved_dupes_baseline,
4427 coverage,
4428 })
4429}
4430
4431fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4432 !args.no_css && config.css.unwrap_or(true)
4433}
4434
4435fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4436 audit_css_enabled(config, args)
4437 && !args.no_css_deep
4438 && (args.css_deep || config.css_deep.unwrap_or(true))
4439}
4440
4441fn run_resolved_audit(
4442 dispatch: &DispatchContext<'_>,
4443 args: &AuditDispatchArgs,
4444 inputs: &ResolvedAuditInputs,
4445) -> ExitCode {
4446 let cli = dispatch.cli;
4447 audit::run_audit(
4448 &audit::AuditOptions {
4449 root: dispatch.root,
4450 config_path: &cli.config,
4451 cache_dir: &inputs.cache_dir,
4452 output: dispatch.output,
4453 no_cache: cli.no_cache,
4454 threads: dispatch.threads,
4455 quiet: dispatch.quiet,
4456 allow_remote_extends: cli.allow_remote_extends,
4457 changed_since: cli.changed_since.as_deref(),
4458 production: cli.production,
4459 production_dead_code: Some(inputs.production.dead_code),
4460 production_health: Some(inputs.production.health),
4461 production_dupes: Some(inputs.production.dupes),
4462 workspace: cli.workspace.as_deref(),
4463 changed_workspaces: cli.changed_workspaces.as_deref(),
4464 explain: cli.explain,
4465 explain_skipped: cli.explain_skipped,
4466 performance: cli.performance,
4467 group_by: cli.group_by,
4468 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4469 health_baseline: inputs.health_baseline.as_deref(),
4470 dupes_baseline: inputs.dupes_baseline.as_deref(),
4471 max_crap: args.max_crap,
4472 coverage: inputs.coverage.as_deref(),
4473 coverage_root: args.coverage_root.as_deref(),
4474 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4475 include_entry_exports: cli.include_entry_exports,
4476 css: audit_css_enabled(&inputs.audit_cfg, args),
4480 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4481 runtime_coverage: args.runtime_coverage.as_deref(),
4482 min_invocations_hot: args.min_invocations_hot,
4483 brief: args.brief,
4484 max_decisions: args.max_decisions,
4485 walkthrough_guide: args.walkthrough_guide,
4486 walkthrough: args.walkthrough,
4487 mark_viewed: &args.mark_viewed,
4488 show_cleared: args.show_cleared,
4489 walkthrough_file: args.walkthrough_file.as_deref(),
4490 show_deprioritized: args.show_deprioritized,
4491 },
4492 args.gate_marker.as_deref(),
4493 )
4494}
4495
4496fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4500 let args = decision_surface_audit_args(max_decisions);
4501 let inputs = match resolve_audit_inputs(dispatch, &args) {
4502 Ok(inputs) => inputs,
4503 Err(code) => return code,
4504 };
4505 audit::run_decision_surface(&decision_surface_audit_options(
4506 dispatch,
4507 &inputs,
4508 max_decisions,
4509 ))
4510}
4511
4512fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
4513 AuditDispatchArgs {
4514 production_dead_code: false,
4515 production_health: false,
4516 production_dupes: false,
4517 dead_code_baseline: None,
4518 health_baseline: None,
4519 dupes_baseline: None,
4520 max_crap: None,
4521 coverage: None,
4522 coverage_root: None,
4523 no_css: true,
4524 css_deep: false,
4525 no_css_deep: false,
4526 gate: None,
4527 runtime_coverage: None,
4528 min_invocations_hot: 0,
4529 gate_marker: None,
4530 brief: true,
4531 max_decisions,
4532 walkthrough_guide: false,
4533 walkthrough_file: None,
4534 walkthrough: false,
4535 mark_viewed: Vec::new(),
4536 show_cleared: false,
4537 show_deprioritized: false,
4538 }
4539}
4540
4541fn decision_surface_audit_options<'a>(
4542 dispatch: &'a DispatchContext<'a>,
4543 inputs: &'a ResolvedAuditInputs,
4544 max_decisions: usize,
4545) -> audit::AuditOptions<'a> {
4546 let cli = dispatch.cli;
4547 audit::AuditOptions {
4548 root: dispatch.root,
4549 config_path: &cli.config,
4550 cache_dir: &inputs.cache_dir,
4551 output: dispatch.output,
4552 no_cache: cli.no_cache,
4553 threads: dispatch.threads,
4554 quiet: dispatch.quiet,
4555 allow_remote_extends: cli.allow_remote_extends,
4556 changed_since: cli.changed_since.as_deref(),
4557 production: cli.production,
4558 production_dead_code: Some(inputs.production.dead_code),
4559 production_health: Some(inputs.production.health),
4560 production_dupes: Some(inputs.production.dupes),
4561 workspace: cli.workspace.as_deref(),
4562 changed_workspaces: cli.changed_workspaces.as_deref(),
4563 explain: cli.explain,
4564 explain_skipped: cli.explain_skipped,
4565 performance: cli.performance,
4566 group_by: cli.group_by,
4567 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4568 health_baseline: inputs.health_baseline.as_deref(),
4569 dupes_baseline: inputs.dupes_baseline.as_deref(),
4570 max_crap: None,
4571 coverage: None,
4572 coverage_root: None,
4573 gate: inputs.audit_cfg.gate,
4574 include_entry_exports: cli.include_entry_exports,
4575 css: false,
4577 css_deep: false,
4578 runtime_coverage: None,
4579 min_invocations_hot: 0,
4580 brief: true,
4581 max_decisions,
4582 walkthrough_guide: false,
4583 walkthrough: false,
4584 mark_viewed: &[],
4585 show_cleared: false,
4586 walkthrough_file: None,
4587 show_deprioritized: false,
4588 }
4589}
4590
4591struct HealthDispatchArgs<'a> {
4592 max_cyclomatic: Option<u16>,
4593 max_cognitive: Option<u16>,
4594 max_crap: Option<f64>,
4595 top: Option<usize>,
4596 sort: health::SortBy,
4597 complexity: bool,
4598 complexity_breakdown: bool,
4599 file_scores: bool,
4600 coverage_gaps: bool,
4601 hotspots: bool,
4602 ownership: bool,
4603 ownership_emails: Option<fallow_config::EmailMode>,
4604 targets: bool,
4605 css: bool,
4606 effort: Option<EffortFilter>,
4607 score: bool,
4608 min_score: Option<f64>,
4609 min_severity: Option<fallow_output::FindingSeverity>,
4610 report_only: bool,
4611 since: Option<&'a str>,
4612 min_commits: Option<u32>,
4613 save_snapshot: Option<&'a Option<String>>,
4614 trend: bool,
4615 coverage: Option<&'a std::path::Path>,
4616 coverage_root: Option<&'a std::path::Path>,
4617 runtime_coverage: Option<&'a std::path::Path>,
4618 min_invocations_hot: u64,
4619 min_observation_volume: Option<u32>,
4620 low_traffic_threshold: Option<f64>,
4621}
4622
4623struct ResolvedHealthCoverageInputs {
4624 coverage: Option<PathBuf>,
4625 coverage_root: Option<PathBuf>,
4626}
4627
4628fn resolve_health_coverage_inputs(
4629 dispatch: &DispatchContext<'_>,
4630 cli_coverage: Option<&std::path::Path>,
4631 cli_coverage_root: Option<&std::path::Path>,
4632) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
4633 let env_coverage = path_from_env("FALLOW_COVERAGE");
4634 let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
4635 let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
4636 let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
4637 let config_health = if needs_config_coverage || needs_config_coverage_root {
4638 Some(
4639 load_config(
4640 dispatch.root,
4641 &dispatch.cli.config,
4642 LoadConfigArgs {
4643 output: dispatch.output,
4644 no_cache: dispatch.cli.no_cache,
4645 threads: dispatch.threads,
4646 production: dispatch.cli.production,
4647 quiet: dispatch.quiet,
4648 allow_remote_extends: dispatch.cli.allow_remote_extends,
4649 },
4650 )?
4651 .health,
4652 )
4653 } else {
4654 None
4655 };
4656
4657 Ok(ResolvedHealthCoverageInputs {
4658 coverage: cli_coverage
4659 .map(std::path::Path::to_path_buf)
4660 .or(env_coverage)
4661 .or_else(|| {
4662 config_health
4663 .as_ref()
4664 .and_then(|health| health.coverage.clone())
4665 }),
4666 coverage_root: cli_coverage_root
4667 .map(std::path::Path::to_path_buf)
4668 .or(env_coverage_root)
4669 .or_else(|| {
4670 config_health
4671 .as_ref()
4672 .and_then(|health| health.coverage_root.clone())
4673 }),
4674 })
4675}
4676
4677fn path_from_env(name: &str) -> Option<PathBuf> {
4678 std::env::var_os(name)
4679 .filter(|value| !value.is_empty())
4680 .map(PathBuf::from)
4681}
4682
4683fn validate_health_report_only_gate(
4684 report_only: bool,
4685 min_score: Option<f64>,
4686 min_severity: Option<fallow_output::FindingSeverity>,
4687 output: fallow_config::OutputFormat,
4688) -> Result<(), ExitCode> {
4689 if report_only && (min_score.is_some() || min_severity.is_some()) {
4690 return Err(emit_error(
4691 "--report-only cannot be combined with --min-score or --min-severity. \
4692 --report-only always exits 0; drop it to gate on score/severity, or \
4693 drop the gate flags to stay advisory.",
4694 2,
4695 output,
4696 ));
4697 }
4698
4699 Ok(())
4700}
4701
4702fn resolve_runtime_coverage_options(
4703 runtime_coverage: Option<&std::path::Path>,
4704 min_invocations_hot: u64,
4705 min_observation_volume: Option<u32>,
4706 low_traffic_threshold: Option<f64>,
4707 output: fallow_config::OutputFormat,
4708) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
4709 let Some(path) = runtime_coverage else {
4710 return Ok(None);
4711 };
4712
4713 health::coverage::prepare_options(
4714 path,
4715 min_invocations_hot,
4716 min_observation_volume,
4717 low_traffic_threshold,
4718 output,
4719 )
4720 .map(Some)
4721}
4722
4723fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
4724 let cli = dispatch.cli;
4725 let root = dispatch.root;
4726 let (output, _quiet, _fail_on_issues) = dispatch.ci_defaults();
4727 if let Err(code) = validate_health_report_only_gate(
4728 args.report_only,
4729 args.min_score,
4730 args.min_severity,
4731 output,
4732 ) {
4733 return code;
4734 }
4735 let runtime_coverage = match resolve_runtime_coverage_options(
4736 args.runtime_coverage,
4737 args.min_invocations_hot,
4738 args.min_observation_volume,
4739 args.low_traffic_threshold,
4740 output,
4741 ) {
4742 Ok(options) => options,
4743 Err(code) => return code,
4744 };
4745 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4746 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
4747 Err(code) => return code,
4748 };
4749 let coverage_inputs =
4750 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
4751 Ok(inputs) => inputs,
4752 Err(code) => return code,
4753 };
4754 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
4755 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
4756}
4757
4758fn derive_health_dispatch_run<'a>(
4759 args: &'a HealthDispatchArgs<'a>,
4760 output: fallow_config::OutputFormat,
4761 coverage_inputs: &'a ResolvedHealthCoverageInputs,
4762 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
4763) -> fallow_engine::health::HealthRunOptions<'a> {
4764 fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
4765 output,
4766 thresholds: health_threshold_overrides(args),
4767 top: args.top,
4768 sort: args.sort.clone().into(),
4769 complexity: args.complexity,
4770 file_scores: args.file_scores,
4771 coverage_gaps: args.coverage_gaps,
4772 hotspots: args.hotspots,
4773 ownership: args.ownership,
4774 ownership_emails: args.ownership_emails,
4775 targets: args.targets,
4776 css: args.css,
4777 effort: args.effort.map(EffortFilter::to_estimate),
4778 score: args.score,
4779 gates: health_gate_options(args),
4780 snapshot_requested: args.save_snapshot.is_some(),
4781 trend: args.trend,
4782 since: args.since,
4783 min_commits: args.min_commits,
4784 coverage_inputs: health_coverage_inputs(coverage_inputs),
4785 runtime_coverage,
4786 })
4787}
4788
4789fn health_threshold_overrides(
4790 args: &HealthDispatchArgs<'_>,
4791) -> fallow_engine::health::HealthThresholdOverrides {
4792 fallow_engine::health::HealthThresholdOverrides {
4793 max_cyclomatic: args.max_cyclomatic,
4794 max_cognitive: args.max_cognitive,
4795 max_crap: args.max_crap,
4796 }
4797}
4798
4799fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
4800 fallow_engine::health::HealthGateOptions {
4801 min_score: args.min_score,
4802 min_severity: args.min_severity,
4803 report_only: args.report_only,
4804 }
4805}
4806
4807fn health_coverage_inputs(
4808 coverage_inputs: &ResolvedHealthCoverageInputs,
4809) -> fallow_engine::health::HealthCoverageInputs<'_> {
4810 fallow_engine::health::HealthCoverageInputs {
4811 coverage: coverage_inputs.coverage.as_deref(),
4812 coverage_root: coverage_inputs.coverage_root.as_deref(),
4813 }
4814}
4815
4816struct ResolvedHealthDispatch<'a> {
4820 run: fallow_engine::health::HealthRunOptions<'a>,
4821 production: bool,
4822}
4823
4824fn run_health_dispatch(
4827 dispatch: &DispatchContext<'_>,
4828 args: &HealthDispatchArgs<'_>,
4829 resolved: ResolvedHealthDispatch<'_>,
4830) -> ExitCode {
4831 let cli = dispatch.cli;
4832 let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
4833 let run = resolved.run;
4834 let sections = run.sections;
4835 let production = resolved.production;
4836 health::run_health(&HealthOptions {
4837 root: dispatch.root,
4838 config_path: &cli.config,
4839 output,
4840 no_cache: cli.no_cache,
4841 threads: dispatch.threads,
4842 quiet,
4843 thresholds: run.thresholds,
4844 top: run.top,
4845 sort: run.sort,
4846 production,
4847 production_override: Some(production),
4848 allow_remote_extends: cli.allow_remote_extends,
4849 changed_since: cli.changed_since.as_deref(),
4850 diff_index: None,
4851 use_shared_diff_index: true,
4852 workspace: cli.workspace.as_deref(),
4853 changed_workspaces: cli.changed_workspaces.as_deref(),
4854 baseline: cli.baseline.as_deref(),
4855 save_baseline: cli.save_baseline.as_deref(),
4856 complexity: sections.complexity,
4857 file_scores: sections.file_scores,
4858 coverage_gaps: sections.coverage_gaps,
4859 config_activates_coverage_gaps: !sections.any_section,
4860 hotspots: sections.hotspots,
4861 ownership: run.ownership,
4862 ownership_emails: run.ownership_emails,
4863 targets: sections.targets,
4864 css: sections.css,
4865 css_deep: false,
4866 force_full: sections.force_full,
4867 score_only_output: sections.score_only_output,
4868 enforce_coverage_gap_gate: true,
4869 effort: run.effort,
4870 score: sections.score,
4871 gates: run.gates,
4872 since: run.since,
4873 min_commits: run.min_commits,
4874 explain: cli.explain,
4875 summary: cli.summary,
4876 save_snapshot: args
4877 .save_snapshot
4878 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
4879 trend: args.trend,
4880 coverage_inputs: run.coverage_inputs,
4881 performance: cli.performance,
4882 runtime_coverage: run.runtime_coverage,
4883 churn_file: cli.churn_file.as_deref(),
4884 complexity_breakdown: args.complexity_breakdown,
4885 group_by: cli.group_by.map(Into::into),
4886 })
4887}
4888
4889#[cfg(test)]
4890mod tests {
4891 use super::*;
4892
4893 #[test]
4897 fn cli_definition_has_no_flag_collisions() {
4898 use clap::CommandFactory;
4899 Cli::command().debug_assert();
4900 }
4901
4902 #[test]
4903 fn regression_baseline_help_explains_the_default_destination() {
4904 use clap::CommandFactory;
4905 let help = Cli::command().render_long_help().to_string();
4906
4907 assert!(help.contains("Omit PATH to update regression.baseline"));
4908 assert!(help.contains("discovered fallow config"));
4909 assert!(help.contains("create .fallowrc.json when none exists"));
4910 }
4911
4912 #[test]
4916 fn after_help_lists_every_task_matrix_command() {
4917 for row in crate::task_matrix::TASK_MATRIX {
4918 assert!(
4919 TOP_LEVEL_AFTER_HELP.contains(row.command),
4920 "root --help cheat sheet is missing task-matrix command '{}'; \
4921 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
4922 row.command
4923 );
4924 }
4925 }
4926
4927 #[test]
4931 fn high_value_commands_route_to_distinct_workflows() {
4932 use clap::Parser;
4933 use fallow_config::OutputFormat;
4934
4935 let distinct = [
4936 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
4937 (vec!["fallow", "security"], telemetry::Workflow::Security),
4938 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
4939 (
4940 vec!["fallow", "explain", "unused-exports"],
4941 telemetry::Workflow::Explain,
4942 ),
4943 (
4944 vec!["fallow", "watch"],
4945 telemetry::Workflow::CodeQualityReview,
4946 ),
4947 (
4948 vec!["fallow", "list"],
4949 telemetry::Workflow::ProjectInventory,
4950 ),
4951 (
4952 vec!["fallow", "workspaces"],
4953 telemetry::Workflow::ProjectInventory,
4954 ),
4955 (
4956 vec!["fallow", "schema"],
4957 telemetry::Workflow::ProjectInventory,
4958 ),
4959 (vec!["fallow", "init"], telemetry::Workflow::Setup),
4960 (
4961 vec!["fallow", "hooks", "install", "--target", "git"],
4962 telemetry::Workflow::Setup,
4963 ),
4964 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
4965 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
4966 (
4967 vec!["fallow", "rule-pack-schema"],
4968 telemetry::Workflow::Setup,
4969 ),
4970 (vec!["fallow", "config"], telemetry::Workflow::Setup),
4971 (
4972 vec!["fallow", "ci-template", "gitlab"],
4973 telemetry::Workflow::Setup,
4974 ),
4975 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
4976 (
4977 vec!["fallow", "telemetry", "status"],
4978 telemetry::Workflow::Setup,
4979 ),
4980 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
4981 (
4982 vec!["fallow", "license", "status"],
4983 telemetry::Workflow::License,
4984 ),
4985 ];
4986 for (argv, expected) in distinct {
4987 let cli = Cli::try_parse_from(&argv).expect("argv parses");
4988 assert_eq!(
4989 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
4990 expected,
4991 "{argv:?} should map to {expected:?}"
4992 );
4993 }
4994 }
4995
4996 #[test]
5001 fn version_flag_accepts_lower_v_upper_v_and_long() {
5002 use clap::CommandFactory;
5003 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5004 let err = Cli::command()
5005 .try_get_matches_from(argv)
5006 .expect_err("version flag should short-circuit parsing");
5007 assert_eq!(
5008 err.kind(),
5009 clap::error::ErrorKind::DisplayVersion,
5010 "{argv:?} should trigger the Version action"
5011 );
5012 }
5013 }
5014
5015 #[test]
5020 fn cli_help_text_contains_no_implementation_status_wording() {
5021 use clap::CommandFactory;
5022 let mut root = Cli::command();
5023 let mut violations: Vec<(String, String)> = Vec::new();
5024 visit_help(&mut root, "fallow", &mut violations);
5025 assert!(
5026 violations.is_empty(),
5027 "found implementation-status wording in --help output:\n{}",
5028 violations
5029 .iter()
5030 .map(|(cmd, line)| format!(" {cmd}: {line}"))
5031 .collect::<Vec<_>>()
5032 .join("\n")
5033 );
5034 }
5035
5036 #[test]
5037 fn top_level_help_groups_commands_by_workflow() {
5038 use clap::CommandFactory;
5039 let help = Cli::command().render_long_help().to_string();
5040 let expected_order = [
5041 "Analysis:",
5042 " dead-code",
5043 " dupes",
5044 " health",
5045 " flags",
5046 " security",
5047 " audit",
5048 "Workflow:",
5049 " watch",
5050 " fix",
5051 "Project inspection:",
5052 " list",
5053 " workspaces",
5054 " explain",
5055 " impact",
5056 "Setup and configuration:",
5057 " init",
5058 " recommend",
5059 " migrate",
5060 " config",
5061 " config-schema",
5062 " plugin-schema",
5063 " plugin-check",
5064 " rule-pack-schema",
5065 "Automation and CI:",
5066 " ci",
5067 " ci-template",
5068 " hooks",
5069 " setup-hooks",
5070 "Runtime coverage:",
5071 " coverage",
5072 " license",
5073 "Reference:",
5074 " schema",
5075 " help",
5076 "Options:",
5077 ];
5078 let mut cursor = 0;
5079 for needle in expected_order {
5080 let Some(offset) = help[cursor..].find(needle) else {
5081 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5082 };
5083 cursor += offset + needle.len();
5084 }
5085 }
5086
5087 #[test]
5088 fn security_help_hides_globals_rejected_by_security_validator() {
5089 let help = render_security_help(SecurityHelpTarget::Parent);
5090
5091 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5092 assert!(
5093 !help_contains_long_flag(&help, long),
5094 "security help must hide unsupported --{long}:\n{help}"
5095 );
5096 }
5097
5098 for long in [
5099 "root",
5100 "config",
5101 "format",
5102 "quiet",
5103 "no-cache",
5104 "threads",
5105 "changed-since",
5106 "diff-file",
5107 "diff-stdin",
5108 "workspace",
5109 "changed-workspaces",
5110 "ci",
5111 "fail-on-issues",
5112 "sarif-file",
5113 "summary",
5114 "output-file",
5115 "max-file-size",
5116 "explain",
5117 "surface",
5118 ] {
5119 assert!(
5120 help_contains_long_flag(&help, long),
5121 "security help must keep supported --{long}:\n{help}"
5122 );
5123 }
5124 }
5125
5126 #[test]
5127 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5128 assert_eq!(
5129 security_help_target(["security", "--help"]),
5130 Some(SecurityHelpTarget::Parent)
5131 );
5132 assert_eq!(
5133 security_help_target(["security", "-h"]),
5134 Some(SecurityHelpTarget::Parent)
5135 );
5136 assert_eq!(
5137 security_help_target(["--format", "json", "security", "--help"]),
5138 Some(SecurityHelpTarget::Parent)
5139 );
5140 assert_eq!(
5141 security_help_target(["help", "security"]),
5142 Some(SecurityHelpTarget::Parent)
5143 );
5144 assert_eq!(
5145 security_help_target(["security", "survivors", "--help"]),
5146 Some(SecurityHelpTarget::Survivors)
5147 );
5148 assert_eq!(
5149 security_help_target(["security", "survivors", "-h"]),
5150 Some(SecurityHelpTarget::Survivors)
5151 );
5152 assert_eq!(
5153 security_help_target(["help", "security", "survivors"]),
5154 Some(SecurityHelpTarget::Survivors)
5155 );
5156 assert_eq!(
5157 security_help_target(["security", "blind-spots", "--help"]),
5158 Some(SecurityHelpTarget::BlindSpots)
5159 );
5160 assert_eq!(
5161 security_help_target(["help", "security", "blind-spots"]),
5162 Some(SecurityHelpTarget::BlindSpots)
5163 );
5164 assert_eq!(security_help_target(["health", "--help"]), None);
5165 assert_eq!(security_help_target(["help", "health"]), None);
5166 }
5167
5168 #[test]
5169 fn security_unsupported_global_validator_matches_hidden_help_contract() {
5170 for (argv, expected) in [
5171 (vec!["fallow", "security", "--performance"], "--performance"),
5172 (
5173 vec!["fallow", "security", "--baseline", "base.json"],
5174 "--baseline",
5175 ),
5176 (
5177 vec!["fallow", "security", "--dupes-mode", "weak"],
5178 "--dupes-mode",
5179 ),
5180 ] {
5181 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5182 assert_eq!(unsupported_security_global(&cli), Some(expected));
5183 }
5184
5185 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5186 .expect("security --explain parses");
5187 assert_eq!(unsupported_security_global(&explain), None);
5188 }
5189
5190 #[test]
5191 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5192 use clap::CommandFactory;
5193
5194 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5195 .get_arguments()
5196 .filter(|arg| arg.is_global_set())
5197 .filter_map(|arg| arg.get_long().map(str::to_owned))
5198 .filter(|name| {
5199 matches!(
5200 name.as_str(),
5201 "root"
5202 | "config"
5203 | "allow-remote-extends"
5204 | "no-cache"
5205 | "threads"
5206 | "changed-since"
5207 | "diff-file"
5208 | "production"
5209 | "workspace"
5210 | "changed-workspaces"
5211 | "explain"
5212 )
5213 })
5214 .collect();
5215 let programmatic_flags: std::collections::BTreeSet<String> =
5216 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5217 .iter()
5218 .map(|flag| (*flag).to_owned())
5219 .collect();
5220
5221 assert_eq!(programmatic_flags, cli_flags);
5222 }
5223
5224 #[test]
5225 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5226 use clap::CommandFactory;
5227
5228 let cli = Cli::command();
5229 let dead_code = cli
5230 .get_subcommands()
5231 .find(|command| command.get_name() == "dead-code")
5232 .expect("dead-code subcommand is registered");
5233 let cli_flags: std::collections::BTreeSet<String> = dead_code
5234 .get_arguments()
5235 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5236 .collect();
5237
5238 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5239 assert!(
5240 cli_flags.contains(*flag),
5241 "registry filter flag {flag} is missing from dead-code clap args"
5242 );
5243 }
5244 }
5245
5246 fn help_contains_long_flag(help: &str, long: &str) -> bool {
5247 let flag = format!("--{long}");
5248 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5249 .any(|token| token == flag)
5250 }
5251
5252 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5253 let help = cmd.render_long_help().to_string();
5254 for line in scan_forbidden(&help) {
5255 violations.push((path.to_owned(), line));
5256 }
5257 let names: Vec<String> = cmd
5258 .get_subcommands()
5259 .map(|sub| sub.get_name().to_owned())
5260 .collect();
5261 for name in names {
5262 if name == "help" {
5263 continue;
5264 }
5265 if let Some(sub) = cmd.find_subcommand_mut(&name) {
5266 let sub_path = format!("{path} {name}");
5267 visit_help(sub, &sub_path, violations);
5268 }
5269 }
5270 }
5271
5272 fn scan_forbidden(s: &str) -> Vec<String> {
5273 let lower = s.to_ascii_lowercase();
5274 let mut out = Vec::new();
5275 for word in ["stub", "placeholder"] {
5276 if let Some(idx) = find_whole_word(&lower, word) {
5277 out.push(extract_line(s, idx));
5278 }
5279 }
5280 if let Some(idx) = lower.find("not yet") {
5281 out.push(extract_line(s, idx));
5282 }
5283 out
5284 }
5285
5286 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5287 let bytes = haystack.as_bytes();
5288 let mut start = 0;
5289 while let Some(rel) = haystack[start..].find(word) {
5290 let abs = start + rel;
5291 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5292 let after_idx = abs + word.len();
5293 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5294 if before_ok && after_ok {
5295 return Some(abs);
5296 }
5297 start = abs + word.len();
5298 }
5299 None
5300 }
5301
5302 fn extract_line(s: &str, byte_idx: usize) -> String {
5303 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5304 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5305 s[line_start..line_end].trim().to_owned()
5306 }
5307
5308 #[test]
5309 fn emit_error_returns_given_exit_code() {
5310 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5311 assert_eq!(code, ExitCode::from(2));
5312 }
5313
5314 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5315 TelemetryRun {
5316 workflow: telemetry::Workflow::Health,
5317 output: fallow_config::OutputFormat::Json,
5318 quiet: true,
5319 start: std::time::Instant::now(),
5320 context: telemetry::WorkflowContext {
5321 run_scope: telemetry::RunScope::FullProject,
5322 config_shape: telemetry::ConfigShape::Default,
5323 output_destination: telemetry::OutputDestination::Stdout,
5324 analysis_mode: mode,
5325 },
5326 }
5327 }
5328
5329 #[test]
5330 fn fallback_failure_reason_skips_success_and_findings() {
5331 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5332
5333 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5334 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5335 }
5336
5337 #[test]
5338 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5339 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5340 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5341
5342 assert_eq!(
5343 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5344 Some(telemetry::FailureReason::Network),
5345 );
5346 assert_eq!(
5347 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5348 Some(telemetry::FailureReason::Auth),
5349 );
5350 assert_eq!(
5351 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5352 Some(telemetry::FailureReason::Auth),
5353 );
5354 assert_eq!(
5355 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5356 Some(telemetry::FailureReason::Analysis),
5357 );
5358 }
5359
5360 #[test]
5361 fn bare_coverage_flags_parse_without_subcommand() {
5362 let cli = Cli::try_parse_from([
5363 "fallow",
5364 "--coverage",
5365 "coverage/coverage-final.json",
5366 "--coverage-root",
5367 "/ci/workspace",
5368 ])
5369 .expect("bare combined coverage flags should parse");
5370 assert!(cli.command.is_none());
5371 assert_eq!(
5372 cli.coverage.as_deref(),
5373 Some(std::path::Path::new("coverage/coverage-final.json"))
5374 );
5375 assert_eq!(
5376 cli.coverage_root.as_deref(),
5377 Some(std::path::Path::new("/ci/workspace"))
5378 );
5379 }
5380
5381 #[test]
5382 fn bare_coverage_before_subcommand_is_detectable() {
5383 let cli = Cli::try_parse_from([
5384 "fallow",
5385 "--coverage",
5386 "coverage/coverage-final.json",
5387 "dead-code",
5388 ])
5389 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5390 assert!(cli.command.is_some());
5391 assert!(cli_has_bare_coverage_input(&cli));
5392 let message = bare_coverage_subcommand_error_message();
5393 assert!(message.contains("bare combined-mode flags"));
5394 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5395 }
5396
5397 #[test]
5398 fn subcommand_coverage_flag_keeps_regular_clap_error() {
5399 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5400 panic!("dead-code --coverage should fail to parse");
5401 };
5402 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5403 }
5404
5405 #[test]
5406 fn format_parsing_covers_all_variants() {
5407 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
5408 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
5409 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
5410 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
5411 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
5412 assert!(matches!(
5413 parse_format_arg("markdown"),
5414 Some(Format::Markdown)
5415 ));
5416 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
5417 assert!(matches!(
5418 parse_format_arg("codeclimate"),
5419 Some(Format::CodeClimate)
5420 ));
5421 assert!(matches!(
5422 parse_format_arg("gitlab-codequality"),
5423 Some(Format::CodeClimate)
5424 ));
5425 assert!(matches!(
5426 parse_format_arg("gitlab-code-quality"),
5427 Some(Format::CodeClimate)
5428 ));
5429 assert!(matches!(
5430 parse_format_arg("pr-comment-github"),
5431 Some(Format::PrCommentGithub)
5432 ));
5433 assert!(matches!(
5434 parse_format_arg("pr-comment-gitlab"),
5435 Some(Format::PrCommentGitlab)
5436 ));
5437 assert!(matches!(
5438 parse_format_arg("review-github"),
5439 Some(Format::ReviewGithub)
5440 ));
5441 assert!(matches!(
5442 parse_format_arg("review-gitlab"),
5443 Some(Format::ReviewGitlab)
5444 ));
5445 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
5446 assert!(parse_format_arg("xml").is_none());
5447 assert!(parse_format_arg("").is_none());
5448 }
5449
5450 #[test]
5451 fn quiet_parsing_logic() {
5452 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
5453 assert!(parse("1"));
5454 assert!(parse("true"));
5455 assert!(parse("TRUE"));
5456 assert!(parse("True"));
5457 assert!(!parse("0"));
5458 assert!(!parse("false"));
5459 assert!(!parse("yes"));
5460 }
5461
5462 #[test]
5463 fn tracing_filter_defaults_to_warn_without_env() {
5464 assert_eq!(build_tracing_filter(None).to_string(), "warn");
5465 }
5466
5467 #[test]
5468 fn tracing_filter_respects_explicit_env_directives() {
5469 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
5470 }
5471
5472 #[test]
5473 fn tracing_filter_treats_empty_env_as_off() {
5474 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
5475 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
5476 }
5477}