1use std::{
2 collections::{BTreeMap, BTreeSet},
3 future::Future,
4 io::Write,
5 path::{Path, PathBuf},
6 process::ExitCode,
7 sync::{Arc, Mutex},
8 time::Duration,
9};
10
11mod builtins;
12mod completion;
13mod help;
14mod tree_render;
15
16use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser};
17
18use crate::{
19 ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
20 FeatureFlag, GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec,
21 RuntimeGroupSpec,
22 auth::commands::auth_command_group,
23 command::{
24 CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
25 leaf_matches,
26 },
27 error::exit_code_for_error,
28 feature_flags::{FlagEntry, FlagPolicy, FlagRegistry, Stage},
29 flags::{
30 GlobalFlags, derive_bool_flags, derive_value_flags, extract_command_path,
31 extract_output_format, global_flags_from_matches, has_true_schema_flag, min_stage_env_var,
32 output_env_var, register_global_flags, register_reason_flag, resolve_default_output_format,
33 },
34 guide::{guide_content, render_guide_human},
35 module::{Module, ModuleContext},
36 output::{
37 FieldInfo, HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry,
38 format_help_section, global_human_view_registry_snapshot, global_schema_registry_snapshot,
39 },
40 search::{SearchDocument, SearchIndex},
41};
42
43use builtins::{
44 completion_args, completion_command, guide_args, guide_command, help_args, help_command,
45 search_args, search_command,
46};
47use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
48pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
49
50#[derive(Clone, Debug, Default, Eq, PartialEq)]
52pub struct BuildInfo {
53 pub version: String,
55 pub commit: Option<String>,
57 pub date: Option<String>,
59}
60
61impl BuildInfo {
62 #[must_use]
64 pub fn new(version: impl Into<String>) -> Self {
65 Self {
66 version: version.into(),
67 commit: None,
68 date: None,
69 }
70 }
71
72 #[must_use]
74 pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
75 self.commit = Some(commit.into());
76 self
77 }
78
79 #[must_use]
81 pub fn with_date(mut self, date: impl Into<String>) -> Self {
82 self.date = Some(date.into());
83 self
84 }
85
86 #[must_use]
88 pub fn version_string(&self) -> String {
89 let commit = self.commit.as_deref().unwrap_or_default();
90 let date = self.date.as_deref().unwrap_or_default();
91
92 if commit.is_empty() && date.is_empty() {
93 self.version.clone()
94 } else {
95 format!("{} (commit {commit}, built {date})", self.version)
96 }
97 }
98}
99
100pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
102pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
104pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
106pub type PreRun =
108 Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
109pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
111pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
113pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
115pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
119
120const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
124
125const MAX_ARGV0_DEPTH: usize = 16;
130
131#[derive(Clone)]
143#[non_exhaustive]
144pub enum Argv0Route {
145 Alias(Vec<String>),
151 Personality(Arc<dyn Fn() -> CliConfig + Send + Sync>),
156}
157
158impl std::fmt::Debug for Argv0Route {
159 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 match self {
161 Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(),
162 Self::Personality(_) => formatter.write_str("Personality(..)"),
163 }
164 }
165}
166
167#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175#[non_exhaustive]
176pub enum Argv0LinkMethod {
177 SoftLink,
180 HardLink,
183 Script,
187}
188
189pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 5] =
193 ["help", "guide", "tree", "completion", "search"];
194
195#[derive(Clone, Default)]
201pub struct CliConfig {
202 pub name: String,
204 pub short: String,
206 pub long: Option<String>,
208 pub build: BuildInfo,
210 pub app_id: String,
212 pub default_auth_provider: Option<String>,
214 pub modules: Vec<Module>,
216 pub commands: Vec<RuntimeCommandSpec>,
218 pub auth_extra_commands: Vec<RuntimeCommandSpec>,
224 pub guides: Vec<GuideEntry>,
226 pub views: Vec<HumanViewDef>,
228 pub auth_providers: Vec<Arc<dyn AuthProvider>>,
230 pub user_agent: Option<String>,
234 pub redacted_debug_headers: Vec<String>,
240 pub authz: Option<Arc<dyn Authorizer>>,
242 pub auditor: Option<Arc<dyn Auditor>>,
244 pub activity: Option<Arc<dyn ActivityEmitter>>,
246 pub init_deps: Option<InitDeps>,
248 pub register_flags: Option<RegisterFlags>,
250 pub apply_flags: Option<ApplyFlags>,
252 pub pre_run: Option<PreRun>,
254 pub meta_resolver: Option<ResolveMeta>,
256 pub on_shutdown: Option<OnShutdown>,
258 pub extra_search_docs: Option<ExtraSearchDocs>,
260 pub root_next_actions: Option<RootNextActions>,
262 pub admin_category: Option<String>,
267 pub config_commands: bool,
272 pub argv0_routes: BTreeMap<String, Argv0Route>,
280 pub environments: Option<Arc<crate::environments::Environments>>,
287 pub startup_args: Option<Vec<std::ffi::OsString>>,
290 pub min_stage: Stage,
301 pub feature_overrides: BTreeMap<String, Stage>,
313 pub auto_interactive: bool,
324}
325
326impl CliConfig {
327 #[must_use]
329 pub fn new(
330 name: impl Into<String>,
331 short: impl Into<String>,
332 app_id: impl Into<String>,
333 ) -> Self {
334 Self {
335 name: name.into(),
336 short: short.into(),
337 app_id: app_id.into(),
338 ..Self::default()
339 }
340 }
341
342 #[must_use]
344 pub fn with_long(mut self, long: impl Into<String>) -> Self {
345 self.long = Some(long.into());
346 self
347 }
348
349 #[must_use]
351 pub fn with_build(mut self, build: BuildInfo) -> Self {
352 self.build = build;
353 self
354 }
355
356 #[must_use]
358 pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
359 self.default_auth_provider = Some(provider.into());
360 self
361 }
362
363 #[must_use]
392 pub fn with_environments(
393 mut self,
394 environments: Arc<crate::environments::Environments>,
395 ) -> Self {
396 self.environments = Some(environments);
397 self
398 }
399
400 #[must_use]
423 pub fn with_startup_args<I, S>(mut self, args: I) -> Self
424 where
425 I: IntoIterator<Item = S>,
426 S: Into<std::ffi::OsString>,
427 {
428 self.startup_args = Some(args.into_iter().map(Into::into).collect());
429 self
430 }
431
432 #[must_use]
439 pub fn with_min_stage(mut self, stage: Stage) -> Self {
440 self.min_stage = stage;
441 self
442 }
443
444 #[must_use]
451 pub fn with_auto_interactive(mut self, enabled: bool) -> Self {
452 self.auto_interactive = enabled;
453 self
454 }
455
456 #[must_use]
461 pub fn with_feature_override(mut self, key: impl Into<String>, stage: Stage) -> Self {
462 self.feature_overrides.insert(key.into(), stage);
463 self
464 }
465
466 fn flag_policy(&self) -> FlagPolicy {
469 FlagPolicy {
470 min_stage: self.min_stage,
471 overrides: self.feature_overrides.clone(),
472 }
473 }
474
475 #[must_use]
484 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
485 self.user_agent = Some(user_agent.into());
486 self
487 }
488
489 #[must_use]
499 pub fn with_redacted_debug_headers(
500 mut self,
501 names: impl IntoIterator<Item = impl Into<String>>,
502 ) -> Self {
503 self.redacted_debug_headers
504 .extend(names.into_iter().filter_map(|name| {
505 let name = name.into().trim().to_owned();
506 (!name.is_empty()).then_some(name)
507 }));
508 self
509 }
510
511 #[must_use]
518 pub fn user_agent_string(&self) -> String {
519 if let Some(user_agent) = &self.user_agent {
520 return user_agent.clone();
521 }
522 if self.build.version.is_empty() {
523 self.name.clone()
524 } else {
525 format!("{}/{}", self.name, self.build.version)
526 }
527 }
528
529 #[must_use]
538 pub fn with_module(mut self, module: Module) -> Self {
539 self.modules.push(module);
540 self
541 }
542
543 #[must_use]
547 pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
548 self.modules.extend(modules);
549 self
550 }
551
552 #[must_use]
554 pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
555 self.commands.push(command);
556 self
557 }
558
559 #[must_use]
568 pub fn with_auth_extra_commands(
569 mut self,
570 commands: impl IntoIterator<Item = RuntimeCommandSpec>,
571 ) -> Self {
572 self.auth_extra_commands.extend(commands);
573 self
574 }
575
576 #[must_use]
578 pub fn with_guide(mut self, guide: GuideEntry) -> Self {
579 self.guides.push(guide);
580 self
581 }
582
583 #[must_use]
585 pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
586 self.guides.extend(guides);
587 self
588 }
589
590 #[must_use]
592 pub fn with_view(mut self, view: HumanViewDef) -> Self {
593 self.views.push(view);
594 self
595 }
596
597 #[must_use]
599 pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
600 self.auth_providers.push(provider);
601 self
602 }
603
604 #[must_use]
606 pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
607 self.authz = Some(authz);
608 self
609 }
610
611 #[must_use]
613 pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
614 self.auditor = Some(auditor);
615 self
616 }
617
618 #[must_use]
620 pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
621 self.activity = Some(activity);
622 self
623 }
624
625 #[must_use]
627 pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
628 self.init_deps = Some(init_deps);
629 self
630 }
631
632 #[must_use]
634 pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
635 self.register_flags = Some(register_flags);
636 self
637 }
638
639 #[must_use]
641 pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
642 self.apply_flags = Some(apply_flags);
643 self
644 }
645
646 #[must_use]
648 pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
649 self.pre_run = Some(pre_run);
650 self
651 }
652
653 #[must_use]
655 pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
656 self.meta_resolver = Some(meta_resolver);
657 self
658 }
659
660 #[must_use]
662 pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
663 self.on_shutdown = Some(on_shutdown);
664 self
665 }
666
667 #[must_use]
669 pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
670 self.extra_search_docs = Some(extra_search_docs);
671 self
672 }
673
674 #[must_use]
676 pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
677 self.root_next_actions = Some(root_next_actions);
678 self
679 }
680
681 #[must_use]
685 pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
686 self.admin_category = Some(category.into());
687 self
688 }
689
690 #[must_use]
696 pub fn with_config_commands(mut self) -> Self {
697 self.config_commands = true;
698 self
699 }
700
701 #[must_use]
724 pub fn with_argv0_alias(
725 mut self,
726 name: impl Into<String>,
727 command_path: impl IntoIterator<Item = impl Into<String>>,
728 ) -> Self {
729 let name = name.into();
730 debug_assert!(
731 is_valid_argv0_name(&name),
732 "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
733 );
734 debug_assert!(
735 name != self.name,
736 "argv0 route name {name:?} must differ from the CLI's own name {:?}",
737 self.name
738 );
739 let tokens = command_path.into_iter().map(Into::into).collect();
740 self.argv0_routes.insert(name, Argv0Route::Alias(tokens));
741 self
742 }
743
744 #[must_use]
766 pub fn with_argv0_personality(
767 mut self,
768 name: impl Into<String>,
769 build: impl Fn() -> CliConfig + Send + Sync + 'static,
770 ) -> Self {
771 let name = name.into();
772 debug_assert!(
773 is_valid_argv0_name(&name),
774 "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
775 );
776 debug_assert!(
777 name != self.name,
778 "argv0 route name {name:?} must differ from the CLI's own name {:?}",
779 self.name
780 );
781 self.argv0_routes
782 .insert(name, Argv0Route::Personality(Arc::new(build)));
783 self
784 }
785}
786
787impl std::fmt::Debug for CliConfig {
788 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 formatter
790 .debug_struct("CliConfig")
791 .field("name", &self.name)
792 .field("short", &self.short)
793 .field("long", &self.long)
794 .field("build", &self.build)
795 .field("app_id", &self.app_id)
796 .field("default_auth_provider", &self.default_auth_provider)
797 .field("modules", &self.modules)
798 .field("commands", &self.commands)
799 .field("guides", &self.guides)
800 .field("views", &self.views)
801 .field("auth_providers_len", &self.auth_providers.len())
802 .field("has_authz", &self.authz.is_some())
803 .field("has_auditor", &self.auditor.is_some())
804 .field("has_activity", &self.activity.is_some())
805 .field("has_init_deps", &self.init_deps.is_some())
806 .field("has_register_flags", &self.register_flags.is_some())
807 .field("has_apply_flags", &self.apply_flags.is_some())
808 .field("has_pre_run", &self.pre_run.is_some())
809 .field("has_meta_resolver", &self.meta_resolver.is_some())
810 .field("has_on_shutdown", &self.on_shutdown.is_some())
811 .field("has_extra_search_docs", &self.extra_search_docs.is_some())
812 .field("has_root_next_actions", &self.root_next_actions.is_some())
813 .field("admin_category", &self.admin_category)
814 .field(
815 "argv0_routes",
816 &self.argv0_routes.keys().collect::<Vec<_>>(),
817 )
818 .field("min_stage", &self.min_stage)
819 .field("feature_overrides", &self.feature_overrides)
820 .finish()
821 }
822}
823
824#[derive(Clone, Debug, PartialEq)]
826pub struct CliRunOutput {
827 pub exit_code: i32,
829 pub rendered: String,
831}
832
833impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
834 fn from(o: crate::middleware::MiddlewareOutput) -> Self {
835 Self {
836 exit_code: o.exit_code,
837 rendered: o.rendered,
838 }
839 }
840}
841
842#[derive(Clone)]
848pub struct Cli {
849 config: CliConfig,
850 middleware: Middleware,
851 root: Command,
852 commands: BTreeMap<String, RuntimeCommandSpec>,
853 module_entries: Vec<ModuleHelpEntry>,
854 guide_entries: Vec<GuideEntry>,
855 init_deps: Option<InitDeps>,
856 apply_flags: Option<ApplyFlags>,
857 pre_run: Option<PreRun>,
858 meta_resolver: Option<ResolveMeta>,
859 on_shutdown: Option<OnShutdown>,
860 extra_search_docs: Option<ExtraSearchDocs>,
861 root_next_actions: Option<RootNextActions>,
862 init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
863}
864
865#[derive(Clone, Debug, Eq, PartialEq)]
866struct InitFailure {
867 message: String,
868 code: String,
869 system: String,
870 request_id: String,
871 fix: Option<String>,
872 exit_code: i32,
873}
874
875impl InitFailure {
876 fn capture(err: &CliCoreError) -> Self {
877 let envelope = crate::output::build_error_envelope(err, "");
878 let (code, system, request_id) = envelope.error.map_or_else(
879 || ("ERROR".to_owned(), String::new(), String::new()),
880 |error| (error.code, error.system, error.request_id),
881 );
882 Self {
883 message: err.to_string(),
884 code,
885 system,
886 request_id,
887 fix: envelope.fix,
888 exit_code: exit_code_for_error(err),
889 }
890 }
891
892 fn into_error(self) -> CliCoreError {
893 let message = CliCoreError::SystemMessage {
894 message: self.message,
895 system: self.system,
896 code: self.code,
897 request_id: self.request_id,
898 };
899 CliCoreError::with_exit_code(
900 self.exit_code,
901 CliCoreError::with_fix(self.fix.unwrap_or_default(), message),
902 )
903 }
904}
905
906impl std::fmt::Debug for Cli {
907 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908 formatter
909 .debug_struct("Cli")
910 .field("config", &self.config)
911 .field("middleware", &self.middleware)
912 .field("root", &self.root)
913 .field("commands", &self.commands)
914 .field("module_entries", &self.module_entries)
915 .field("guide_entries", &self.guide_entries)
916 .field("has_init_deps", &self.init_deps.is_some())
917 .field("has_apply_flags", &self.apply_flags.is_some())
918 .field("has_pre_run", &self.pre_run.is_some())
919 .field("has_meta_resolver", &self.meta_resolver.is_some())
920 .field("has_on_shutdown", &self.on_shutdown.is_some())
921 .field("has_extra_search_docs", &self.extra_search_docs.is_some())
922 .field("has_root_next_actions", &self.root_next_actions.is_some())
923 .finish()
924 }
925}
926
927impl Cli {
928 #[must_use]
930 pub fn new(config: CliConfig) -> Self {
931 let auth_providers = config.auth_providers.clone();
932 let guides = config.guides.clone();
933 let views = config.views.clone();
934 let modules = config.modules.clone();
935 let commands = config.commands.clone();
936 let init_deps = config.init_deps.clone();
937 let apply_flags = config.apply_flags.clone();
938 let pre_run = config.pre_run.clone();
939 let meta_resolver = config.meta_resolver.clone();
940 let on_shutdown = config.on_shutdown.clone();
941 let extra_search_docs = config.extra_search_docs.clone();
942 let root_next_actions = config.root_next_actions.clone();
943 let mut root = Command::new(config.name.clone())
944 .about(config.short.clone())
945 .disable_help_subcommand(true)
946 .version(config.build.version_string());
947 if let Some(long) = &config.long
948 && !long.is_empty()
949 {
950 root = root.long_about(long.clone());
951 }
952 root = register_global_flags(root)
953 .subcommand(help_command())
954 .subcommand(guide_command())
955 .subcommand(Command::new("tree").about("Display full command tree"))
956 .subcommand(completion_command())
957 .subcommand(search_command());
958 if let Some(register_flags) = &config.register_flags {
959 root = register_flags(root);
960 }
961 if config.authz.is_some() || config.auditor.is_some() || config.activity.is_some() {
970 root = register_reason_flag(root);
971 }
972 if config.environments.is_some() {
973 root = root.arg(
974 Arg::new("env")
975 .long("env")
976 .global(true)
977 .value_name("ENV")
978 .display_order(crate::flags::global_flag_order::ENV)
979 .help("Override the active environment (see: env list)"),
980 );
981 }
982 let intro = config
983 .long
984 .as_deref()
985 .filter(|long| !long.is_empty())
986 .unwrap_or(config.short.as_str());
987 root = root
988 .long_about(build_root_long(intro, &[], false))
989 .help_template(ROOT_HELP_TEMPLATE);
990
991 let mut middleware = Middleware::new();
992 middleware.app_id = config.app_id.clone();
993 crate::fs::migrate_macos_config_dir(&config.app_id);
997 middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id));
1000 middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
1001 middleware.authz = config.authz.clone();
1002 middleware.auditor = config.auditor.clone();
1003 middleware.activity = config.activity.clone();
1004 middleware
1005 .schema_registry
1006 .merge(&global_schema_registry_snapshot());
1007 middleware
1008 .human_views
1009 .merge(&global_human_view_registry_snapshot());
1010 if let Some(environments) = &config.environments {
1011 let startup_args = config
1023 .startup_args
1024 .clone()
1025 .unwrap_or_else(|| std::env::args_os().collect());
1026 let startup_env_flag = prescan_env_flag(
1027 startup_args
1028 .iter()
1029 .skip(1) .map(|arg| arg.to_string_lossy().into_owned()),
1031 );
1032 middleware.env =
1036 environments.effective_active(startup_env_flag.as_deref(), &middleware.config);
1037 middleware.environments = Some(Arc::clone(environments));
1038 }
1039 let mut flag_policy = config.flag_policy();
1040 if let Some(min_stage) = global_min_stage_override(&config.app_id) {
1041 flag_policy.min_stage = min_stage;
1042 }
1043 if let Some(environments) = &middleware.environments
1044 && let Ok(source) = environments.source(&middleware.env)
1045 {
1046 let chain = crate::env_config::SourceChain::new().push(&source);
1047 match crate::env_config::resolve_field::<Stage>(
1048 &chain,
1049 "min_stage",
1050 "min_stage",
1051 None,
1052 false,
1053 crate::env_config::default_from_toml::<Stage>,
1054 |_raw: &str| -> std::result::Result<Stage, String> { Err(String::new()) },
1055 ) {
1056 Ok(Some(min_stage)) => flag_policy.min_stage = min_stage,
1057 Ok(None) => {}
1058 Err(err) => {
1059 tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment min_stage");
1060 }
1061 }
1062 match crate::env_config::resolve_field::<BTreeMap<String, Stage>>(
1063 &chain,
1064 "feature_overrides",
1065 "feature_overrides",
1066 None,
1067 false,
1068 crate::env_config::default_from_toml::<BTreeMap<String, Stage>>,
1069 |_raw: &str| -> std::result::Result<BTreeMap<String, Stage>, String> {
1070 Err(String::new())
1071 },
1072 ) {
1073 Ok(Some(overrides)) => flag_policy.overrides.extend(overrides),
1074 Ok(None) => {}
1075 Err(err) => {
1076 tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment feature_overrides");
1077 }
1078 }
1079 }
1080 middleware.flag_policy = flag_policy;
1081
1082 let mut cli = Self {
1083 config,
1084 middleware,
1085 root,
1086 commands: BTreeMap::new(),
1087 module_entries: Vec::new(),
1088 guide_entries: Vec::new(),
1089 init_deps,
1090 apply_flags,
1091 pre_run,
1092 meta_resolver,
1093 on_shutdown,
1094 extra_search_docs,
1095 root_next_actions,
1096 init_state: Arc::new(Mutex::new(None)),
1097 };
1098 for provider in auth_providers {
1099 cli.register_auth_provider(provider);
1100 }
1101 if cli.middleware.default_auth_provider.is_empty()
1102 && let Some(provider) = cli.middleware.auth.registered_names().first()
1103 {
1104 cli.middleware.default_auth_provider = provider.clone();
1105 }
1106 if !cli.middleware.default_auth_provider.is_empty() {
1107 cli.ensure_auth_command();
1108 }
1109 for view in views {
1110 cli.middleware.human_views.register(view);
1111 }
1112 cli.add_guides(guides);
1113 for module in modules {
1114 cli.add_module(module);
1115 }
1116 for command in commands {
1117 cli.add_command(command);
1118 }
1119 if cli.config.config_commands {
1120 cli.ensure_config_command();
1121 }
1122 if cli.config.environments.is_some() {
1123 cli.ensure_env_command();
1124 }
1125 cli.ensure_flags_command();
1126 cli
1127 }
1128
1129 fn register_auth_help_entry(&mut self) {
1134 let category = self
1135 .config
1136 .admin_category
1137 .clone()
1138 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
1139 let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
1140 let short = self
1141 .root
1142 .find_subcommand("auth")
1143 .filter(|auth| !auth.is_hide_set())
1144 .map(|auth| {
1145 auth.get_about()
1146 .map(ToString::to_string)
1147 .unwrap_or_default()
1148 });
1149 if !already_listed && let Some(short) = short {
1150 self.module_entries.push(ModuleHelpEntry {
1151 category,
1152 name: "auth".to_owned(),
1153 short,
1154 });
1155 }
1156 self.refresh_root_long();
1157 }
1158
1159 #[must_use]
1161 pub fn middleware(&self) -> &Middleware {
1162 &self.middleware
1163 }
1164
1165 pub fn middleware_mut(&mut self) -> &mut Middleware {
1167 &mut self.middleware
1168 }
1169
1170 pub async fn execute(&self) -> ExitCode {
1172 let mut stdout = std::io::stdout().lock();
1173 let mut stderr = std::io::stderr().lock();
1174 match self
1175 .execute_from(std::env::args_os(), &mut stdout, &mut stderr)
1176 .await
1177 {
1178 Ok(code) => code,
1179 Err(err) => {
1180 drop(writeln!(stderr, "{err}"));
1181 ExitCode::from(1)
1182 }
1183 }
1184 }
1185
1186 pub async fn execute_from<I, S, O, E>(
1193 &self,
1194 args: I,
1195 stdout: &mut O,
1196 stderr: &mut E,
1197 ) -> std::io::Result<ExitCode>
1198 where
1199 I: IntoIterator<Item = S>,
1200 S: Into<std::ffi::OsString> + Clone,
1201 O: Write,
1202 E: Write,
1203 {
1204 self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
1205 .await
1206 }
1207
1208 pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
1210 &self,
1211 args: I,
1212 stdout: &mut O,
1213 stderr: &mut E,
1214 shutdown: Shutdown,
1215 ) -> std::io::Result<ExitCode>
1216 where
1217 I: IntoIterator<Item = S>,
1218 S: Into<std::ffi::OsString> + Clone,
1219 O: Write,
1220 E: Write,
1221 Shutdown: Future<Output = ()>,
1222 {
1223 self.install_default_user_agent();
1224 let output = run_until_signal(self.run(args), shutdown).await;
1225 if output.exit_code == 130
1226 && output.rendered == "command interrupted\n"
1227 && let Some(on_shutdown) = &self.on_shutdown
1228 {
1229 on_shutdown();
1230 }
1231 if output.exit_code == 0 {
1232 stdout.write_all(output.rendered.as_bytes())?;
1233 } else {
1234 stderr.write_all(output.rendered.as_bytes())?;
1235 }
1236 Ok(process_exit_code(output.exit_code))
1237 }
1238
1239 fn install_default_user_agent(&self) {
1247 crate::transport::set_default_user_agent(self.config.user_agent_string());
1248 }
1249
1250 pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
1252 self.middleware.auth.register(provider);
1253 self.ensure_auth_command();
1254 self.refresh_root_long();
1255 self
1256 }
1257
1258 #[must_use]
1260 pub fn root_command(&self) -> &Command {
1261 &self.root
1262 }
1263
1264 pub fn add_module_group(
1266 &mut self,
1267 category: impl Into<String>,
1268 group: RuntimeGroupSpec,
1269 ) -> &mut Self {
1270 self.add_module_group_inner(category, group, None)
1271 }
1272
1273 fn add_module_group_inner(
1279 &mut self,
1280 category: impl Into<String>,
1281 group: RuntimeGroupSpec,
1282 inherited: Option<FeatureFlag>,
1283 ) -> &mut Self {
1284 if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
1288 tracing::warn!(
1289 name = %group.group.name,
1290 "module group name is reserved by cli-engine built-ins; the group will not be registered"
1291 );
1292 return self;
1293 }
1294
1295 let mut prefix = Vec::new();
1296 let Some(group) = prune_feature_flag_tree(
1297 group,
1298 inherited.as_ref(),
1299 &self.middleware.flag_policy,
1300 &mut prefix,
1301 &mut self.middleware.flag_registry,
1302 ) else {
1303 return self;
1304 };
1305
1306 let category = category.into();
1307 if !group.group.hidden {
1308 self.module_entries.push(ModuleHelpEntry {
1309 category,
1310 name: group.group.name.clone(),
1311 short: group.group.short.clone(),
1312 });
1313 }
1314
1315 let mut prefix = Vec::new();
1316 register_runtime_group_metadata(
1317 &group,
1318 &mut prefix,
1319 &mut self.middleware.schema_registry,
1320 &mut self.middleware.human_views,
1321 );
1322 let mut prefix = Vec::new();
1323 group.register_commands(&mut prefix, &mut self.commands);
1324 let mut prefix = Vec::new();
1325 let clap_group = runtime_group_clap_command_with_schema_help(
1326 &group,
1327 &mut prefix,
1328 &self.middleware.schema_registry,
1329 );
1330 self.root = self.root.clone().subcommand(clap_group);
1331 self.refresh_root_long();
1332 self
1333 }
1334
1335 pub fn add_module(&mut self, module: Module) -> &mut Self {
1337 for view in module.views.clone() {
1338 self.middleware.human_views.register(view);
1339 }
1340 self.add_guides(module.guides.clone());
1341 let mut context = ModuleContext::new(&mut self.middleware);
1342 let group = (module.register)(&mut context);
1343 let (guides, views) = context.into_parts();
1344 for view in views {
1345 self.middleware.human_views.register(view);
1346 }
1347 self.add_guides(guides);
1348 self.add_module_group_inner(module.category, group, module.feature_flag.clone())
1349 }
1350
1351 pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
1353 let name = command.spec.name.clone();
1354 register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
1355 self.commands.insert(name, command.clone());
1356 self.root = self
1357 .root
1358 .clone()
1359 .subcommand(command_clap_command_with_schema_help(
1360 &command.spec,
1361 &command.spec.name,
1362 &self.middleware.schema_registry,
1363 ));
1364 self
1365 }
1366
1367 pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
1369 if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1370 self.root = self.root.clone().subcommand(guide_command());
1371 }
1372 self.sync_guide_topic_values();
1373 self.refresh_root_long();
1374 self
1375 }
1376
1377 pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
1379 let mut seen = self
1380 .guide_entries
1381 .iter()
1382 .map(|entry| entry.name.clone())
1383 .collect::<BTreeSet<_>>();
1384 for entry in entries {
1385 if seen.insert(entry.name.clone()) {
1386 self.guide_entries.push(entry);
1387 }
1388 }
1389 if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1390 self.root = self.root.clone().subcommand(guide_command());
1391 }
1392 self.sync_guide_topic_values();
1393 self.refresh_root_long();
1394 self
1395 }
1396
1397 fn sync_guide_topic_values(&mut self) {
1401 if self.guide_entries.is_empty() {
1402 return;
1403 }
1404 let names = self
1405 .guide_entries
1406 .iter()
1407 .map(|entry| entry.name.clone())
1408 .collect::<Vec<_>>();
1409 if let Some(guide_cmd) = self.root.find_subcommand_mut("guide") {
1410 let taken = std::mem::replace(guide_cmd, Command::new("guide"));
1411 *guide_cmd = taken.mut_arg("topic", |arg| {
1412 arg.value_parser(PossibleValuesParser::new(names))
1413 });
1414 }
1415 }
1416
1417 async fn resolve_argv0(&self, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
1426 if self.config.argv0_routes.is_empty() {
1427 return Argv0Outcome::Proceed(text_args);
1428 }
1429
1430 if depth > MAX_ARGV0_DEPTH {
1431 return Argv0Outcome::Handled(
1432 self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"),
1433 );
1434 }
1435
1436 let explicit = text_args.get(1).map(String::as_str) == Some("argv0");
1441 let (name, rest) = if explicit {
1442 match text_args.get(2) {
1443 None => {
1444 return Argv0Outcome::Handled(self.render_argv0_error(
1445 &text_args,
1446 "the argv0 command requires a name to dispatch as",
1447 ));
1448 }
1449 Some(name) => (
1453 program_basename(name),
1454 text_args
1455 .get(3..)
1456 .map(<[String]>::to_vec)
1457 .unwrap_or_default(),
1458 ),
1459 }
1460 } else {
1461 let name = text_args
1462 .first()
1463 .map(|arg| program_basename(arg))
1464 .unwrap_or_default();
1465 let rest = text_args
1466 .get(1..)
1467 .map(<[String]>::to_vec)
1468 .unwrap_or_default();
1469 (name, rest)
1470 };
1471
1472 match self.config.argv0_routes.get(&name) {
1473 Some(Argv0Route::Alias(tokens)) => {
1474 let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len());
1477 rewritten.push(self.config.name.clone());
1478 rewritten.extend(tokens.iter().cloned());
1479 rewritten.extend(rest);
1480 Argv0Outcome::Proceed(rewritten)
1481 }
1482 Some(Argv0Route::Personality(build)) => {
1483 let config = build();
1488 let bin = config.name.clone();
1489 let alt = Self::new(config);
1490 let mut alt_args = Vec::with_capacity(1 + rest.len());
1491 alt_args.push(bin);
1492 alt_args.extend(rest);
1493 Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await)
1494 }
1495 None if explicit => Argv0Outcome::Handled(self.render_argv0_error(
1496 &text_args,
1497 format!(
1498 "{name:?} is not a registered argv0 name; known names: {}",
1499 self.known_argv0_names()
1500 ),
1501 )),
1502 None => {
1503 let mut rewritten = Vec::with_capacity(1 + rest.len());
1508 rewritten.push(self.config.name.clone());
1509 rewritten.extend(rest);
1510 Argv0Outcome::Proceed(rewritten)
1511 }
1512 }
1513 }
1514
1515 fn resolve_run_output_format(&self) -> String {
1518 use std::io::IsTerminal;
1519
1520 let env = std::env::var(output_env_var(&self.config.app_id)).ok();
1521 let engine_config = self.middleware.config.engine();
1522 resolve_default_output_format(
1523 env.as_deref(),
1524 engine_config.output.format.as_deref(),
1525 std::io::stdout().is_terminal(),
1526 )
1527 }
1528
1529 fn known_argv0_names(&self) -> String {
1532 self.config
1533 .argv0_routes
1534 .keys()
1535 .cloned()
1536 .collect::<Vec<_>>()
1537 .join(", ")
1538 }
1539
1540 fn render_argv0_error(&self, text_args: &[String], message: impl Into<String>) -> CliRunOutput {
1545 let mut middleware = self.middleware.clone();
1546 middleware.output_format =
1547 extract_output_format(text_args, &self.resolve_run_output_format());
1548 let err = CliCoreError::message(message);
1549 self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id))
1550 }
1551
1552 #[must_use]
1557 pub fn argv0_names(&self) -> Vec<&str> {
1558 self.config
1559 .argv0_routes
1560 .keys()
1561 .map(String::as_str)
1562 .collect()
1563 }
1564
1565 pub fn create_link(
1590 &self,
1591 name: &str,
1592 dir: impl AsRef<Path>,
1593 target: Option<&Path>,
1594 method: Argv0LinkMethod,
1595 ) -> std::io::Result<PathBuf> {
1596 if !self.config.argv0_routes.contains_key(name) {
1597 return Err(std::io::Error::new(
1598 std::io::ErrorKind::InvalidInput,
1599 format!("{name:?} is not a registered argv0 name"),
1600 ));
1601 }
1602
1603 let dir = dir.as_ref();
1604 std::fs::create_dir_all(dir)?;
1605 let link = dir.join(argv0_link_file_name(name, method));
1606
1607 let resolved_target;
1609 let target = match target {
1610 Some(target) => target,
1611 None => {
1612 resolved_target = std::env::current_exe()?;
1613 resolved_target.as_path()
1614 }
1615 };
1616
1617 if std::fs::symlink_metadata(&link).is_ok() {
1621 if argv0_link_matches(&link, target, name, method)? {
1622 return Ok(link);
1623 }
1624 std::fs::remove_file(&link)?;
1625 }
1626
1627 match method {
1628 Argv0LinkMethod::SoftLink => create_symlink(target, &link)?,
1629 Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?,
1630 Argv0LinkMethod::Script => {
1631 std::fs::write(&link, argv0_script_contents(target, name))?;
1632 make_executable(&link)?;
1633 }
1634 }
1635 Ok(link)
1636 }
1637
1638 pub async fn run<I, S>(&self, args: I) -> CliRunOutput
1643 where
1644 I: IntoIterator<Item = S>,
1645 S: Into<std::ffi::OsString> + Clone,
1646 {
1647 self.run_with_depth(args, 0).await
1648 }
1649
1650 async fn run_with_depth<I, S>(&self, args: I, depth: usize) -> CliRunOutput
1653 where
1654 I: IntoIterator<Item = S>,
1655 S: Into<std::ffi::OsString> + Clone,
1656 {
1657 let raw_args = args
1658 .into_iter()
1659 .map(Into::into)
1660 .collect::<Vec<std::ffi::OsString>>();
1661 let text_args = raw_args
1662 .iter()
1663 .map(|arg| arg.to_string_lossy().into_owned())
1664 .collect::<Vec<_>>();
1665 let text_args = match self.resolve_argv0(text_args, depth).await {
1666 Argv0Outcome::Handled(output) => return output,
1667 Argv0Outcome::Proceed(args) => args,
1668 };
1669 let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
1670 if has_root_version_flag(&text_args, &self.root, &self.config.name) {
1671 return self.finish_run(CliRunOutput {
1672 exit_code: 0,
1673 rendered: format!(
1674 "{} version {}\n",
1675 self.config.name,
1676 self.config.build.version_string()
1677 ),
1678 });
1679 }
1680 if let Some(output) = self.try_run_schema_bypass(&text_args) {
1681 return output;
1682 }
1683 let bool_flags = derive_bool_flags(&self.root);
1686 let value_flags = derive_value_flags(&self.root);
1687 let positionals =
1688 positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
1689 let command_keyword_count =
1690 command_keyword_count(&text_args, &self.config.name, &bool_flags, &value_flags);
1691 if let Some(parts) =
1692 group_help_target_parts(&self.root, &positionals, command_keyword_count)
1693 {
1694 clap_args = rewrite_group_help_args(
1701 &clap_args,
1702 &self.config.name,
1703 &bool_flags,
1704 &value_flags,
1705 &parts,
1706 );
1707 } else if let Some(unknown) =
1708 detect_unknown_group_command(&self.root, &positionals[..command_keyword_count])
1709 {
1710 if let Some(corrections) =
1712 full_command_correction(&self.root, &positionals[..command_keyword_count])
1713 {
1714 let display = correction_display(
1715 &self.config.name,
1716 &positionals[..command_keyword_count],
1717 &corrections,
1718 );
1719 let full_fix_message = format_did_you_mean(&unknown.base, &display);
1720 match crate::prompt::confirm_command_correction(
1721 &clap_args,
1722 &display,
1723 self.config.auto_interactive,
1724 ) {
1725 crate::prompt::CommandCorrection::Accepted => {
1726 for (index, replacement) in &corrections {
1727 clap_args = replace_positional_command_token(
1728 &clap_args,
1729 &self.config.name,
1730 &bool_flags,
1731 &value_flags,
1732 *index,
1733 replacement,
1734 );
1735 }
1736 clap_args = rewrite_group_help_if_needed(
1737 &self.root,
1738 &clap_args,
1739 &self.config.name,
1740 &bool_flags,
1741 &value_flags,
1742 );
1743 }
1744 crate::prompt::CommandCorrection::Declined => {
1745 return self.finish_run(CliRunOutput {
1746 exit_code: 1,
1747 rendered: full_fix_message,
1748 });
1749 }
1750 crate::prompt::CommandCorrection::Cancelled => {
1751 return self.finish_run(CliRunOutput {
1752 exit_code: 130,
1753 rendered: "Cancelled.".to_owned(),
1754 });
1755 }
1756 }
1757 } else {
1758 return self.finish_run(CliRunOutput {
1759 exit_code: 1,
1760 rendered: unknown.base,
1761 });
1762 }
1763 }
1764
1765 let matches = match self.root.clone().try_get_matches_from(&clap_args) {
1766 Ok(matches) => matches,
1767 Err(err) => {
1768 if let Some(recovery) = crate::prompt::try_recover_missing_args(
1770 &err,
1771 &clap_args,
1772 &self.root,
1773 &self.config.name,
1774 self.config.auto_interactive,
1775 ) {
1776 match recovery {
1777 crate::prompt::RecoveryResult::Recovered { args } => {
1778 match self.root.clone().try_get_matches_from(args) {
1779 Ok(m) => m,
1780 Err(retry_err) => {
1781 return self.finish_run(CliRunOutput {
1782 exit_code: retry_err.exit_code(),
1783 rendered: retry_err.to_string(),
1784 });
1785 }
1786 }
1787 }
1788 crate::prompt::RecoveryResult::Cancelled { resume } => {
1789 return self.finish_run(CliRunOutput {
1790 exit_code: 130,
1791 rendered: format!("Cancelled. Resume with:\n {resume}\n"),
1792 });
1793 }
1794 }
1795 } else {
1796 return self.finish_run(CliRunOutput {
1797 exit_code: err.exit_code(),
1798 rendered: err.to_string(),
1799 });
1800 }
1801 }
1802 };
1803
1804 let default_format = self.resolve_run_output_format();
1805 let flags =
1806 global_flags_from_matches(&matches, &default_format, self.config.auto_interactive);
1807 crate::config::set_credential_store_flag(flags.credential_store);
1810 let command_timeout = match parse_command_timeout(&flags.timeout) {
1811 Ok(timeout) => timeout,
1812 Err(err) => {
1813 return self.finish_run(render_cli_error(
1814 &self.middleware,
1815 &err,
1816 &self.config.app_id,
1817 ));
1818 }
1819 };
1820 let mut middleware = self.middleware.clone();
1821 apply_global_flags(&mut middleware, &flags, command_timeout);
1822 install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1823 if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1824 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1825 }
1826 if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1829 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1830 }
1831
1832 let command_path = command_path_from_matches(&self.config.name, &matches);
1833 if command_path == "help" {
1834 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
1835 {
1836 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1837 }
1838 return self.finish_run(self.render_help_command(&matches));
1839 }
1840 if command_path == "tree" {
1841 if let Err(err) = self.run_pre_run(
1842 &mut middleware,
1843 &command_path,
1844 &crate::middleware::ValueMap::new(),
1845 ) {
1846 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1847 }
1848 return self.finish_run(tree_render::render_tree(
1849 &self.root,
1850 &self.config.app_id,
1851 &middleware,
1852 ));
1853 }
1854 if command_path == "guide" {
1855 if let Err(err) =
1856 self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
1857 {
1858 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1859 }
1860 return self.finish_run(self.render_guide(&matches, &flags.output_format));
1861 }
1862 if command_path == "search" {
1863 let args = search_args(&matches);
1864 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1865 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1866 }
1867 let query = args
1868 .get("query")
1869 .and_then(|v| v.as_str())
1870 .unwrap_or_default();
1871 let scope_path = args
1872 .get("scope")
1873 .and_then(|v| v.as_str())
1874 .unwrap_or_default();
1875 let scope = self.resolve_search_scope(scope_path);
1876 return self.finish_run(self.render_search(query, &scope, &flags.output_format));
1877 }
1878 if command_path == "completion" {
1879 let args = completion_args(&matches);
1880 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1881 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1882 }
1883 let install = args
1884 .get("install")
1885 .and_then(|v| v.as_bool())
1886 .unwrap_or(false);
1887 let shell_opt = args
1888 .get("shell")
1889 .and_then(|v| v.as_str())
1890 .map(str::to_owned);
1891 if install {
1892 use crate::cli::completion::{detect_shell, parse_shell};
1893 let shell = match shell_opt {
1894 Some(ref s) => match parse_shell(s) {
1895 Ok(s) => s,
1896 Err(e) => {
1897 return self.finish_run(render_cli_error(
1898 &middleware,
1899 &e,
1900 &self.config.app_id,
1901 ));
1902 }
1903 },
1904 None => match detect_shell() {
1905 Ok(s) => s,
1906 Err(e) => {
1907 return self.finish_run(render_cli_error(
1908 &middleware,
1909 &e,
1910 &self.config.app_id,
1911 ));
1912 }
1913 },
1914 };
1915 return self.finish_run(
1916 completion::install(&self.root, &self.config.name, shell)
1917 .await
1918 .unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
1919 );
1920 }
1921 return self.finish_run(self.render_completion_print(shell_opt, &middleware));
1922 }
1923 let Some(command) = self.commands.get(&command_path) else {
1924 if !command_path.is_empty()
1925 && let Some(group) = find_command_by_colon_path(&self.root, &command_path)
1926 && group.get_subcommands().next().is_some()
1927 {
1928 if let Err(err) = self.run_pre_run(
1929 &mut middleware,
1930 &command_path,
1931 &crate::middleware::ValueMap::new(),
1932 ) {
1933 return self.finish_run(render_cli_error(
1934 &middleware,
1935 &err,
1936 &self.config.app_id,
1937 ));
1938 }
1939 if middleware.interactive
1940 && let Some(subcommand) = single_leaf_subcommand(group)
1941 {
1942 let augmented = inject_subcommand_after_command_path(
1943 &text_args,
1944 &self.config.name,
1945 &command_path,
1946 &subcommand,
1947 &bool_flags,
1948 &value_flags,
1949 );
1950 return Box::pin(self.run_with_depth(augmented, depth + 1)).await;
1951 }
1952 return self.finish_run(self.render_bare_group_discovery(
1953 group,
1954 &command_path,
1955 &middleware,
1956 ));
1957 }
1958 if command_path.is_empty()
1959 && let Some(root_next_actions) = &self.root_next_actions
1960 {
1961 let actions = root_next_actions();
1966 return self.finish_run(self.render_root(&middleware, actions));
1967 }
1968 return self.finish_run(CliRunOutput {
1969 exit_code: if command_path.is_empty() { 0 } else { 1 },
1970 rendered: if command_path.is_empty() {
1971 self.root.clone().render_long_help().to_string()
1972 } else {
1973 format!("unknown command {command_path:?}")
1974 },
1975 });
1976 };
1977
1978 let mut middleware = match self.initialized_middleware() {
1979 Ok(middleware) => middleware,
1980 Err(err) => {
1981 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1982 }
1983 };
1984 apply_global_flags(&mut middleware, &flags, command_timeout);
1985 install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1986 if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1987 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1988 }
1989 if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1992 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1993 }
1994
1995 let leaf = leaf_matches(&matches);
1996 apply_pagination_flags(&mut middleware, &command.spec, leaf);
1997 let args = command_args_from_matches(leaf, &command.spec, false);
1998 let user_args = command_args_from_matches(leaf, &command.spec, true);
1999 let pagination_command = command.spec.pagination.is_some().then(|| {
2000 pagination_command_base(
2001 &self.config.name,
2002 &command_path,
2003 &command.spec,
2004 &user_args,
2005 &flags,
2006 )
2007 });
2008 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
2009 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
2010 }
2011 let meta = self.resolve_meta(&command_path, command.spec.metadata());
2012 let default_fields = command.spec.default_fields.clone().unwrap_or_default();
2013 let system = command.spec.system.clone().unwrap_or_default();
2014 let view_id = command
2019 .spec
2020 .view_id
2021 .clone()
2022 .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
2023
2024 if let Some(streaming_handler) = command.streaming_handler.clone() {
2025 let result = run_with_timeout(
2026 command_timeout,
2027 &flags.timeout,
2028 run_streaming_command(
2029 &middleware,
2030 MiddlewareRequest {
2031 meta,
2032 command_path: &command_path,
2033 system: &system,
2034 user_args,
2035 args,
2036 default_fields: &default_fields,
2037 view_id: view_id.as_deref(),
2038 auth: command.spec.auth,
2039 raw_output: command.spec.raw_output,
2040 pagination_command,
2041 },
2042 Arc::new(leaf.clone()),
2043 streaming_handler,
2044 ),
2045 )
2046 .await;
2047 return self.finish_run(match result {
2048 Ok(output) => output,
2049 Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
2050 });
2051 }
2052
2053 let handler = command.handler.clone();
2054 let args_for_handler = args.clone();
2055 let user_args_for_handler = user_args.clone();
2056 let handler_path = command_path.clone();
2057 let middleware_for_handler = middleware.clone();
2058 let raw_matches_for_handler = Arc::new(leaf.clone());
2059 let result = run_with_timeout(
2060 command_timeout,
2061 &flags.timeout,
2062 middleware.run(
2063 MiddlewareRequest {
2064 meta,
2065 command_path: &command_path,
2066 system: &system,
2067 user_args,
2068 args,
2069 default_fields: &default_fields,
2070 view_id: view_id.as_deref(),
2071 auth: command.spec.auth,
2072 raw_output: command.spec.raw_output,
2073 pagination_command,
2074 },
2075 async move |credential| {
2076 handler(CommandContext {
2077 credential,
2078 args: args_for_handler,
2079 user_args: user_args_for_handler,
2080 command_path: handler_path,
2081 middleware: middleware_for_handler,
2082 raw_matches: raw_matches_for_handler,
2083 })
2084 .await
2085 },
2086 ),
2087 )
2088 .await;
2089
2090 match result {
2091 Ok(output) => self.finish_run(output.into()),
2092 Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
2093 }
2094 }
2095
2096 fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
2097 if !has_true_schema_flag(args) {
2098 return None;
2099 }
2100 let bool_flags = derive_bool_flags(&self.root);
2101 let value_flags = derive_value_flags(&self.root);
2102 let command_path =
2103 self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
2104 let command = find_command_by_colon_path(&self.root, &command_path)?;
2109 if command.get_subcommands().next().is_some() {
2110 return None;
2111 }
2112 let output_format = extract_output_format(args, &self.resolve_run_output_format());
2113 match self.middleware.schema_registry.get_by_path(&command_path) {
2117 Some(schema) => Some(self.render_schema(schema, &output_format)),
2118 None => Some(self.render_schema(
2119 crate::output::no_schema_response(&command_path),
2120 &output_format,
2121 )),
2122 }
2123 }
2124
2125 fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
2126 let format: crate::output::OutputFormat = match output_format.parse() {
2127 Ok(format) => format,
2128 Err(err) => {
2129 return CliRunOutput {
2130 exit_code: exit_code_for_error(&err),
2131 rendered: err.to_string(),
2132 };
2133 }
2134 };
2135 let envelope =
2136 crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
2137 match crate::output::render(format, &envelope) {
2138 Ok(rendered) => CliRunOutput {
2139 exit_code: 0,
2140 rendered,
2141 },
2142 Err(err) => CliRunOutput {
2143 exit_code: exit_code_for_error(&err),
2144 rendered: err.to_string(),
2145 },
2146 }
2147 }
2148
2149 fn render_bare_group_discovery(
2157 &self,
2158 group: &Command,
2159 command_path: &str,
2160 middleware: &Middleware,
2161 ) -> CliRunOutput {
2162 let format: crate::output::OutputFormat = match middleware.output_format.parse() {
2163 Ok(format) => format,
2164 Err(err) => {
2165 return CliRunOutput {
2166 exit_code: exit_code_for_error(&err),
2167 rendered: err.to_string(),
2168 };
2169 }
2170 };
2171 if format == crate::output::OutputFormat::Human {
2172 return CliRunOutput {
2173 exit_code: 0,
2174 rendered: group.clone().render_long_help().to_string(),
2175 };
2176 }
2177 let path = format!("{} {}", self.config.name, command_path.replace(':', " "));
2178 let tree = crate::tree::build_tree_from_clap_with_path(group, path);
2179 tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format)
2180 }
2181
2182 fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
2183 let format: crate::output::OutputFormat = match output_format.parse() {
2184 Ok(format) => format,
2185 Err(err) => {
2186 return CliRunOutput {
2187 exit_code: exit_code_for_error(&err),
2188 rendered: err.to_string(),
2189 };
2190 }
2191 };
2192 let docs = self.search_documents(scope);
2193 let results = SearchIndex::new(docs).search(query, 10);
2194 let envelope =
2195 crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
2196 match crate::output::render(format, &envelope) {
2197 Ok(rendered) => CliRunOutput {
2198 exit_code: 0,
2199 rendered,
2200 },
2201 Err(err) => CliRunOutput {
2202 exit_code: exit_code_for_error(&err),
2203 rendered: err.to_string(),
2204 },
2205 }
2206 }
2207
2208 fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
2214 if !crate::output::is_valid_output_format(&middleware.output_format) {
2219 let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
2220 return CliRunOutput {
2221 exit_code: exit_code_for_error(&err),
2222 rendered: err.to_string(),
2223 };
2224 }
2225 let format = middleware
2226 .output_format
2227 .parse()
2228 .unwrap_or(crate::output::OutputFormat::Json);
2229 if format == crate::output::OutputFormat::Human {
2230 let base_long = self
2234 .root
2235 .get_long_about()
2236 .map(ToString::to_string)
2237 .unwrap_or_default();
2238 let long = format!("{base_long}{}", render_next_actions_human(&actions));
2239 let rendered = self
2240 .root
2241 .clone()
2242 .long_about(long)
2243 .render_long_help()
2244 .to_string();
2245 return CliRunOutput {
2246 exit_code: 0,
2247 rendered,
2248 };
2249 }
2250 let description = self
2251 .config
2252 .long
2253 .as_deref()
2254 .filter(|long| !long.is_empty())
2255 .unwrap_or(self.config.short.as_str());
2256 let data = serde_json::json!({
2257 "description": description,
2258 "version": self.config.build.version,
2259 });
2260 let envelope = crate::Envelope::success(data, self.config.app_id.clone())
2261 .with_next_actions(actions)
2262 .prepare_for_render(&middleware.verbose);
2263 match crate::output::render(format, &envelope) {
2264 Ok(rendered) => CliRunOutput {
2265 exit_code: 0,
2266 rendered,
2267 },
2268 Err(err) => CliRunOutput {
2269 exit_code: exit_code_for_error(&err),
2270 rendered: err.to_string(),
2271 },
2272 }
2273 }
2274
2275 fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
2276 let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
2277 .unwrap_or((&self.root, Vec::new()));
2278 let mut docs = Vec::new();
2279 let mut aliases = Vec::new();
2280 append_command_alias_terms(scoped, &mut aliases);
2281 collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
2282 if scope.is_empty() {
2283 for entry in &self.guide_entries {
2284 docs.push(SearchDocument {
2285 id: format!("guide:{}", entry.name),
2286 kind: "guide".to_owned(),
2287 title: format!("guide {}", entry.name),
2288 summary: entry.summary.clone(),
2289 content: format!("{} {}", entry.summary, entry.content),
2290 });
2291 }
2292 if let Some(extra_search_docs) = &self.extra_search_docs {
2293 docs.extend(extra_search_docs());
2294 }
2295 }
2296 docs
2297 }
2298
2299 fn resolve_search_scope(&self, scope_path: &str) -> String {
2310 if scope_path.is_empty() {
2311 return String::new();
2312 }
2313 let parts: Vec<String> = scope_path.split(':').map(str::to_owned).collect();
2314 match canonical_path_from_parts(&self.root, &parts) {
2315 Some(scope) => scope,
2316 None => {
2317 warn_unresolvable_search_scope(scope_path);
2318 String::new()
2319 }
2320 }
2321 }
2322
2323 fn canonical_command_path(&self, command_path: &str) -> String {
2324 find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
2325 || command_path.to_owned(),
2326 |(_, canonical)| canonical.join(":"),
2327 )
2328 }
2329
2330 fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput {
2331 use std::io::IsTerminal;
2332
2333 if !crate::output::is_valid_output_format(output_format) {
2337 let err = CliCoreError::InvalidOutputFormat(output_format.to_owned());
2338 return CliRunOutput {
2339 exit_code: exit_code_for_error(&err),
2340 rendered: err.to_string(),
2341 };
2342 }
2343
2344 let leaf = leaf_matches(matches);
2345 let topic = leaf.get_one::<String>("topic").map(String::as_str);
2346 match guide_content(&self.guide_entries, topic) {
2347 Ok(rendered) => {
2348 let rendered = if topic.is_some() && output_format == "human" {
2352 let is_tty = std::io::stdout().is_terminal();
2353 render_guide_human(&rendered, crate::output::terminal_width(), is_tty)
2354 } else {
2355 rendered
2356 };
2357 CliRunOutput {
2358 exit_code: 0,
2359 rendered,
2360 }
2361 }
2362 Err(err) => CliRunOutput {
2363 exit_code: 1,
2364 rendered: err,
2365 },
2366 }
2367 }
2368
2369 fn render_completion_print(
2370 &self,
2371 shell_opt: Option<String>,
2372 middleware: &Middleware,
2373 ) -> CliRunOutput {
2374 use crate::cli::completion::{detect_shell, generate_script, parse_shell};
2375 let shell = match shell_opt {
2376 Some(s) => match parse_shell(&s) {
2377 Ok(s) => s,
2378 Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2379 },
2380 None => match detect_shell() {
2381 Ok(s) => s,
2382 Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2383 },
2384 };
2385 match generate_script(&self.root, &self.config.name, shell) {
2386 Ok(script) => CliRunOutput {
2387 exit_code: 0,
2388 rendered: script,
2389 },
2390 Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
2391 }
2392 }
2393
2394 fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
2395 let leaf = leaf_matches(matches);
2396 let parts = leaf
2397 .get_many::<String>("command")
2398 .map(|values| values.map(String::as_str).collect::<Vec<_>>())
2399 .unwrap_or_default();
2400 self.render_help_for_parts(&parts)
2401 }
2402
2403 fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
2410 if parts.is_empty() {
2411 return CliRunOutput {
2412 exit_code: 0,
2413 rendered: self.root.clone().render_long_help().to_string(),
2414 };
2415 }
2416 let Some(command) = find_help_target(&self.root, parts) else {
2417 return CliRunOutput {
2418 exit_code: 1,
2419 rendered: format!(
2420 "unknown command {:?} — run '{} help' for available commands",
2421 parts.join(" "),
2422 self.config.name
2423 ),
2424 };
2425 };
2426 CliRunOutput {
2427 exit_code: 0,
2428 rendered: command.clone().render_long_help().to_string(),
2429 }
2430 }
2431
2432 fn refresh_root_long(&mut self) {
2433 let builtins = BUILTIN_COMMAND_NAMES;
2438 let categorized: BTreeSet<&str> = self
2439 .module_entries
2440 .iter()
2441 .map(|entry| entry.name.as_str())
2442 .collect();
2443 let mut generic: Vec<ModuleHelpEntry> = self
2444 .root
2445 .get_subcommands()
2446 .filter(|command| !command.is_hide_set())
2447 .filter(|command| !builtins.contains(&command.get_name()))
2448 .filter(|command| !categorized.contains(command.get_name()))
2449 .map(|command| ModuleHelpEntry {
2450 category: "Commands".to_owned(),
2451 name: command.get_name().to_owned(),
2452 short: command
2453 .get_about()
2454 .map(ToString::to_string)
2455 .unwrap_or_default(),
2456 })
2457 .collect();
2458 generic.sort_by(|left, right| left.name.cmp(&right.name));
2459
2460 let mut entries = self.module_entries.clone();
2461 entries.extend(generic);
2462 let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
2463 let intro = self
2464 .config
2465 .long
2466 .as_deref()
2467 .filter(|long| !long.is_empty())
2468 .unwrap_or(self.config.short.as_str());
2469 self.root = self
2470 .root
2471 .clone()
2472 .long_about(build_root_long(intro, &entries, has_guide));
2473 }
2474
2475 fn ensure_auth_command(&mut self) {
2476 let default_provider = self.default_auth_provider();
2477 let registered_names = self.middleware.auth.registered_names();
2478 if default_provider.is_empty() && registered_names.is_empty() {
2479 return;
2480 }
2481 let replacing_builtin = self.commands.contains_key("auth:login");
2482 if has_subcommand(&self.root, "auth") && !replacing_builtin {
2483 return;
2484 }
2485 let mut group = auth_command_group(&default_provider, ®istered_names);
2486 let mut seen_names: std::collections::HashSet<String> =
2487 group.commands.iter().map(|c| c.spec.name.clone()).collect();
2488 for extra in self.config.auth_extra_commands.clone() {
2489 if !seen_names.insert(extra.spec.name.clone()) {
2490 tracing::warn!(
2491 command = %extra.spec.name,
2492 "auth_extra_commands entry collides with a built-in auth subcommand or an \
2493 earlier auth_extra_commands entry; ignoring"
2494 );
2495 continue;
2496 }
2497 group = group.with_command(extra);
2498 }
2499 let mut prefix = Vec::new();
2500 register_runtime_group_metadata(
2501 &group,
2502 &mut prefix,
2503 &mut self.middleware.schema_registry,
2504 &mut self.middleware.human_views,
2505 );
2506 let mut prefix = Vec::new();
2507 group.register_commands(&mut prefix, &mut self.commands);
2508 let mut prefix = Vec::new();
2509 let clap_group = runtime_group_clap_command_with_schema_help(
2510 &group,
2511 &mut prefix,
2512 &self.middleware.schema_registry,
2513 );
2514 self.root = if replacing_builtin {
2515 self.root.clone().mut_subcommand("auth", |_| clap_group)
2516 } else {
2517 self.root.clone().subcommand(clap_group)
2518 };
2519 self.register_auth_help_entry();
2523 }
2524
2525 fn ensure_config_command(&mut self) {
2529 if has_subcommand(&self.root, "config") {
2530 return;
2531 }
2532 let group = crate::config_commands::config_command_group();
2533 let mut prefix = Vec::new();
2534 group.register_commands(&mut prefix, &mut self.commands);
2535 let mut prefix = Vec::new();
2536 let clap_group = runtime_group_clap_command_with_schema_help(
2537 &group,
2538 &mut prefix,
2539 &self.middleware.schema_registry,
2540 );
2541 self.root = self.root.clone().subcommand(clap_group);
2542 let category = self
2543 .config
2544 .admin_category
2545 .clone()
2546 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2547 if !self
2548 .module_entries
2549 .iter()
2550 .any(|entry| entry.name == "config")
2551 {
2552 self.module_entries.push(ModuleHelpEntry {
2553 category,
2554 name: "config".to_owned(),
2555 short: "Read and write the CLI config file".to_owned(),
2556 });
2557 }
2558 self.refresh_root_long();
2559 }
2560
2561 fn ensure_env_command(&mut self) {
2565 if has_subcommand(&self.root, "env") {
2566 return;
2567 }
2568 let group = crate::env_commands::env_command_group();
2569 let mut prefix = Vec::new();
2570 group.register_commands(&mut prefix, &mut self.commands);
2571 let mut prefix = Vec::new();
2572 let clap_group = runtime_group_clap_command_with_schema_help(
2573 &group,
2574 &mut prefix,
2575 &self.middleware.schema_registry,
2576 );
2577 self.root = self.root.clone().subcommand(clap_group);
2578 let category = self
2579 .config
2580 .admin_category
2581 .clone()
2582 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2583 if !self.module_entries.iter().any(|e| e.name == "env") {
2584 self.module_entries.push(ModuleHelpEntry {
2585 category,
2586 name: "env".to_owned(),
2587 short: "Manage the active environment".to_owned(),
2588 });
2589 }
2590 self.refresh_root_long();
2591 }
2592
2593 fn ensure_flags_command(&mut self) {
2599 if has_subcommand(&self.root, "flags") {
2600 return;
2601 }
2602 let group = crate::flag_commands::flags_command_group();
2603 let mut prefix = Vec::new();
2604 group.register_commands(&mut prefix, &mut self.commands);
2605 let mut prefix = Vec::new();
2606 let clap_group = runtime_group_clap_command_with_schema_help(
2607 &group,
2608 &mut prefix,
2609 &self.middleware.schema_registry,
2610 );
2611 self.root = self.root.clone().subcommand(clap_group);
2612 let category = self
2613 .config
2614 .admin_category
2615 .clone()
2616 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2617 if !self.module_entries.iter().any(|e| e.name == "flags") {
2618 self.module_entries.push(ModuleHelpEntry {
2619 category,
2620 name: "flags".to_owned(),
2621 short: "Inspect declared feature flags".to_owned(),
2622 });
2623 }
2624 self.refresh_root_long();
2625 }
2626
2627 fn default_auth_provider(&self) -> String {
2628 if !self.middleware.default_auth_provider.is_empty() {
2629 return self.middleware.default_auth_provider.clone();
2630 }
2631 self.middleware
2632 .auth
2633 .registered_names()
2634 .into_iter()
2635 .next()
2636 .unwrap_or_default()
2637 }
2638
2639 fn initialized_middleware(&self) -> Result<Middleware> {
2640 let Some(init_deps) = &self.init_deps else {
2641 return Ok(self.middleware.clone());
2642 };
2643 let mut guard = self
2644 .init_state
2645 .lock()
2646 .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
2647 if let Some(result) = guard.as_ref() {
2648 return result.clone().map_err(InitFailure::into_error);
2649 }
2650 let mut middleware = self.middleware.clone();
2651 let result = init_deps(&mut middleware)
2652 .map(|()| middleware)
2653 .map_err(|err| InitFailure::capture(&err));
2654 *guard = Some(result.clone());
2655 result.map_err(InitFailure::into_error)
2656 }
2657
2658 fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2659 if let Some(apply_flags) = &self.apply_flags {
2660 apply_flags(matches, middleware)?;
2661 }
2662 Ok(())
2663 }
2664
2665 fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2672 let Some(environments) = middleware.environments.as_ref() else {
2678 return Ok(());
2679 };
2680 if let Some(env) = matches.get_one::<String>("env") {
2681 environments.source(env)?;
2682 middleware.env = env.clone();
2683 }
2684 Ok(())
2685 }
2686
2687 fn run_pre_run(
2688 &self,
2689 middleware: &mut Middleware,
2690 command_path: &str,
2691 args: &crate::middleware::ValueMap,
2692 ) -> Result<()> {
2693 if let Some(pre_run) = &self.pre_run {
2694 pre_run(middleware, command_path, args)?;
2695 }
2696 Ok(())
2697 }
2698
2699 fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
2700 if let Some(resolver) = &self.meta_resolver {
2701 resolver(command_path, meta)
2702 } else {
2703 meta
2704 }
2705 }
2706
2707 fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
2708 crate::config::clear_credential_store_flag();
2711 if let Some(on_shutdown) = &self.on_shutdown {
2712 on_shutdown();
2713 }
2714 output
2715 }
2716}
2717
2718fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
2719 middleware.output_format = flags.output_format.clone();
2720 middleware.verbose = flags.verbose.clone();
2721 middleware.dry_run = flags.dry_run;
2722 middleware.fields = flags.fields.clone();
2723 middleware.fields_explicit = flags.fields_explicit;
2724 middleware.filter = flags.filter.clone();
2725 middleware.expr = flags.expr.clone();
2726 middleware.reason = flags.reason.clone();
2727 middleware.schema = flags.schema;
2728 middleware.timeout = timeout;
2729 middleware.debug = flags.debug.clone();
2730 middleware.interactive = flags.interactive;
2731}
2732
2733fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) {
2736 let Some(pagination) = spec.pagination else {
2737 return;
2738 };
2739 middleware.limit = leaf
2740 .get_one::<i64>("limit")
2741 .copied()
2742 .unwrap_or(pagination.default_limit);
2743 middleware.offset = leaf.get_one::<i64>("offset").copied().unwrap_or(0);
2744}
2745
2746fn pagination_command_base(
2773 binary_name: &str,
2774 command_path: &str,
2775 spec: &CommandSpec,
2776 user_args: &crate::middleware::ValueMap,
2777 flags: &GlobalFlags,
2778) -> String {
2779 let mut parts = vec![
2780 quote_pagination_value(binary_name),
2781 command_path.replace(':', " "),
2782 ];
2783 for arg in &spec.args {
2784 let id = arg.get_id().as_str();
2785 if let Some(value) = user_args.get(id) {
2786 push_pagination_arg(&mut parts, arg, value);
2787 }
2788 }
2789 for (flag, value) in [
2790 ("--filter", &flags.filter),
2791 ("--expr", &flags.expr),
2792 ("--fields", &flags.fields),
2793 ] {
2794 if !value.is_empty() {
2795 parts.push(flag.to_owned());
2796 parts.push(quote_pagination_value(value));
2797 }
2798 }
2799 parts.join(" ")
2800}
2801
2802fn push_pagination_arg(parts: &mut Vec<String>, arg: &Arg, value: &serde_json::Value) {
2803 let flag = arg
2804 .get_long()
2805 .map(|long| format!("--{long}"))
2806 .or_else(|| arg.get_short().map(|short| format!("-{short}")));
2807 match value {
2808 serde_json::Value::Bool(enabled) => {
2809 if matches!(
2810 arg.get_action(),
2811 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
2812 ) {
2813 if let Some(flag) = flag {
2819 parts.push(flag);
2820 }
2821 } else {
2822 push_flagged_value(parts, flag, &enabled.to_string());
2826 }
2827 }
2828 serde_json::Value::Array(items) => {
2829 for item in items {
2838 push_flagged_value(parts, flag.clone(), &pagination_arg_display(item));
2839 }
2840 }
2841 serde_json::Value::Null => {}
2842 other => push_flagged_value(parts, flag, &pagination_arg_display(other)),
2843 }
2844}
2845
2846fn push_flagged_value(parts: &mut Vec<String>, flag: Option<String>, value: &str) {
2847 if let Some(flag) = flag {
2848 parts.push(flag);
2849 }
2850 parts.push(quote_pagination_value(value));
2851}
2852
2853fn pagination_arg_display(value: &serde_json::Value) -> String {
2854 match value {
2855 serde_json::Value::String(text) => text.clone(),
2856 other => other.to_string(),
2857 }
2858}
2859
2860fn quote_pagination_value(value: &str) -> String {
2869 let safe_unquoted =
2870 |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@');
2871 if value.is_empty() || !value.chars().all(safe_unquoted) {
2872 let escaped = value
2873 .replace('\\', "\\\\")
2874 .replace('"', "\\\"")
2875 .replace('$', "\\$")
2876 .replace('`', "\\`");
2877 format!("\"{escaped}\"")
2878 } else {
2879 value.to_owned()
2880 }
2881}
2882
2883fn debug_transport_logger_for(
2892 debug: &str,
2893 extra_redacted: &[String],
2894) -> Arc<dyn crate::transport::TransportLogger> {
2895 if crate::debug_component_enabled(debug, "transport") {
2896 Arc::new(
2897 crate::transport::StderrTransportLogger::new()
2898 .with_redacted_headers(extra_redacted.iter().cloned()),
2899 )
2900 } else {
2901 Arc::new(crate::transport::NoopTransportLogger)
2902 }
2903}
2904
2905fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2916 crate::transport::set_default_transport_logger(debug_transport_logger_for(
2917 debug,
2918 extra_redacted,
2919 ));
2920}
2921
2922async fn run_with_timeout<F, T>(
2923 timeout: Option<Duration>,
2924 timeout_label: &str,
2925 future: F,
2926) -> Result<T>
2927where
2928 F: Future<Output = Result<T>>,
2929{
2930 let Some(timeout) = timeout else {
2931 return future.await;
2932 };
2933 match tokio::time::timeout(timeout, future).await {
2934 Ok(result) => result,
2935 Err(_) => Err(CliCoreError::message(format!(
2936 "command timed out after {timeout_label}"
2937 ))),
2938 }
2939}
2940
2941async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2942where
2943 Run: Future<Output = CliRunOutput>,
2944 Shutdown: Future<Output = ()>,
2945{
2946 tokio::pin!(run);
2947 tokio::pin!(shutdown);
2948 tokio::select! {
2949 output = &mut run => output,
2950 () = &mut shutdown => CliRunOutput {
2951 exit_code: 130,
2952 rendered: "command interrupted\n".to_owned(),
2953 },
2954 }
2955}
2956
2957#[cfg(unix)]
2958async fn shutdown_signal() {
2959 let ctrl_c = tokio::signal::ctrl_c();
2960 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2961 Ok(mut sigterm) => {
2962 tokio::select! {
2963 _ = ctrl_c => {},
2964 _ = sigterm.recv() => {},
2965 }
2966 }
2967 Err(_) => {
2968 drop(ctrl_c.await);
2969 }
2970 }
2971}
2972
2973#[cfg(not(unix))]
2974async fn shutdown_signal() {
2975 drop(tokio::signal::ctrl_c().await);
2976}
2977
2978fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2979 let raw = raw.trim();
2980 if raw.is_empty() {
2981 return Ok(Some(Duration::from_secs(60)));
2982 }
2983 let Some(seconds) = parse_duration_seconds(raw) else {
2984 return Err(CliCoreError::message(format!(
2985 "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2986 )));
2987 };
2988 if seconds <= 0.0 {
2989 Ok(None)
2990 } else {
2991 Ok(Some(Duration::from_secs_f64(seconds)))
2992 }
2993}
2994
2995fn parse_duration_seconds(raw: &str) -> Option<f64> {
2996 for (suffix, seconds) in [
2997 ("ns", 0.000_000_001_f64),
2998 ("us", 0.000_001_f64),
2999 ("µs", 0.000_001_f64),
3000 ("ms", 0.001_f64),
3001 ("s", 1.0_f64),
3002 ("m", 60.0_f64),
3003 ("h", 3600.0_f64),
3004 ] {
3005 if let Some(number) = raw.strip_suffix(suffix) {
3006 let value = number.parse::<f64>().ok()?;
3007 if !value.is_finite() {
3008 return None;
3009 }
3010 return Some(value * seconds);
3011 }
3012 }
3013 None
3014}
3015
3016fn global_min_stage_override(app_id: &str) -> Option<Stage> {
3023 let var = min_stage_env_var(app_id);
3024 let value = std::env::var(&var).ok()?;
3025 value.parse::<Stage>().map_or_else(
3026 |err| {
3027 tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override");
3028 None
3029 },
3030 Some,
3031 )
3032}
3033
3034fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option<String> {
3055 let mut result = None;
3056 while let Some(arg) = args.next() {
3057 if arg == "--" {
3062 break;
3063 }
3064 let value = if let Some(v) = arg.strip_prefix("--env=") {
3065 Some(v.to_owned())
3066 } else if arg == "--env" {
3067 args.next().filter(|v| !v.starts_with('-'))
3074 } else {
3075 None
3076 };
3077 if let Some(v) = value.filter(|v| !v.is_empty()) {
3078 result = Some(v);
3079 }
3080 }
3081 result
3082}
3083
3084fn render_cli_error(
3085 middleware: &Middleware,
3086 err: &(dyn std::error::Error + 'static),
3087 system: &str,
3088) -> CliRunOutput {
3089 let format = middleware
3090 .output_format
3091 .parse::<crate::output::OutputFormat>()
3092 .unwrap_or(crate::output::OutputFormat::Json);
3093 let envelope =
3094 crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
3095 match crate::output::render(format, &envelope) {
3096 Ok(rendered) => CliRunOutput {
3097 exit_code: exit_code_for_error(err),
3098 rendered,
3099 },
3100 Err(render_err) => CliRunOutput {
3101 exit_code: exit_code_for_error(err),
3102 rendered: render_err.to_string(),
3103 },
3104 }
3105}
3106
3107fn find_command_by_colon_path<'command>(
3108 root: &'command Command,
3109 path: &str,
3110) -> Option<&'command Command> {
3111 find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
3112}
3113
3114fn find_help_target<'command>(
3115 root: &'command Command,
3116 parts: &[&str],
3117) -> Option<&'command Command> {
3118 let mut current = root;
3119 let mut matched_any = false;
3120 for part in parts {
3121 let Some(next) = current.find_subcommand(part) else {
3122 break;
3123 };
3124 current = next;
3125 matched_any = true;
3126 }
3127 matched_any.then_some(current)
3128}
3129
3130fn find_command_and_canonical_path_by_colon_path<'command>(
3131 root: &'command Command,
3132 path: &str,
3133) -> Option<(&'command Command, Vec<String>)> {
3134 if path.is_empty() {
3135 return Some((root, Vec::new()));
3136 }
3137 let mut current = root;
3138 let mut canonical = Vec::new();
3139 for part in path.split(':') {
3140 current = current.find_subcommand(part)?;
3141 canonical.push(current.get_name().to_owned());
3142 }
3143 Some((current, canonical))
3144}
3145
3146fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
3147 if parts.is_empty() {
3148 return Some(String::new());
3149 }
3150 let mut current = root;
3151 let mut canonical = Vec::new();
3152 for part in parts {
3153 current = current.find_subcommand(part)?;
3154 canonical.push(current.get_name().to_owned());
3155 }
3156 Some(canonical.join(":"))
3157}
3158
3159fn warn_unresolvable_search_scope(scope_path: &str) {
3168 let mut stderr = std::io::stderr().lock();
3169 stderr
3170 .write_all(
3171 format!(
3172 "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n"
3173 )
3174 .as_bytes(),
3175 )
3176 .ok();
3177}
3178
3179fn collect_command_search_documents(
3180 command: &Command,
3181 prefix: &mut Vec<String>,
3182 aliases: &mut Vec<String>,
3183 docs: &mut Vec<SearchDocument>,
3184) {
3185 if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
3186 return;
3187 }
3188 if command.get_subcommands().next().is_some() {
3189 for child in command.get_subcommands() {
3190 prefix.push(child.get_name().to_owned());
3191 let alias_len = aliases.len();
3192 append_command_alias_terms(child, aliases);
3193 collect_command_search_documents(child, prefix, aliases, docs);
3194 aliases.truncate(alias_len);
3195 prefix.pop();
3196 }
3197 return;
3198 }
3199 if prefix.is_empty() {
3200 prefix.push(command.get_name().to_owned());
3201 append_command_alias_terms(command, aliases);
3202 }
3203 let path = prefix.join(" ");
3204 let alias_text = aliases.join(" ");
3205 docs.push(SearchDocument {
3206 id: format!("cmd:{path}"),
3207 kind: "command".to_owned(),
3208 title: path,
3209 summary: command
3210 .get_about()
3211 .map(ToString::to_string)
3212 .unwrap_or_default(),
3213 content: format!(
3214 "{} {} {} {}",
3215 command
3216 .get_about()
3217 .map(ToString::to_string)
3218 .unwrap_or_default(),
3219 command
3220 .get_long_about()
3221 .map(ToString::to_string)
3222 .unwrap_or_default(),
3223 command_flag_text(command),
3224 alias_text
3225 ),
3226 });
3227 if prefix.len() == 1 && prefix[0] == command.get_name() {
3228 prefix.pop();
3229 }
3230}
3231
3232fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
3233 aliases.extend(command.get_all_aliases().map(str::to_owned));
3234 aliases.extend(
3235 command
3236 .get_all_short_flag_aliases()
3237 .map(|alias| alias.to_string()),
3238 );
3239 aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
3240}
3241
3242fn command_flag_text(command: &Command) -> String {
3243 command
3244 .get_arguments()
3245 .filter(|arg| !arg.is_hide_set())
3246 .filter_map(|arg| {
3247 let mut names = Vec::new();
3248 if let Some(short) = arg.get_short() {
3249 names.push(format!("-{short}"));
3250 }
3251 if let Some(long) = arg.get_long() {
3252 names.push(format!("--{long}"));
3253 }
3254 if let Some(short_aliases) = arg.get_all_short_aliases() {
3255 names.extend(
3256 short_aliases
3257 .into_iter()
3258 .map(|short_alias| format!("-{short_alias}")),
3259 );
3260 }
3261 if let Some(aliases) = arg.get_all_aliases() {
3262 names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
3263 }
3264 (!names.is_empty()).then(|| names.join(" "))
3265 })
3266 .collect::<Vec<_>>()
3267 .join(" ")
3268}
3269
3270fn has_subcommand(command: &Command, name: &str) -> bool {
3271 command
3272 .get_subcommands()
3273 .any(|child| child.get_name() == name)
3274}
3275
3276fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
3277 let bool_flags = derive_bool_flags(root);
3278 let value_flags = derive_value_flags(root);
3279 let mut iter = args.iter().peekable();
3280 if iter
3281 .peek()
3282 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3283 {
3284 iter.next();
3285 }
3286
3287 while let Some(arg) = iter.next() {
3288 match arg.as_str() {
3289 "--version" | "-v" => return true,
3290 "--" => return false,
3291 value if value.contains('=') || bool_flags.contains(value) => continue,
3292 value
3293 if value_flags.contains(value)
3294 || unknown_flag_consumes_value(value, iter.peek()) =>
3295 {
3296 iter.next();
3297 }
3298 value if value.starts_with('-') => {}
3299 _ => return false,
3300 }
3301 }
3302 false
3303}
3304
3305fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
3306 let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
3307 let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
3308 let mut normalized = Vec::with_capacity(args.len());
3309 let mut index = 0;
3310 let mut current = root;
3311 while index < args.len() {
3312 let arg = &args[index];
3313 if index == 0 && arg_matches_root_name(arg, root.get_name()) {
3314 normalized.push(arg.clone());
3315 index += 1;
3316 continue;
3317 }
3318
3319 if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
3320 normalized.push(format!("{arg}={default}"));
3321 index += 1;
3322 continue;
3323 }
3324
3325 if let Some(default) = optional_string_defaults.get(arg.as_str()) {
3326 match args.get(index + 1) {
3327 None => {
3328 normalized.push(format!("{arg}={default}"));
3329 index += 1;
3330 continue;
3331 }
3332 Some(next)
3333 if current.get_name() == root.get_name()
3334 || next.starts_with('-')
3335 || direct_subcommand(current, next).is_some() =>
3336 {
3337 normalized.push(format!("{arg}={default}"));
3338 index += 1;
3339 continue;
3340 }
3341 Some(next) => {
3342 normalized.push(arg.clone());
3343 normalized.push(next.clone());
3344 index += 2;
3345 continue;
3346 }
3347 }
3348 }
3349
3350 normalized.push(arg.clone());
3351 if !arg.starts_with('-')
3352 && let Some(next_command) = direct_subcommand(current, arg)
3353 {
3354 current = next_command;
3355 }
3356 index += 1;
3357 }
3358 normalized
3359}
3360
3361fn direct_subcommand<'command>(
3362 command: &'command Command,
3363 token: &str,
3364) -> Option<&'command Command> {
3365 command.get_subcommands().find(|child| {
3366 child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
3367 })
3368}
3369
3370fn format_did_you_mean(base: &str, suggestion: &str) -> String {
3372 format!("{base} — did you mean {suggestion:?}?")
3373}
3374
3375struct UnknownGroupCommand {
3377 base: String,
3378}
3379
3380fn detect_unknown_group_command(
3383 root: &Command,
3384 positionals: &[String],
3385) -> Option<UnknownGroupCommand> {
3386 if positionals.is_empty() {
3387 return None;
3388 }
3389
3390 let mut current = root;
3391 let mut path = vec![root.get_name().to_owned()];
3392 for token in positionals {
3393 if let Some(next) = current.find_subcommand(token) {
3394 current = next;
3395 path.push(next.get_name().to_owned());
3396 continue;
3397 }
3398 if current.get_subcommands().next().is_some() {
3399 let base = format!("unknown command {token:?} for {:?}", path.join(" "));
3400 return Some(UnknownGroupCommand { base });
3401 }
3402 return None;
3403 }
3404 None
3405}
3406
3407fn command_keyword_count(
3409 args: &[String],
3410 root_name: &str,
3411 bool_flags: &BTreeSet<String>,
3412 value_flags: &BTreeSet<String>,
3413) -> usize {
3414 let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags);
3415 match args.iter().position(|arg| arg == "--") {
3416 Some(end) => {
3417 positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len()
3418 }
3419 None => positionals.len(),
3420 }
3421}
3422
3423fn rewrite_group_help_if_needed(
3426 root: &Command,
3427 clap_args: &[String],
3428 root_name: &str,
3429 bool_flags: &BTreeSet<String>,
3430 value_flags: &BTreeSet<String>,
3431) -> Vec<String> {
3432 let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags);
3433 let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags);
3434 let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else {
3435 return clap_args.to_vec();
3436 };
3437 rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts)
3438}
3439
3440fn replace_positional_command_token(
3443 args: &[String],
3444 root_name: &str,
3445 bool_flags: &BTreeSet<String>,
3446 value_flags: &BTreeSet<String>,
3447 target: usize,
3448 replacement: &str,
3449) -> Vec<String> {
3450 let mut out = args.to_vec();
3451 let mut index = 0;
3452 if out
3453 .first()
3454 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3455 {
3456 index = 1;
3457 }
3458
3459 let mut positional = 0;
3460 while index < out.len() {
3461 let arg = &out[index];
3462 if arg == "--" {
3463 break;
3464 }
3465 if arg.contains('=') {
3466 index += 1;
3467 continue;
3468 }
3469 if bool_flags.contains(arg) {
3470 index += 1;
3471 continue;
3472 }
3473 if value_flags.contains(arg)
3474 || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref())
3475 {
3476 index += 2;
3477 continue;
3478 }
3479 if arg.starts_with('-') {
3480 index += 1;
3481 continue;
3482 }
3483 if positional == target {
3484 out[index] = replacement.to_owned();
3485 break;
3486 }
3487 positional += 1;
3488 index += 1;
3489 }
3490 out
3491}
3492
3493fn nearest_subcommand(command: &Command, token: &str) -> Option<String> {
3496 let token = token.to_ascii_lowercase();
3497 let max_distance = 1.max(token.chars().count() / 3);
3498
3499 command
3500 .get_subcommands()
3501 .filter(|child| !child.is_hide_set())
3502 .filter_map(|child| {
3503 let best = std::iter::once(child.get_name())
3504 .chain(child.get_all_aliases())
3505 .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase()))
3506 .min()?;
3507 (best <= max_distance).then(|| (best, child.get_name().to_owned()))
3508 })
3509 .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)))
3510 .map(|(_, name)| name)
3511}
3512
3513fn full_command_correction(root: &Command, positionals: &[String]) -> Option<Vec<(usize, String)>> {
3517 let mut current = root;
3518 let mut corrections = Vec::new();
3519 for (index, token) in positionals.iter().enumerate() {
3520 if let Some(next) = current.find_subcommand(token) {
3521 current = next;
3522 continue;
3523 }
3524 if current.get_subcommands().next().is_none() {
3525 break;
3526 }
3527 if token == "help" && current.find_subcommand("help").is_none() {
3528 break;
3529 }
3530 let suggestion = nearest_subcommand(current, token)?;
3531 let next = current.find_subcommand(&suggestion)?;
3532 corrections.push((index, suggestion));
3533 current = next;
3534 }
3535 (!corrections.is_empty()).then_some(corrections)
3536}
3537
3538fn correction_display(
3541 root_name: &str,
3542 positionals: &[String],
3543 corrections: &[(usize, String)],
3544) -> String {
3545 if let [(index, only)] = corrections
3546 && *index + 1 == positionals.len()
3547 {
3548 return only.clone();
3549 }
3550 let mut tokens = vec![root_name.to_owned()];
3551 for (index, token) in positionals.iter().enumerate() {
3552 let corrected = corrections
3553 .iter()
3554 .find(|(i, _)| *i == index)
3555 .map(|(_, replacement)| replacement.clone())
3556 .unwrap_or_else(|| token.clone());
3557 tokens.push(corrected);
3558 }
3559 tokens.join(" ")
3560}
3561
3562#[cfg(test)]
3563mod unknown_command_suggestion_tests {
3564 use super::*;
3565
3566 fn sample_group() -> Command {
3567 Command::new("gddy").subcommand(
3568 Command::new("domain")
3569 .alias("dns-domain")
3570 .subcommand(Command::new("list"))
3571 .subcommand(Command::new("available")),
3572 )
3573 }
3574
3575 #[test]
3576 fn osa_distance_treats_adjacent_transposition_as_one_edit() {
3577 assert_eq!(strsim::osa_distance("domain", "domain"), 0);
3579 assert_eq!(strsim::osa_distance("domian", "domain"), 1);
3580 assert_eq!(strsim::osa_distance("lst", "list"), 1);
3581 assert_eq!(strsim::osa_distance("lsit", "list"), 1);
3582 assert_eq!(strsim::osa_distance("cat", "set"), 2);
3583 }
3584
3585 #[test]
3586 fn nearest_subcommand_matches_close_typos() {
3587 let root = sample_group();
3588 let domain = root.find_subcommand("domain").expect("domain registered");
3589 assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list"));
3590 assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list"));
3591 assert_eq!(
3592 nearest_subcommand(domain, "avaliable").as_deref(),
3593 Some("available")
3594 );
3595 }
3596
3597 #[test]
3598 fn nearest_subcommand_rejects_unrelated_tokens() {
3599 let root = sample_group();
3600 let domain = root.find_subcommand("domain").expect("domain registered");
3601 assert_eq!(nearest_subcommand(domain, "missing"), None);
3602 }
3603
3604 #[test]
3605 fn nearest_subcommand_returns_canonical_name_for_alias_typos() {
3606 let root = sample_group();
3607 assert_eq!(
3608 nearest_subcommand(&root, "dns-domian").as_deref(),
3609 Some("domain")
3610 );
3611 }
3612
3613 #[test]
3614 fn nearest_subcommand_skips_hidden_commands() {
3615 let root = Command::new("gddy")
3616 .subcommand(Command::new("visible"))
3617 .subcommand(Command::new("hiddeen").hide(true));
3618 assert_eq!(nearest_subcommand(&root, "hidden"), None);
3619 }
3620
3621 #[test]
3622 fn nearest_subcommand_rejects_short_unrelated_tokens() {
3623 let root = Command::new("gddy").subcommand(
3624 Command::new("config")
3625 .subcommand(Command::new("get"))
3626 .subcommand(Command::new("set"))
3627 .subcommand(Command::new("add")),
3628 );
3629 let config = root.find_subcommand("config").expect("config registered");
3630 assert_eq!(nearest_subcommand(config, "cat"), None);
3631 assert_eq!(nearest_subcommand(config, "x"), None);
3632 assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set"));
3633 }
3634
3635 #[test]
3636 fn unknown_group_command_formats_did_you_mean_suffix() {
3637 let root = sample_group();
3638 let unknown = detect_unknown_group_command(&root, &["domian".to_owned()])
3639 .expect("domian is an unknown top-level command");
3640 assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\"");
3641 assert_eq!(
3642 format_did_you_mean(&unknown.base, "domain"),
3643 "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?"
3644 );
3645 }
3646
3647 #[test]
3648 fn detect_unknown_group_command_reports_nested_typos() {
3649 let root = sample_group();
3650 let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()])
3651 .expect("lst is an unknown subcommand of domain");
3652 assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\"");
3653 assert_eq!(
3654 format_did_you_mean(&unknown.base, "list"),
3655 "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?"
3656 );
3657 }
3658
3659 #[test]
3660 fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() {
3661 let root = sample_group();
3662 let unknown = detect_unknown_group_command(&root, &["missing".to_owned()])
3663 .expect("missing is an unknown top-level command");
3664 assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\"");
3665 }
3666
3667 #[test]
3668 fn full_command_correction_fixes_a_single_group_typo() {
3669 let root = sample_group();
3670 let corrections = full_command_correction(&root, &["domian".to_owned()])
3671 .expect("domian is correctable to domain");
3672 assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3673 }
3674
3675 #[test]
3676 fn full_command_correction_fixes_every_typo_in_a_nested_path() {
3677 let root = sample_group();
3678 let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()])
3679 .expect("both tokens are correctable");
3680 assert_eq!(
3681 corrections,
3682 vec![(0, "domain".to_owned()), (1, "list".to_owned())]
3683 );
3684 }
3685
3686 #[test]
3687 fn full_command_correction_bails_when_a_token_has_no_near_match() {
3688 let root = sample_group();
3689 assert_eq!(
3690 full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]),
3691 None
3692 );
3693 }
3694
3695 #[test]
3696 fn full_command_correction_is_none_when_there_is_nothing_to_correct() {
3697 let root = sample_group();
3698 assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None);
3699 assert_eq!(full_command_correction(&root, &[]), None);
3700 }
3701
3702 #[test]
3703 fn full_command_correction_corrects_the_group_before_curated_help() {
3704 let root = sample_group();
3705 let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()])
3706 .expect("domian is correctable even ahead of a help token");
3707 assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3708 }
3709
3710 #[test]
3711 fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() {
3712 let root = sample_group();
3713 let corrections = full_command_correction(
3714 &root,
3715 &[
3716 "domain".to_owned(),
3717 "avaliable".to_owned(),
3718 "example.com".to_owned(),
3719 ],
3720 )
3721 .expect("avaliable is correctable to available");
3722 assert_eq!(corrections, vec![(1, "available".to_owned())]);
3723 }
3724
3725 #[test]
3726 fn correction_display_shows_the_bare_token_for_a_single_fix() {
3727 let corrections = vec![(1, "list".to_owned())];
3728 assert_eq!(
3729 correction_display(
3730 "gddy",
3731 &["domain".to_owned(), "lst".to_owned()],
3732 &corrections
3733 ),
3734 "list"
3735 );
3736 }
3737
3738 #[test]
3739 fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() {
3740 let corrections = vec![(0, "domain".to_owned())];
3741 assert_eq!(
3742 correction_display(
3743 "gddy",
3744 &["domian".to_owned(), "list".to_owned()],
3745 &corrections
3746 ),
3747 "gddy domain list"
3748 );
3749 }
3750
3751 #[test]
3752 fn correction_display_shows_the_full_command_for_multiple_fixes() {
3753 let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())];
3754 assert_eq!(
3755 correction_display(
3756 "gddy",
3757 &["domian".to_owned(), "lst".to_owned()],
3758 &corrections
3759 ),
3760 "gddy domain list"
3761 );
3762 }
3763
3764 #[test]
3765 fn replace_positional_command_token_rewrites_only_the_target() {
3766 let bool_flags: BTreeSet<String> = ["--verbose".to_owned()].into_iter().collect();
3767 let value_flags: BTreeSet<String> = ["--output".to_owned()].into_iter().collect();
3768 let args = vec![
3769 "gddy".to_owned(),
3770 "--output".to_owned(),
3771 "json".to_owned(),
3772 "domain".to_owned(),
3773 "lst".to_owned(),
3774 ];
3775 let corrected =
3776 replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list");
3777 assert_eq!(
3778 corrected,
3779 vec!["gddy", "--output", "json", "domain", "list"]
3780 );
3781 }
3782
3783 #[test]
3784 fn rewrite_group_help_if_needed_runs_after_typo_correction() {
3785 let root = sample_group();
3786 let bool_flags = derive_bool_flags(&root);
3787 let value_flags = derive_value_flags(&root);
3788 let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()];
3789 let corrected =
3790 replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain");
3791 assert_eq!(corrected, vec!["gddy", "domain", "help"]);
3792 let rewritten =
3793 rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags);
3794 assert_eq!(rewritten, vec!["gddy", "help", "domain"]);
3795 }
3796}
3797
3798fn group_help_target_parts(
3821 root: &Command,
3822 positionals: &[String],
3823 command_keyword_count: usize,
3824) -> Option<Vec<String>> {
3825 let help_index = positionals.iter().position(|token| token == "help")?;
3826 if help_index == 0 {
3828 return None;
3829 }
3830 if help_index >= command_keyword_count {
3832 return None;
3833 }
3834 let prefix = &positionals[..help_index];
3835 let mut current = root;
3836 for token in prefix {
3837 current = current.find_subcommand(token)?;
3838 }
3839 current.get_subcommands().next()?;
3841 if current.find_subcommand("help").is_some() {
3843 return None;
3844 }
3845 let suffix = &positionals[help_index + 1..];
3847 Some(prefix.iter().chain(suffix).cloned().collect())
3848}
3849
3850fn rewrite_group_help_args(
3861 clap_args: &[String],
3862 root_name: &str,
3863 bool_flags: &BTreeSet<String>,
3864 value_flags: &BTreeSet<String>,
3865 parts: &[String],
3866) -> Vec<String> {
3867 let mut next_positional = std::iter::once("help".to_owned())
3869 .chain(parts.iter().cloned())
3870 .peekable();
3871 let mut out = Vec::with_capacity(clap_args.len());
3872 let mut iter = clap_args.iter().peekable();
3873 if iter
3874 .peek()
3875 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3876 && let Some(program) = iter.next()
3877 {
3878 out.push(program.clone());
3879 }
3880
3881 let mut take_positional =
3882 |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
3883
3884 while let Some(arg) = iter.next() {
3885 if arg == "--" {
3886 out.push(arg.clone());
3887 for rest in iter.by_ref() {
3889 out.push(take_positional(rest));
3890 }
3891 break;
3892 }
3893 if arg.contains('=') || bool_flags.contains(arg) {
3894 out.push(arg.clone());
3895 continue;
3896 }
3897 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3898 out.push(arg.clone());
3899 if let Some(value) = iter.next() {
3900 out.push(value.clone());
3901 }
3902 continue;
3903 }
3904 if arg.starts_with('-') {
3905 out.push(arg.clone());
3906 continue;
3907 }
3908 out.push(take_positional(arg));
3909 }
3910 out.extend(next_positional);
3912 out
3913}
3914
3915fn positional_command_tokens(
3916 args: &[String],
3917 root_name: &str,
3918 bool_flags: &BTreeSet<String>,
3919 value_flags: &BTreeSet<String>,
3920) -> Vec<String> {
3921 let mut tokens = Vec::new();
3922 let mut iter = args.iter().peekable();
3923 if iter
3924 .peek()
3925 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3926 {
3927 iter.next();
3928 }
3929
3930 while let Some(arg) = iter.next() {
3931 if arg == "--" {
3932 tokens.extend(iter.cloned());
3933 break;
3934 }
3935 if arg.contains('=') {
3936 continue;
3937 }
3938 if bool_flags.contains(arg) {
3939 continue;
3940 }
3941 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3942 iter.next();
3943 continue;
3944 }
3945 if arg.starts_with('-') {
3946 continue;
3947 }
3948 tokens.push(arg.clone());
3949 }
3950 tokens
3951}
3952
3953fn single_leaf_subcommand(group: &Command) -> Option<String> {
3958 let candidates: Vec<_> = group
3959 .get_subcommands()
3960 .filter(|child| !child.is_hide_set())
3961 .filter(|child| child.get_name() != "help")
3962 .filter(|child| child.get_subcommands().next().is_none())
3963 .collect();
3964 if candidates.len() == 1 {
3965 Some(candidates[0].get_name().to_string())
3966 } else {
3967 None
3968 }
3969}
3970
3971fn inject_subcommand_after_command_path(
3974 args: &[String],
3975 root_name: &str,
3976 command_path: &str,
3977 subcommand: &str,
3978 bool_flags: &BTreeSet<String>,
3979 value_flags: &BTreeSet<String>,
3980) -> Vec<String> {
3981 let path_parts: Vec<&str> = command_path.split(':').collect();
3982 let mut result = Vec::with_capacity(args.len() + 1);
3983 let mut iter = args.iter().peekable();
3984
3985 if iter
3986 .peek()
3987 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3988 {
3989 result.push(iter.next().expect("peeked").clone());
3990 }
3991
3992 let mut matched = 0_usize;
3993 while let Some(arg) = iter.next() {
3994 if arg == "--" {
3995 result.push(arg.clone());
3996 result.extend(iter.cloned());
3997 break;
3998 }
3999 if arg.contains('=') {
4000 result.push(arg.clone());
4001 continue;
4002 }
4003 if bool_flags.contains(arg) {
4004 result.push(arg.clone());
4005 continue;
4006 }
4007 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
4008 result.push(arg.clone());
4009 if let Some(value) = iter.next() {
4010 result.push(value.clone());
4011 }
4012 continue;
4013 }
4014 if arg.starts_with('-') {
4015 result.push(arg.clone());
4016 continue;
4017 }
4018
4019 result.push(arg.clone());
4020 if matched < path_parts.len() && arg == path_parts[matched] {
4021 matched += 1;
4022 if matched == path_parts.len() {
4023 result.push(subcommand.to_string());
4024 }
4025 }
4026 }
4027 result
4028}
4029
4030fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
4031 arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
4032}
4033
4034fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
4035 arg == root_name
4036 || Path::new(arg)
4037 .file_stem()
4038 .and_then(|n| n.to_str())
4039 .is_some_and(|n| n == root_name)
4040}
4041
4042enum Argv0Outcome {
4045 Proceed(Vec<String>),
4047 Handled(CliRunOutput),
4049}
4050
4051fn program_basename(arg: &str) -> String {
4055 Path::new(arg)
4056 .file_stem()
4057 .and_then(|stem| stem.to_str())
4058 .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
4059}
4060
4061fn is_valid_argv0_name(name: &str) -> bool {
4066 !name.is_empty()
4067 && name.chars().all(|character| {
4068 character.is_ascii_alphanumeric() || character == '-' || character == '_'
4069 })
4070}
4071
4072fn argv0_link_matches(
4077 link: &Path,
4078 target: &Path,
4079 name: &str,
4080 method: Argv0LinkMethod,
4081) -> std::io::Result<bool> {
4082 let metadata = std::fs::symlink_metadata(link)?;
4083 match method {
4084 Argv0LinkMethod::SoftLink => {
4085 Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
4086 }
4087 Argv0LinkMethod::HardLink => {
4088 if metadata.file_type().is_symlink() {
4089 return Ok(false);
4090 }
4091 Ok(std::fs::read(link)? == std::fs::read(target)?)
4094 }
4095 Argv0LinkMethod::Script => {
4096 if metadata.file_type().is_symlink() {
4097 return Ok(false);
4098 }
4099 Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
4100 }
4101 }
4102}
4103
4104fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
4106 let extension = match method {
4107 Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
4108 Argv0LinkMethod::Script => "",
4110 _ if cfg!(windows) => ".exe",
4111 _ => "",
4112 };
4113 format!("{name}{extension}")
4114}
4115
4116fn argv0_script_contents(target: &Path, name: &str) -> String {
4120 let target = target.display();
4121 if cfg!(windows) {
4122 format!("@\"{target}\" argv0 {name} %*\r\n")
4123 } else {
4124 format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
4125 }
4126}
4127
4128#[cfg(unix)]
4129fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4130 std::os::unix::fs::symlink(target, link)
4131}
4132
4133#[cfg(windows)]
4134fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4135 std::os::windows::fs::symlink_file(target, link)
4136}
4137
4138#[cfg(not(any(unix, windows)))]
4139fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
4140 Err(std::io::Error::new(
4141 std::io::ErrorKind::Unsupported,
4142 "symlink creation is not supported on this platform",
4143 ))
4144}
4145
4146#[cfg(unix)]
4148fn make_executable(path: &Path) -> std::io::Result<()> {
4149 use std::os::unix::fs::PermissionsExt;
4150 let mut permissions = std::fs::metadata(path)?.permissions();
4151 permissions.set_mode(0o755);
4152 std::fs::set_permissions(path, permissions)
4153}
4154
4155#[cfg(not(unix))]
4156fn make_executable(_path: &Path) -> std::io::Result<()> {
4157 Ok(())
4158}
4159
4160fn prune_feature_flag_tree(
4184 mut group: RuntimeGroupSpec,
4185 inherited: Option<&FeatureFlag>,
4186 policy: &FlagPolicy,
4187 prefix: &mut Vec<String>,
4188 registry: &mut FlagRegistry,
4189) -> Option<RuntimeGroupSpec> {
4190 prefix.push(group.group.name.clone());
4191
4192 let effective = group
4193 .group
4194 .feature_flag
4195 .clone()
4196 .or_else(|| inherited.cloned());
4197 if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) {
4198 prefix.pop();
4199 return None;
4200 }
4201
4202 let mut kept_groups = Vec::with_capacity(group.groups.len());
4203 for child in std::mem::take(&mut group.groups) {
4204 if let Some(pruned) =
4205 prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry)
4206 {
4207 kept_groups.push(pruned);
4208 }
4209 }
4210 group.groups = kept_groups;
4211
4212 let mut kept_commands = Vec::with_capacity(group.commands.len());
4213 for command in std::mem::take(&mut group.commands) {
4214 prefix.push(command.spec.name.clone());
4215 let command_effective = command
4216 .spec
4217 .feature_flag
4218 .clone()
4219 .or_else(|| effective.clone());
4220 let visible =
4221 record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry);
4222 prefix.pop();
4223 if visible {
4224 kept_commands.push(command);
4225 }
4226 }
4227 group.commands = kept_commands;
4228
4229 prefix.pop();
4230
4231 if group.commands.is_empty() && group.groups.is_empty() {
4232 None
4233 } else {
4234 Some(group)
4235 }
4236}
4237
4238fn record_and_check_visibility(
4242 effective: Option<&FeatureFlag>,
4243 policy: &FlagPolicy,
4244 prefix: &[String],
4245 registry: &mut FlagRegistry,
4246) -> bool {
4247 let Some(flag) = effective else {
4248 return true;
4249 };
4250 let visible = policy.visible(Some(flag.key.as_str()), flag.stage);
4251 registry.record(FlagEntry {
4252 path: prefix.join(":"),
4253 key: flag.key.clone(),
4254 stage: flag.stage,
4255 visible,
4256 });
4257 visible
4258}
4259
4260fn register_runtime_group_metadata(
4261 group: &RuntimeGroupSpec,
4262 prefix: &mut Vec<String>,
4263 schemas: &mut SchemaRegistry,
4264 views: &mut HumanViewRegistry,
4265) {
4266 prefix.push(group.group.name.clone());
4267 for child_group in &group.groups {
4268 register_runtime_group_metadata(child_group, prefix, schemas, views);
4269 }
4270 for child in &group.commands {
4271 prefix.push(child.spec.name.clone());
4272 let command_path = prefix.join(":");
4273 register_command_schema(&child.spec, &command_path, schemas);
4274 if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
4280 views.register(HumanViewDef::new(
4281 command_path,
4282 child.spec.view_columns.clone(),
4283 ));
4284 }
4285 prefix.pop();
4286 }
4287 prefix.pop();
4288}
4289
4290fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
4291 if let Some(schema) = &spec.output_schema {
4292 schemas.register_info(command_path.to_owned(), schema.clone());
4293 }
4294}
4295
4296fn runtime_group_clap_command_with_schema_help(
4297 group: &RuntimeGroupSpec,
4298 prefix: &mut Vec<String>,
4299 schemas: &SchemaRegistry,
4300) -> Command {
4301 let mut command = group_clap_command_without_children(&group.group);
4302 prefix.push(group.group.name.clone());
4303 for child_group in &group.groups {
4304 command = command.subcommand(runtime_group_clap_command_with_schema_help(
4305 child_group,
4306 prefix,
4307 schemas,
4308 ));
4309 }
4310 for child in &group.commands {
4311 prefix.push(child.spec.name.clone());
4312 let command_path = prefix.join(":");
4313 command = command.subcommand(command_clap_command_with_schema_help(
4314 &child.spec,
4315 &command_path,
4316 schemas,
4317 ));
4318 prefix.pop();
4319 }
4320 prefix.pop();
4321 command
4322}
4323
4324fn group_clap_command_without_children(group: &GroupSpec) -> Command {
4325 let mut command = Command::new(group.name.clone())
4326 .about(group.short.clone())
4327 .help_template(GROUP_HELP_TEMPLATE);
4328 if let Some(long) = &group.long
4329 && !long.is_empty()
4330 {
4331 command = command.long_about(long.clone());
4332 }
4333 for alias in &group.aliases {
4334 command = command.alias(alias.clone());
4335 }
4336 if group.hidden {
4337 command = command.hide(true);
4338 }
4339 command
4340}
4341
4342fn command_clap_command_with_schema_help(
4343 spec: &CommandSpec,
4344 command_path: &str,
4345 schemas: &SchemaRegistry,
4346) -> Command {
4347 debug_assert!(
4348 !(spec.raw_output && spec.pagination.is_some()),
4349 "command {:?} sets both raw_output and with_pagination; a single verbatim string \
4350 has no pages, so the two are mutually exclusive",
4351 spec.name
4352 );
4353 let mut command = spec.clap_command();
4354 command = apply_dry_run_visibility(command, spec);
4355 command = apply_pagination_args(command, spec);
4356 let schema = schemas.get_by_path(command_path);
4357 let default_fields = default_field_names(spec);
4358 command = apply_fields_arg(
4359 command,
4360 spec,
4361 schema.as_ref().map(|schema| schema.fields.as_slice()),
4362 &default_fields,
4363 );
4364 command = apply_output_format_visibility(command, spec);
4365 let filter_expr_fields = schema
4366 .as_ref()
4367 .map_or(&[][..], |schema| schema.fields.as_slice());
4368 apply_filter_and_expr_examples(command, spec, filter_expr_fields)
4369}
4370
4371fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command {
4374 if !spec.raw_output {
4375 return command;
4376 }
4377 use std::io::IsTerminal;
4378 command.arg(
4379 Arg::new("output")
4380 .long("output")
4381 .short('o')
4382 .value_name("FORMAT")
4383 .default_value(if std::io::stdout().is_terminal() {
4384 "human"
4385 } else {
4386 "json"
4387 })
4388 .conflicts_with_all(["json", "toon", "human"])
4389 .display_order(crate::flags::global_flag_order::OUTPUT)
4390 .hide(true)
4391 .help("Ignored — this command always prints raw text"),
4392 )
4393}
4394
4395fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command {
4405 let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating);
4406 if mutates {
4407 return command;
4408 }
4409 command.arg(
4410 Arg::new("dry-run")
4411 .long("dry-run")
4412 .num_args(0..=1)
4413 .require_equals(true)
4414 .default_missing_value("true")
4415 .default_value("false")
4416 .value_parser(crate::flags::compat_bool_value_parser())
4417 .display_order(crate::flags::global_flag_order::DRY_RUN)
4418 .hide(true)
4419 .help("Preview mutations without executing"),
4420 )
4421}
4422
4423fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command {
4428 let Some(pagination) = spec.pagination else {
4429 return command;
4430 };
4431 crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit)
4432}
4433
4434fn default_field_names(spec: &CommandSpec) -> Vec<&str> {
4438 spec.default_fields
4439 .as_deref()
4440 .map(|fields| {
4441 fields
4442 .split(',')
4443 .map(str::trim)
4444 .filter(|field| !field.is_empty() && *field != "all" && *field != "*")
4445 .collect()
4446 })
4447 .unwrap_or_default()
4448}
4449
4450fn apply_fields_arg(
4460 command: Command,
4461 spec: &CommandSpec,
4462 schema_fields: Option<&[FieldInfo]>,
4463 default_fields: &[&str],
4464) -> Command {
4465 if spec.raw_output {
4466 return command.arg(
4467 Arg::new("fields")
4468 .long("fields")
4469 .value_name("FIELDS")
4470 .display_order(crate::flags::global_flag_order::FIELDS)
4471 .hide(true)
4472 .help("Ignored — this command always prints raw text"),
4473 );
4474 }
4475 let default_value = spec
4476 .default_fields
4477 .as_deref()
4478 .filter(|fields| !fields.is_empty());
4479 let table = schema_fields
4480 .filter(|fields| !fields.is_empty())
4481 .map(|fields| format_help_section(fields, default_fields));
4482 if default_value.is_none() && table.is_none() {
4483 return command;
4484 }
4485
4486 let mut help = String::from(
4487 "Comma-separated fields to include in output (use 'all' or '*' for everything)",
4488 );
4489 if let Some(table) = &table {
4490 help.push_str("\n\n");
4491 help.push_str(table.trim_end());
4492 }
4493
4494 let mut arg = Arg::new("fields")
4495 .long("fields")
4496 .value_name("FIELDS")
4497 .display_order(crate::flags::global_flag_order::FIELDS)
4502 .help(help);
4503 if let Some(default_value) = default_value {
4504 arg = arg.default_value(default_value.to_owned());
4505 }
4506 command.arg(arg)
4507}
4508
4509fn apply_filter_and_expr_examples(
4517 mut command: Command,
4518 spec: &CommandSpec,
4519 fields: &[FieldInfo],
4520) -> Command {
4521 if spec.raw_output {
4522 return command
4523 .arg(
4524 Arg::new("filter")
4525 .long("filter")
4526 .value_name("EXPR")
4527 .display_order(crate::flags::global_flag_order::FILTER)
4528 .hide(true)
4529 .help("Ignored — this command always prints raw text"),
4530 )
4531 .arg(
4532 Arg::new("expr")
4533 .long("expr")
4534 .value_name("EXPR")
4535 .display_order(crate::flags::global_flag_order::EXPR)
4536 .hide(true)
4537 .help("Ignored — this command always prints raw text"),
4538 );
4539 }
4540 if fields.is_empty() {
4541 return command;
4542 }
4543 let first_string = fields
4544 .iter()
4545 .find(|field| field.field_type == "string")
4546 .map(|field| field.name.as_str());
4547 let first_bool = fields
4548 .iter()
4549 .find(|field| field.field_type == "bool")
4550 .map(|field| field.name.as_str());
4551
4552 if first_string.is_some() || first_bool.is_some() {
4553 let mut help = String::from("Per-item JMESPath predicate for list data");
4554 if let Some(name) = first_string {
4555 help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\""));
4556 }
4557 if let Some(name) = first_bool {
4558 help.push_str(&format!("\ne.g. --filter '{name}'"));
4559 }
4560 command = command.arg(
4561 Arg::new("filter")
4562 .long("filter")
4563 .value_name("EXPR")
4564 .display_order(crate::flags::global_flag_order::FILTER)
4565 .help(help),
4566 );
4567 }
4568
4569 let mut expr_help = String::from("JMESPath query applied to the whole result");
4570 expr_help.push_str("\ne.g. --expr 'length(@)'");
4571 if let Some(name) = first_string {
4572 expr_help.push_str(&format!("\ne.g. --expr '[].{name}'"));
4573 }
4574 command.arg(
4575 Arg::new("expr")
4576 .long("expr")
4577 .value_name("EXPR")
4578 .display_order(crate::flags::global_flag_order::EXPR)
4579 .help(expr_help),
4580 )
4581}
4582
4583fn process_exit_code(code: i32) -> ExitCode {
4584 if code == 0 {
4585 return ExitCode::SUCCESS;
4586 }
4587 match u8::try_from(code) {
4588 Ok(code) if code != 0 => ExitCode::from(code),
4589 Ok(_) | Err(_) => ExitCode::from(1),
4590 }
4591}
4592
4593async fn run_streaming_command(
4594 middleware: &Middleware,
4595 request: MiddlewareRequest<'_>,
4596 raw_matches: Arc<ArgMatches>,
4597 streaming_handler: crate::command::StreamingCommandHandler,
4598) -> Result<CliRunOutput> {
4599 use tokio::{io::AsyncWriteExt, sync::mpsc};
4600
4601 let args_for_handler = request.args.clone();
4602 let user_args_for_handler = request.user_args.clone();
4603 let handler_path = request.command_path.to_owned();
4604 let middleware_for_handler = middleware.clone();
4605 let raw_matches_for_handler = raw_matches;
4606
4607 let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
4608 let sender = StreamSender(tx);
4609
4610 let writer = tokio::spawn(async move {
4614 let mut stdout = tokio::io::stdout();
4615 while let Some(event) = rx.recv().await {
4616 let Ok(line) = serde_json::to_string(&event) else {
4617 continue;
4618 };
4619 if stdout.write_all(line.as_bytes()).await.is_err()
4620 || stdout.write_all(b"\n").await.is_err()
4621 || stdout.flush().await.is_err()
4622 {
4623 break;
4624 }
4625 }
4626 });
4627
4628 let output = middleware
4629 .run(request, async move |credential| {
4630 streaming_handler(
4631 CommandContext {
4632 credential,
4633 args: args_for_handler,
4634 user_args: user_args_for_handler,
4635 command_path: handler_path,
4636 middleware: middleware_for_handler,
4637 raw_matches: raw_matches_for_handler,
4638 },
4639 sender,
4640 )
4641 .await?;
4642 Ok(crate::CommandResult::new(serde_json::Value::Null))
4643 })
4644 .await;
4645
4646 let _write_result = writer.await;
4649
4650 match output {
4651 Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
4652 exit_code: 0,
4653 rendered: String::new(),
4654 }),
4655 Ok(out) => Ok(out.into()),
4656 Err(err) => Ok(CliRunOutput {
4657 exit_code: exit_code_for_error(&err),
4658 rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
4659 }),
4660 }
4661}
4662
4663#[cfg(test)]
4664mod user_agent_tests {
4665 use super::*;
4666
4667 #[test]
4668 fn user_agent_string_derives_name_and_version_by_default() {
4669 let config =
4670 CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
4671 assert_eq!(config.user_agent_string(), "gdx/1.2.3");
4672 }
4673
4674 #[test]
4675 fn user_agent_string_prefers_explicit_override() {
4676 let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
4677 .with_build(BuildInfo::new("1.2.3"))
4678 .with_user_agent("gdx-cli/9.9 (custom)");
4679 assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
4680 }
4681
4682 #[test]
4683 fn user_agent_string_omits_version_when_absent() {
4684 let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
4685 assert_eq!(config.user_agent_string(), "gdx");
4686 }
4687
4688 #[test]
4689 fn install_default_user_agent_publishes_config_value() {
4690 let _guard = crate::transport::client::UA_TEST_LOCK
4691 .lock()
4692 .unwrap_or_else(std::sync::PoisonError::into_inner);
4693 let _restore = crate::transport::client::RestoreDefaultUserAgent;
4694 crate::transport::set_default_user_agent("cli/dev");
4695 let cli = Cli::new(
4696 CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
4697 );
4698 cli.install_default_user_agent();
4699 assert_eq!(
4700 crate::transport::client::default_user_agent(),
4701 "uatest/4.5.6"
4702 );
4703 }
4704
4705 #[test]
4706 fn install_debug_transport_logger_tracks_the_debug_pattern() {
4707 assert!(debug_transport_logger_for("transport", &[]).enabled());
4715
4716 assert!(!debug_transport_logger_for("*,-transport", &[]).enabled());
4718
4719 assert!(!debug_transport_logger_for("", &[]).enabled());
4721 }
4722}
4723
4724#[cfg(test)]
4725mod env_config_tests {
4726 use super::*;
4727
4728 #[test]
4729 fn with_environments_stores_shared_arc_with_consumer_app_id() {
4730 let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
4734 crate::environments::Environments::new("prod")
4735 .with_app_id("gddy")
4736 .with_config_file(true),
4737 ));
4738 let envs = cfg.environments.as_ref().expect("environments set");
4739 assert!(envs.config_file_path().is_some());
4740 }
4741
4742 #[tokio::test]
4743 async fn env_flag_overrides_default_and_reaches_middleware_env() {
4744 use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
4745 use serde_json::json;
4746 let mut cli = Cli::new(
4747 CliConfig::new("envtest", "Env test", "envtest")
4748 .with_environments(Arc::new(
4749 crate::environments::Environments::new("prod")
4750 .with_environment("prod", crate::environments::EnvTable::new())
4751 .with_environment("ote", crate::environments::EnvTable::new()),
4752 ))
4753 .with_startup_args(Vec::<&str>::new()),
4754 );
4755 cli.add_command(RuntimeCommandSpec::new_with_context(
4756 CommandSpec::new("whichenv", "echo env").no_auth(true),
4757 async |ctx| {
4758 Ok(CommandResult::new(
4759 json!({ "env": ctx.environment()?.name().to_owned() }),
4760 ))
4761 },
4762 ));
4763 let out = cli
4764 .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
4765 .await;
4766 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
4767 assert!(out.rendered.contains("\"env\""));
4768 assert!(out.rendered.contains("ote"));
4769 }
4770
4771 #[tokio::test]
4772 async fn unknown_env_flag_produces_error_envelope() {
4773 let cli = Cli::new(
4774 CliConfig::new("envtest2", "Env test", "envtest2")
4775 .with_environments(Arc::new(
4776 crate::environments::Environments::new("prod")
4777 .with_environment("prod", crate::environments::EnvTable::new()),
4778 ))
4779 .with_startup_args(Vec::<&str>::new()),
4780 );
4781 let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
4782 assert_ne!(out.exit_code, 0);
4783 assert!(out.rendered.contains("nope"));
4784 }
4785}
4786
4787#[cfg(test)]
4788mod prescan_env_flag_tests {
4789 use super::*;
4790
4791 fn argv(args: &[&str]) -> impl Iterator<Item = String> {
4792 args.iter()
4793 .map(|s| s.to_string())
4794 .collect::<Vec<_>>()
4795 .into_iter()
4796 }
4797
4798 #[test]
4799 fn finds_space_separated_value() {
4800 assert_eq!(
4801 prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])),
4802 Some("dev".to_owned())
4803 );
4804 }
4805
4806 #[test]
4807 fn finds_equals_separated_value() {
4808 assert_eq!(
4809 prescan_env_flag(argv(&["--env=dev", "list"])),
4810 Some("dev".to_owned())
4811 );
4812 }
4813
4814 #[test]
4815 fn is_none_without_the_flag() {
4816 assert_eq!(prescan_env_flag(argv(&["env", "list"])), None);
4817 }
4818
4819 #[test]
4820 fn trailing_env_flag_with_no_value_is_none() {
4821 assert_eq!(prescan_env_flag(argv(&["--env"])), None);
4822 }
4823
4824 #[test]
4825 fn keeps_the_last_of_multiple_occurrences() {
4826 assert_eq!(
4830 prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])),
4831 Some("foo".to_owned())
4832 );
4833 }
4834
4835 #[test]
4836 fn ignores_an_empty_equals_value() {
4837 assert_eq!(prescan_env_flag(argv(&["--env="])), None);
4838 }
4839
4840 #[test]
4841 fn empty_occurrence_does_not_clobber_an_earlier_real_value() {
4842 assert_eq!(
4843 prescan_env_flag(argv(&["--env", "dev", "--env="])),
4844 Some("dev".to_owned())
4845 );
4846 }
4847
4848 #[test]
4849 fn space_separated_value_starting_with_dash_is_not_a_value() {
4850 assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None);
4854 }
4855
4856 #[test]
4857 fn equals_form_accepts_a_value_starting_with_dash() {
4858 assert_eq!(
4861 prescan_env_flag(argv(&["--env=-foo"])),
4862 Some("-foo".to_owned())
4863 );
4864 }
4865
4866 #[test]
4867 fn stops_at_the_end_of_options_sentinel() {
4868 assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None);
4871 }
4872
4873 #[test]
4874 fn a_real_flag_before_the_sentinel_is_still_found() {
4875 assert_eq!(
4876 prescan_env_flag(argv(&["--env", "dev", "--", "positional"])),
4877 Some("dev".to_owned())
4878 );
4879 }
4880}
4881
4882#[cfg(test)]
4883mod feature_flag_pruning_tests {
4884 use super::*;
4885 use crate::CommandResult;
4886
4887 fn trivial_command(name: &str) -> RuntimeCommandSpec {
4888 RuntimeCommandSpec::new(
4889 CommandSpec::new(name, "short").no_auth(true),
4890 async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
4891 )
4892 }
4893
4894 fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec {
4895 let mut command = trivial_command(name);
4896 command.spec = command.spec.with_feature_flag(key, stage);
4897 command
4898 }
4899
4900 fn empty_policy() -> FlagPolicy {
4901 FlagPolicy::default()
4902 }
4903
4904 #[test]
4905 fn no_flags_anywhere_keeps_everything() {
4906 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4907 .with_command(trivial_command("a"))
4908 .with_command(trivial_command("b"))
4909 .with_group(
4910 RuntimeGroupSpec::new(GroupSpec::new("child", "short"))
4911 .with_command(trivial_command("c")),
4912 );
4913
4914 let mut prefix = Vec::new();
4915 let mut registry = FlagRegistry::new();
4916 let pruned =
4917 prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4918
4919 let pruned = pruned.expect("unflagged tree should never be dropped");
4920 assert_eq!(pruned.commands.len(), 2);
4921 assert_eq!(pruned.groups.len(), 1);
4922 assert_eq!(pruned.groups[0].commands.len(), 1);
4923 assert!(registry.entries().is_empty());
4924 }
4925
4926 #[test]
4927 fn experimental_command_is_pruned_sibling_is_not() {
4928 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4929 .with_command(flagged_command("gated", "gated-flag", Stage::Experimental))
4930 .with_command(trivial_command("sibling"));
4931
4932 let mut prefix = Vec::new();
4933 let mut registry = FlagRegistry::new();
4934 let pruned =
4935 prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry)
4936 .expect("group still has a visible command left");
4937
4938 assert_eq!(pruned.commands.len(), 1);
4939 assert_eq!(pruned.commands[0].spec.name, "sibling");
4940
4941 let entries = registry.entries();
4942 assert_eq!(entries.len(), 1);
4943 assert_eq!(entries[0].path, "root:gated");
4944 assert_eq!(entries[0].key, "gated-flag");
4945 assert!(!entries[0].visible);
4946 }
4947
4948 #[test]
4949 fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() {
4950 let build_tree = || {
4951 RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4952 .with_command(trivial_command("keep-me"))
4953 .with_group(
4954 RuntimeGroupSpec::new(
4955 GroupSpec::new("flagged-group", "short")
4956 .with_feature_flag("group-flag", Stage::Beta),
4957 )
4958 .with_command(trivial_command("cmd-default"))
4959 .with_command(flagged_command(
4960 "cmd-ga",
4961 "cmd-ga-flag",
4962 Stage::Ga,
4963 )),
4964 )
4965 };
4966
4967 let mut prefix = Vec::new();
4972 let mut registry = FlagRegistry::new();
4973 let pruned = prune_feature_flag_tree(
4974 build_tree(),
4975 None,
4976 &empty_policy(),
4977 &mut prefix,
4978 &mut registry,
4979 )
4980 .expect("root keeps its unflagged sibling command");
4981 assert!(pruned.groups.is_empty());
4982 assert_eq!(pruned.commands.len(), 1);
4983 assert_eq!(pruned.commands[0].spec.name, "keep-me");
4984 assert_eq!(registry.entries().len(), 1);
4986 assert_eq!(registry.entries()[0].path, "root:flagged-group");
4987 assert!(!registry.entries()[0].visible);
4988
4989 let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4991 let mut prefix = Vec::new();
4992 let mut registry = FlagRegistry::new();
4993 let pruned =
4994 prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry)
4995 .expect("root is kept");
4996 assert_eq!(pruned.groups.len(), 1);
4997 assert_eq!(pruned.groups[0].commands.len(), 2);
4998 assert!(registry.entries().iter().all(|entry| entry.visible));
4999 }
5000
5001 #[test]
5002 fn ancestor_invisibility_short_circuits_before_children_are_visited() {
5003 let group = RuntimeGroupSpec::new(
5010 GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta),
5011 )
5012 .with_command(flagged_command("child", "child-flag", Stage::Ga));
5013
5014 let mut prefix = Vec::new();
5015 let mut registry = FlagRegistry::new();
5016 let pruned =
5017 prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
5018
5019 assert!(
5020 pruned.is_none(),
5021 "invisible ancestor drops its whole subtree"
5022 );
5023 assert_eq!(registry.entries().len(), 1);
5025 assert_eq!(registry.entries()[0].path, "ancestor");
5026 assert!(registry.by_key("child-flag").is_empty());
5027 }
5028
5029 #[test]
5030 fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() {
5031 let module_flag = FeatureFlag::new("module-flag", Stage::Beta);
5035 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
5036 .with_command(trivial_command("unflagged-child"));
5037
5038 let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
5039 let mut prefix = Vec::new();
5040 let mut registry = FlagRegistry::new();
5041 let pruned = prune_feature_flag_tree(
5042 group,
5043 Some(&module_flag),
5044 &policy,
5045 &mut prefix,
5046 &mut registry,
5047 )
5048 .expect("Beta-permissive policy keeps a Beta-inherited tree");
5049 assert_eq!(pruned.commands.len(), 1);
5050
5051 let entries = registry.entries();
5055 assert_eq!(entries.len(), 2);
5056 assert_eq!(entries[0].path, "root");
5057 assert_eq!(entries[0].key, "module-flag");
5058 assert_eq!(entries[0].stage, Stage::Beta);
5059 assert_eq!(entries[1].path, "root:unflagged-child");
5060 assert_eq!(entries[1].key, "module-flag");
5061 assert_eq!(entries[1].stage, Stage::Beta);
5062
5063 let mut prefix = Vec::new();
5067 let mut registry = FlagRegistry::new();
5068 let pruned = prune_feature_flag_tree(
5069 RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
5070 .with_command(trivial_command("unflagged-child")),
5071 Some(&module_flag),
5072 &empty_policy(),
5073 &mut prefix,
5074 &mut registry,
5075 );
5076 assert!(pruned.is_none());
5077 }
5078
5079 #[test]
5080 fn registry_records_only_named_flags_not_unflagged_nodes() {
5081 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group(
5082 RuntimeGroupSpec::new(
5083 GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta),
5084 )
5085 .with_command(trivial_command("c1"))
5086 .with_command(flagged_command("c2", "c2-flag", Stage::Ga)),
5087 );
5088
5089 let policy = FlagPolicy::default().with_min_stage(Stage::Experimental);
5091 let mut prefix = Vec::new();
5092 let mut registry = FlagRegistry::new();
5093 let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry)
5094 .expect("permissive policy keeps everything");
5095 assert_eq!(pruned.groups[0].commands.len(), 2);
5096
5097 let entries = registry.entries();
5098 assert_eq!(entries.len(), 3, "root has no flag and is not recorded");
5099 assert_eq!(entries[0].path, "root:g");
5100 assert_eq!(entries[0].key, "g-flag");
5101 assert_eq!(entries[1].path, "root:g:c1");
5102 assert_eq!(entries[1].key, "g-flag");
5103 assert_eq!(entries[1].stage, Stage::Beta);
5104 assert_eq!(entries[2].path, "root:g:c2");
5105 assert_eq!(entries[2].key, "c2-flag");
5106 assert_eq!(entries[2].stage, Stage::Ga);
5107 assert!(entries.iter().all(|entry| entry.visible));
5108 }
5109
5110 #[test]
5111 fn module_feature_flag_cascades_into_its_group_via_add_module() {
5112 let module = Module::new("Test Category", |_ctx| {
5118 RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short"))
5119 .with_command(trivial_command("list"))
5120 })
5121 .with_feature_flag("module-flag", Stage::Experimental);
5122
5123 let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest"));
5124 cli.add_module(module);
5125
5126 assert!(
5127 !cli.commands.contains_key("gated-mod:list"),
5128 "module-level Experimental flag should have pruned the whole group under the default Ga policy"
5129 );
5130 assert!(
5131 !has_subcommand(&cli.root, "gated-mod"),
5132 "the pruned group must not be mounted in the clap tree either"
5133 );
5134 }
5135
5136 #[test]
5137 fn module_feature_flag_keeps_group_when_policy_allows_it() {
5138 let module = Module::new("Test Category", |_ctx| {
5139 RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short"))
5140 .with_command(trivial_command("list"))
5141 })
5142 .with_feature_flag("module-flag-2", Stage::Experimental);
5143
5144 let mut cli = Cli::new(
5145 CliConfig::new("modtest2", "Module test", "modtest2")
5146 .with_min_stage(Stage::Experimental),
5147 );
5148 cli.add_module(module);
5149
5150 assert!(cli.commands.contains_key("gated-mod-2:list"));
5151 assert!(has_subcommand(&cli.root, "gated-mod-2"));
5152 }
5153
5154 #[test]
5155 fn active_environment_min_stage_loosens_consumer_level_policy() {
5156 let module = Module::new("Test Category", |_ctx| {
5161 RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short"))
5162 .with_command(trivial_command("list"))
5163 })
5164 .with_feature_flag("module-flag-3", Stage::Experimental);
5165
5166 let mut cli = Cli::new(
5167 CliConfig::new("modtest3", "Module test", "modtest3")
5168 .with_environments(Arc::new(
5169 crate::environments::Environments::new("prod").with_environment(
5170 "prod",
5171 crate::environments::EnvTable::new().with("min_stage", "experimental"),
5172 ),
5173 ))
5174 .with_startup_args(Vec::<&str>::new()),
5175 );
5176 cli.add_module(module);
5177
5178 assert!(cli.commands.contains_key("gated-mod-3:list"));
5179 assert!(has_subcommand(&cli.root, "gated-mod-3"));
5180 }
5181
5182 #[test]
5191 fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() {
5192 fn gated_module() -> Module {
5193 Module::new("Test Category", |_ctx| {
5194 RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short"))
5195 .with_command(trivial_command("list"))
5196 })
5197 .with_feature_flag("module-flag-4", Stage::Experimental)
5198 }
5199 fn environments() -> Arc<crate::environments::Environments> {
5200 Arc::new(
5201 crate::environments::Environments::new("prod")
5202 .with_environment("prod", crate::environments::EnvTable::new())
5203 .with_environment(
5204 "dev",
5205 crate::environments::EnvTable::new().with("min_stage", "experimental"),
5206 ),
5207 )
5208 }
5209
5210 let mut with_dev_flag = Cli::new(
5211 CliConfig::new("modtest4a", "Module test", "modtest4a")
5212 .with_environments(environments())
5213 .with_startup_args(["modtest4a", "--env", "dev"]),
5214 );
5215 with_dev_flag.add_module(gated_module());
5216 assert!(
5217 with_dev_flag.commands.contains_key("gated-mod-4:list"),
5218 "--env dev in startup_args should reveal the Experimental module"
5219 );
5220 assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4"));
5221
5222 let mut without_flag = Cli::new(
5225 CliConfig::new("modtest4b", "Module test", "modtest4b")
5226 .with_environments(environments())
5227 .with_startup_args(Vec::<&str>::new()),
5228 );
5229 without_flag.add_module(gated_module());
5230 assert!(
5231 !without_flag.commands.contains_key("gated-mod-4:list"),
5232 "without --env, the default env's Ga policy should still prune the module"
5233 );
5234 assert!(!has_subcommand(&without_flag.root, "gated-mod-4"));
5235 }
5236
5237 static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(());
5238
5239 struct GlobalMinStageEnvGuard {
5242 key: &'static str,
5243 prev: Option<std::ffi::OsString>,
5244 }
5245 impl GlobalMinStageEnvGuard {
5246 #[allow(unsafe_code)]
5249 fn set(key: &'static str, value: &str) -> Self {
5250 let prev = std::env::var_os(key);
5251 unsafe { std::env::set_var(key, value) };
5254 Self { key, prev }
5255 }
5256
5257 #[allow(unsafe_code)]
5260 fn unset(key: &'static str) -> Self {
5261 let prev = std::env::var_os(key);
5262 unsafe { std::env::remove_var(key) };
5265 Self { key, prev }
5266 }
5267 }
5268 impl Drop for GlobalMinStageEnvGuard {
5269 #[allow(unsafe_code)]
5270 fn drop(&mut self) {
5271 unsafe {
5274 match &self.prev {
5275 Some(v) => std::env::set_var(self.key, v),
5276 None => std::env::remove_var(self.key),
5277 }
5278 }
5279 }
5280 }
5281
5282 #[test]
5283 #[allow(unsafe_code)]
5284 fn global_min_stage_override_is_a_noop_when_unset() {
5285 let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5286 .lock()
5287 .unwrap_or_else(std::sync::PoisonError::into_inner);
5288 const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE";
5289 let _guard = GlobalMinStageEnvGuard::unset(VAR);
5293
5294 assert_eq!(global_min_stage_override("unset-min-stage-app"), None);
5295 }
5296
5297 #[test]
5298 #[allow(unsafe_code)]
5299 fn global_min_stage_override_parses_a_valid_value() {
5300 let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5301 .lock()
5302 .unwrap_or_else(std::sync::PoisonError::into_inner);
5303 const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE";
5304 let _guard = GlobalMinStageEnvGuard::set(VAR, "beta");
5305
5306 assert_eq!(
5307 global_min_stage_override("valid-min-stage-app"),
5308 Some(Stage::Beta)
5309 );
5310 }
5311
5312 #[test]
5313 #[allow(unsafe_code)]
5314 fn global_min_stage_override_ignores_a_malformed_value() {
5315 let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5316 .lock()
5317 .unwrap_or_else(std::sync::PoisonError::into_inner);
5318 const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE";
5319 let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly");
5320
5321 assert_eq!(global_min_stage_override("bad-min-stage-app"), None);
5322 }
5323}
5324
5325#[cfg(test)]
5326mod flags_command_tests {
5327 use super::*;
5328 use crate::CommandResult;
5329
5330 fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
5334 Module::new("Test Category", move |_ctx| {
5335 RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command(
5336 RuntimeCommandSpec::new(
5337 CommandSpec::new("list", "short").no_auth(true),
5338 async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
5339 ),
5340 )
5341 })
5342 .with_feature_flag(key, stage)
5343 }
5344
5345 #[tokio::test]
5346 async fn flags_list_reports_flagged_entries() {
5347 let mut cli = Cli::new(
5348 CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta),
5349 );
5350 cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta));
5351
5352 let out = cli
5353 .run(["flagtest", "flags", "list", "--output", "json"])
5354 .await;
5355 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5356 let rendered: serde_json::Value =
5357 serde_json::from_str(&out.rendered).expect("stdout should contain json");
5358 let entries = rendered["data"].as_array().expect("data should be array");
5359 let command_entry = entries
5360 .iter()
5361 .find(|entry| entry["path"] == "flagged-mod:list")
5362 .expect("flagged command entry should be present");
5363 assert_eq!(command_entry["key"], "list-flag");
5364 assert_eq!(command_entry["stage"], "beta");
5365 assert_eq!(command_entry["visible"], true);
5366 }
5367
5368 #[tokio::test]
5369 async fn flags_info_returns_policy_and_entries_for_known_key() {
5370 let mut cli = Cli::new(
5371 CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta),
5372 );
5373 cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta));
5374
5375 let out = cli
5376 .run([
5377 "flagtest2",
5378 "flags",
5379 "info",
5380 "info-flag",
5381 "--output",
5382 "json",
5383 ])
5384 .await;
5385 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5386 let rendered: serde_json::Value =
5387 serde_json::from_str(&out.rendered).expect("stdout should contain json");
5388 let data = &rendered["data"];
5389 assert_eq!(data["key"], "info-flag");
5390 assert_eq!(data["policy"]["min_stage"], "beta");
5391 assert!(data["policy"]["override"].is_null());
5392 let entries = data["entries"].as_array().expect("entries should be array");
5393 assert!(!entries.is_empty());
5394 assert!(entries.iter().any(|entry| {
5395 entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage"
5396 }));
5397 }
5398
5399 #[tokio::test]
5400 async fn flags_info_reports_override_decided_by() {
5401 let mut cli = Cli::new(
5406 CliConfig::new("flagtest3", "Flag test", "flagtest3")
5407 .with_feature_override("override-flag", Stage::Ga),
5408 );
5409 cli.add_module(flagged_module(
5410 "flagged-mod-3",
5411 "override-flag",
5412 Stage::Experimental,
5413 ));
5414
5415 let out = cli
5416 .run([
5417 "flagtest3",
5418 "flags",
5419 "info",
5420 "override-flag",
5421 "--output",
5422 "json",
5423 ])
5424 .await;
5425 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5426 let rendered: serde_json::Value =
5427 serde_json::from_str(&out.rendered).expect("stdout should contain json");
5428 let data = &rendered["data"];
5429 assert_eq!(data["policy"]["min_stage"], "ga");
5430 assert_eq!(data["policy"]["override"], "ga");
5431 let entries = data["entries"].as_array().expect("entries should be array");
5432 assert!(!entries.is_empty());
5433 assert!(
5434 entries
5435 .iter()
5436 .all(|entry| entry["decided_by"] == "override")
5437 );
5438 assert!(entries.iter().all(|entry| entry["visible"] == true));
5439 assert!(entries.iter().all(|entry| entry["stage"] == "experimental"));
5440 }
5441
5442 #[tokio::test]
5443 async fn flags_info_unknown_key_errors() {
5444 let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4"));
5445
5446 let out = cli
5447 .run(["flagtest4", "flags", "info", "no-such-flag"])
5448 .await;
5449 assert_ne!(out.exit_code, 0);
5450 assert!(out.rendered.contains("no such flag"));
5451 }
5452}