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