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 return self.finish_run(self.render_bare_group_discovery(
1940 group,
1941 &command_path,
1942 &middleware,
1943 ));
1944 }
1945 if command_path.is_empty()
1946 && let Some(root_next_actions) = &self.root_next_actions
1947 {
1948 let actions = root_next_actions();
1953 return self.finish_run(self.render_root(&middleware, actions));
1954 }
1955 return self.finish_run(CliRunOutput {
1956 exit_code: if command_path.is_empty() { 0 } else { 1 },
1957 rendered: if command_path.is_empty() {
1958 self.root.clone().render_long_help().to_string()
1959 } else {
1960 format!("unknown command {command_path:?}")
1961 },
1962 });
1963 };
1964
1965 let mut middleware = match self.initialized_middleware() {
1966 Ok(middleware) => middleware,
1967 Err(err) => {
1968 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1969 }
1970 };
1971 apply_global_flags(&mut middleware, &flags, command_timeout);
1972 install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1973 if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1974 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1975 }
1976 if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1979 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1980 }
1981
1982 let leaf = leaf_matches(&matches);
1983 apply_pagination_flags(&mut middleware, &command.spec, leaf);
1984 let args = command_args_from_matches(leaf, &command.spec, false);
1985 let user_args = command_args_from_matches(leaf, &command.spec, true);
1986 let pagination_command = command.spec.pagination.is_some().then(|| {
1987 pagination_command_base(
1988 &self.config.name,
1989 &command_path,
1990 &command.spec,
1991 &user_args,
1992 &flags,
1993 )
1994 });
1995 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1996 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1997 }
1998 let meta = self.resolve_meta(&command_path, command.spec.metadata());
1999 let default_fields = command.spec.default_fields.clone().unwrap_or_default();
2000 let system = command.spec.system.clone().unwrap_or_default();
2001 let view_id = command
2006 .spec
2007 .view_id
2008 .clone()
2009 .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
2010
2011 if let Some(streaming_handler) = command.streaming_handler.clone() {
2012 let result = run_with_timeout(
2013 command_timeout,
2014 &flags.timeout,
2015 run_streaming_command(
2016 &middleware,
2017 MiddlewareRequest {
2018 meta,
2019 command_path: &command_path,
2020 system: &system,
2021 user_args,
2022 args,
2023 default_fields: &default_fields,
2024 view_id: view_id.as_deref(),
2025 auth: command.spec.auth,
2026 raw_output: command.spec.raw_output,
2027 pagination_command,
2028 },
2029 Arc::new(leaf.clone()),
2030 streaming_handler,
2031 ),
2032 )
2033 .await;
2034 return self.finish_run(match result {
2035 Ok(output) => output,
2036 Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
2037 });
2038 }
2039
2040 let handler = command.handler.clone();
2041 let args_for_handler = args.clone();
2042 let user_args_for_handler = user_args.clone();
2043 let handler_path = command_path.clone();
2044 let middleware_for_handler = middleware.clone();
2045 let raw_matches_for_handler = Arc::new(leaf.clone());
2046 let result = run_with_timeout(
2047 command_timeout,
2048 &flags.timeout,
2049 middleware.run(
2050 MiddlewareRequest {
2051 meta,
2052 command_path: &command_path,
2053 system: &system,
2054 user_args,
2055 args,
2056 default_fields: &default_fields,
2057 view_id: view_id.as_deref(),
2058 auth: command.spec.auth,
2059 raw_output: command.spec.raw_output,
2060 pagination_command,
2061 },
2062 async move |credential| {
2063 handler(CommandContext {
2064 credential,
2065 args: args_for_handler,
2066 user_args: user_args_for_handler,
2067 command_path: handler_path,
2068 middleware: middleware_for_handler,
2069 raw_matches: raw_matches_for_handler,
2070 })
2071 .await
2072 },
2073 ),
2074 )
2075 .await;
2076
2077 match result {
2078 Ok(output) => self.finish_run(output.into()),
2079 Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
2080 }
2081 }
2082
2083 fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
2084 if !has_true_schema_flag(args) {
2085 return None;
2086 }
2087 let bool_flags = derive_bool_flags(&self.root);
2088 let value_flags = derive_value_flags(&self.root);
2089 let command_path =
2090 self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
2091 let command = find_command_by_colon_path(&self.root, &command_path)?;
2096 if command.get_subcommands().next().is_some() {
2097 return None;
2098 }
2099 let output_format = extract_output_format(args, &self.resolve_run_output_format());
2100 match self.middleware.schema_registry.get_by_path(&command_path) {
2104 Some(schema) => Some(self.render_schema(schema, &output_format)),
2105 None => Some(self.render_schema(
2106 crate::output::no_schema_response(&command_path),
2107 &output_format,
2108 )),
2109 }
2110 }
2111
2112 fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
2113 let format: crate::output::OutputFormat = match output_format.parse() {
2114 Ok(format) => format,
2115 Err(err) => {
2116 return CliRunOutput {
2117 exit_code: exit_code_for_error(&err),
2118 rendered: err.to_string(),
2119 };
2120 }
2121 };
2122 let envelope =
2123 crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
2124 match crate::output::render(format, &envelope) {
2125 Ok(rendered) => CliRunOutput {
2126 exit_code: 0,
2127 rendered,
2128 },
2129 Err(err) => CliRunOutput {
2130 exit_code: exit_code_for_error(&err),
2131 rendered: err.to_string(),
2132 },
2133 }
2134 }
2135
2136 fn render_bare_group_discovery(
2144 &self,
2145 group: &Command,
2146 command_path: &str,
2147 middleware: &Middleware,
2148 ) -> CliRunOutput {
2149 let format: crate::output::OutputFormat = match middleware.output_format.parse() {
2150 Ok(format) => format,
2151 Err(err) => {
2152 return CliRunOutput {
2153 exit_code: exit_code_for_error(&err),
2154 rendered: err.to_string(),
2155 };
2156 }
2157 };
2158 if format == crate::output::OutputFormat::Human {
2159 return CliRunOutput {
2160 exit_code: 0,
2161 rendered: group.clone().render_long_help().to_string(),
2162 };
2163 }
2164 let path = format!("{} {}", self.config.name, command_path.replace(':', " "));
2165 let tree = crate::tree::build_tree_from_clap_with_path(group, path);
2166 tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format)
2167 }
2168
2169 fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
2170 let format: crate::output::OutputFormat = match output_format.parse() {
2171 Ok(format) => format,
2172 Err(err) => {
2173 return CliRunOutput {
2174 exit_code: exit_code_for_error(&err),
2175 rendered: err.to_string(),
2176 };
2177 }
2178 };
2179 let docs = self.search_documents(scope);
2180 let results = SearchIndex::new(docs).search(query, 10);
2181 let envelope =
2182 crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
2183 match crate::output::render(format, &envelope) {
2184 Ok(rendered) => CliRunOutput {
2185 exit_code: 0,
2186 rendered,
2187 },
2188 Err(err) => CliRunOutput {
2189 exit_code: exit_code_for_error(&err),
2190 rendered: err.to_string(),
2191 },
2192 }
2193 }
2194
2195 fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
2201 if !crate::output::is_valid_output_format(&middleware.output_format) {
2206 let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
2207 return CliRunOutput {
2208 exit_code: exit_code_for_error(&err),
2209 rendered: err.to_string(),
2210 };
2211 }
2212 let format = middleware
2213 .output_format
2214 .parse()
2215 .unwrap_or(crate::output::OutputFormat::Json);
2216 if format == crate::output::OutputFormat::Human {
2217 let base_long = self
2221 .root
2222 .get_long_about()
2223 .map(ToString::to_string)
2224 .unwrap_or_default();
2225 let long = format!("{base_long}{}", render_next_actions_human(&actions));
2226 let rendered = self
2227 .root
2228 .clone()
2229 .long_about(long)
2230 .render_long_help()
2231 .to_string();
2232 return CliRunOutput {
2233 exit_code: 0,
2234 rendered,
2235 };
2236 }
2237 let description = self
2238 .config
2239 .long
2240 .as_deref()
2241 .filter(|long| !long.is_empty())
2242 .unwrap_or(self.config.short.as_str());
2243 let data = serde_json::json!({
2244 "description": description,
2245 "version": self.config.build.version,
2246 });
2247 let envelope = crate::Envelope::success(data, self.config.app_id.clone())
2248 .with_next_actions(actions)
2249 .prepare_for_render(&middleware.verbose);
2250 match crate::output::render(format, &envelope) {
2251 Ok(rendered) => CliRunOutput {
2252 exit_code: 0,
2253 rendered,
2254 },
2255 Err(err) => CliRunOutput {
2256 exit_code: exit_code_for_error(&err),
2257 rendered: err.to_string(),
2258 },
2259 }
2260 }
2261
2262 fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
2263 let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
2264 .unwrap_or((&self.root, Vec::new()));
2265 let mut docs = Vec::new();
2266 let mut aliases = Vec::new();
2267 append_command_alias_terms(scoped, &mut aliases);
2268 collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
2269 if scope.is_empty() {
2270 for entry in &self.guide_entries {
2271 docs.push(SearchDocument {
2272 id: format!("guide:{}", entry.name),
2273 kind: "guide".to_owned(),
2274 title: format!("guide {}", entry.name),
2275 summary: entry.summary.clone(),
2276 content: format!("{} {}", entry.summary, entry.content),
2277 });
2278 }
2279 if let Some(extra_search_docs) = &self.extra_search_docs {
2280 docs.extend(extra_search_docs());
2281 }
2282 }
2283 docs
2284 }
2285
2286 fn resolve_search_scope(&self, scope_path: &str) -> String {
2297 if scope_path.is_empty() {
2298 return String::new();
2299 }
2300 let parts: Vec<String> = scope_path.split(':').map(str::to_owned).collect();
2301 match canonical_path_from_parts(&self.root, &parts) {
2302 Some(scope) => scope,
2303 None => {
2304 warn_unresolvable_search_scope(scope_path);
2305 String::new()
2306 }
2307 }
2308 }
2309
2310 fn canonical_command_path(&self, command_path: &str) -> String {
2311 find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
2312 || command_path.to_owned(),
2313 |(_, canonical)| canonical.join(":"),
2314 )
2315 }
2316
2317 fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput {
2318 use std::io::IsTerminal;
2319
2320 if !crate::output::is_valid_output_format(output_format) {
2324 let err = CliCoreError::InvalidOutputFormat(output_format.to_owned());
2325 return CliRunOutput {
2326 exit_code: exit_code_for_error(&err),
2327 rendered: err.to_string(),
2328 };
2329 }
2330
2331 let leaf = leaf_matches(matches);
2332 let topic = leaf.get_one::<String>("topic").map(String::as_str);
2333 match guide_content(&self.guide_entries, topic) {
2334 Ok(rendered) => {
2335 let rendered = if topic.is_some() && output_format == "human" {
2339 let is_tty = std::io::stdout().is_terminal();
2340 render_guide_human(&rendered, crate::output::terminal_width(), is_tty)
2341 } else {
2342 rendered
2343 };
2344 CliRunOutput {
2345 exit_code: 0,
2346 rendered,
2347 }
2348 }
2349 Err(err) => CliRunOutput {
2350 exit_code: 1,
2351 rendered: err,
2352 },
2353 }
2354 }
2355
2356 fn render_completion_print(
2357 &self,
2358 shell_opt: Option<String>,
2359 middleware: &Middleware,
2360 ) -> CliRunOutput {
2361 use crate::cli::completion::{detect_shell, generate_script, parse_shell};
2362 let shell = match shell_opt {
2363 Some(s) => match parse_shell(&s) {
2364 Ok(s) => s,
2365 Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2366 },
2367 None => match detect_shell() {
2368 Ok(s) => s,
2369 Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2370 },
2371 };
2372 match generate_script(&self.root, &self.config.name, shell) {
2373 Ok(script) => CliRunOutput {
2374 exit_code: 0,
2375 rendered: script,
2376 },
2377 Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
2378 }
2379 }
2380
2381 fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
2382 let leaf = leaf_matches(matches);
2383 let parts = leaf
2384 .get_many::<String>("command")
2385 .map(|values| values.map(String::as_str).collect::<Vec<_>>())
2386 .unwrap_or_default();
2387 self.render_help_for_parts(&parts)
2388 }
2389
2390 fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
2397 if parts.is_empty() {
2398 return CliRunOutput {
2399 exit_code: 0,
2400 rendered: self.root.clone().render_long_help().to_string(),
2401 };
2402 }
2403 let Some(command) = find_help_target(&self.root, parts) else {
2404 return CliRunOutput {
2405 exit_code: 1,
2406 rendered: format!(
2407 "unknown command {:?} — run '{} help' for available commands",
2408 parts.join(" "),
2409 self.config.name
2410 ),
2411 };
2412 };
2413 CliRunOutput {
2414 exit_code: 0,
2415 rendered: command.clone().render_long_help().to_string(),
2416 }
2417 }
2418
2419 fn refresh_root_long(&mut self) {
2420 let builtins = BUILTIN_COMMAND_NAMES;
2425 let categorized: BTreeSet<&str> = self
2426 .module_entries
2427 .iter()
2428 .map(|entry| entry.name.as_str())
2429 .collect();
2430 let mut generic: Vec<ModuleHelpEntry> = self
2431 .root
2432 .get_subcommands()
2433 .filter(|command| !command.is_hide_set())
2434 .filter(|command| !builtins.contains(&command.get_name()))
2435 .filter(|command| !categorized.contains(command.get_name()))
2436 .map(|command| ModuleHelpEntry {
2437 category: "Commands".to_owned(),
2438 name: command.get_name().to_owned(),
2439 short: command
2440 .get_about()
2441 .map(ToString::to_string)
2442 .unwrap_or_default(),
2443 })
2444 .collect();
2445 generic.sort_by(|left, right| left.name.cmp(&right.name));
2446
2447 let mut entries = self.module_entries.clone();
2448 entries.extend(generic);
2449 let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
2450 let intro = self
2451 .config
2452 .long
2453 .as_deref()
2454 .filter(|long| !long.is_empty())
2455 .unwrap_or(self.config.short.as_str());
2456 self.root = self
2457 .root
2458 .clone()
2459 .long_about(build_root_long(intro, &entries, has_guide));
2460 }
2461
2462 fn ensure_auth_command(&mut self) {
2463 let default_provider = self.default_auth_provider();
2464 let registered_names = self.middleware.auth.registered_names();
2465 if default_provider.is_empty() && registered_names.is_empty() {
2466 return;
2467 }
2468 let replacing_builtin = self.commands.contains_key("auth:login");
2469 if has_subcommand(&self.root, "auth") && !replacing_builtin {
2470 return;
2471 }
2472 let mut group = auth_command_group(&default_provider, ®istered_names);
2473 let mut seen_names: std::collections::HashSet<String> =
2474 group.commands.iter().map(|c| c.spec.name.clone()).collect();
2475 for extra in self.config.auth_extra_commands.clone() {
2476 if !seen_names.insert(extra.spec.name.clone()) {
2477 tracing::warn!(
2478 command = %extra.spec.name,
2479 "auth_extra_commands entry collides with a built-in auth subcommand or an \
2480 earlier auth_extra_commands entry; ignoring"
2481 );
2482 continue;
2483 }
2484 group = group.with_command(extra);
2485 }
2486 let mut prefix = Vec::new();
2487 register_runtime_group_metadata(
2488 &group,
2489 &mut prefix,
2490 &mut self.middleware.schema_registry,
2491 &mut self.middleware.human_views,
2492 );
2493 let mut prefix = Vec::new();
2494 group.register_commands(&mut prefix, &mut self.commands);
2495 let mut prefix = Vec::new();
2496 let clap_group = runtime_group_clap_command_with_schema_help(
2497 &group,
2498 &mut prefix,
2499 &self.middleware.schema_registry,
2500 );
2501 self.root = if replacing_builtin {
2502 self.root.clone().mut_subcommand("auth", |_| clap_group)
2503 } else {
2504 self.root.clone().subcommand(clap_group)
2505 };
2506 self.register_auth_help_entry();
2510 }
2511
2512 fn ensure_config_command(&mut self) {
2516 if has_subcommand(&self.root, "config") {
2517 return;
2518 }
2519 let group = crate::config_commands::config_command_group();
2520 let mut prefix = Vec::new();
2521 group.register_commands(&mut prefix, &mut self.commands);
2522 let mut prefix = Vec::new();
2523 let clap_group = runtime_group_clap_command_with_schema_help(
2524 &group,
2525 &mut prefix,
2526 &self.middleware.schema_registry,
2527 );
2528 self.root = self.root.clone().subcommand(clap_group);
2529 let category = self
2530 .config
2531 .admin_category
2532 .clone()
2533 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2534 if !self
2535 .module_entries
2536 .iter()
2537 .any(|entry| entry.name == "config")
2538 {
2539 self.module_entries.push(ModuleHelpEntry {
2540 category,
2541 name: "config".to_owned(),
2542 short: "Read and write the CLI config file".to_owned(),
2543 });
2544 }
2545 self.refresh_root_long();
2546 }
2547
2548 fn ensure_env_command(&mut self) {
2552 if has_subcommand(&self.root, "env") {
2553 return;
2554 }
2555 let group = crate::env_commands::env_command_group();
2556 let mut prefix = Vec::new();
2557 group.register_commands(&mut prefix, &mut self.commands);
2558 let mut prefix = Vec::new();
2559 let clap_group = runtime_group_clap_command_with_schema_help(
2560 &group,
2561 &mut prefix,
2562 &self.middleware.schema_registry,
2563 );
2564 self.root = self.root.clone().subcommand(clap_group);
2565 let category = self
2566 .config
2567 .admin_category
2568 .clone()
2569 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2570 if !self.module_entries.iter().any(|e| e.name == "env") {
2571 self.module_entries.push(ModuleHelpEntry {
2572 category,
2573 name: "env".to_owned(),
2574 short: "Manage the active environment".to_owned(),
2575 });
2576 }
2577 self.refresh_root_long();
2578 }
2579
2580 fn ensure_flags_command(&mut self) {
2586 if has_subcommand(&self.root, "flags") {
2587 return;
2588 }
2589 let group = crate::flag_commands::flags_command_group();
2590 let mut prefix = Vec::new();
2591 group.register_commands(&mut prefix, &mut self.commands);
2592 let mut prefix = Vec::new();
2593 let clap_group = runtime_group_clap_command_with_schema_help(
2594 &group,
2595 &mut prefix,
2596 &self.middleware.schema_registry,
2597 );
2598 self.root = self.root.clone().subcommand(clap_group);
2599 let category = self
2600 .config
2601 .admin_category
2602 .clone()
2603 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2604 if !self.module_entries.iter().any(|e| e.name == "flags") {
2605 self.module_entries.push(ModuleHelpEntry {
2606 category,
2607 name: "flags".to_owned(),
2608 short: "Inspect declared feature flags".to_owned(),
2609 });
2610 }
2611 self.refresh_root_long();
2612 }
2613
2614 fn default_auth_provider(&self) -> String {
2615 if !self.middleware.default_auth_provider.is_empty() {
2616 return self.middleware.default_auth_provider.clone();
2617 }
2618 self.middleware
2619 .auth
2620 .registered_names()
2621 .into_iter()
2622 .next()
2623 .unwrap_or_default()
2624 }
2625
2626 fn initialized_middleware(&self) -> Result<Middleware> {
2627 let Some(init_deps) = &self.init_deps else {
2628 return Ok(self.middleware.clone());
2629 };
2630 let mut guard = self
2631 .init_state
2632 .lock()
2633 .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
2634 if let Some(result) = guard.as_ref() {
2635 return result.clone().map_err(InitFailure::into_error);
2636 }
2637 let mut middleware = self.middleware.clone();
2638 let result = init_deps(&mut middleware)
2639 .map(|()| middleware)
2640 .map_err(|err| InitFailure::capture(&err));
2641 *guard = Some(result.clone());
2642 result.map_err(InitFailure::into_error)
2643 }
2644
2645 fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2646 if let Some(apply_flags) = &self.apply_flags {
2647 apply_flags(matches, middleware)?;
2648 }
2649 Ok(())
2650 }
2651
2652 fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2659 let Some(environments) = middleware.environments.as_ref() else {
2665 return Ok(());
2666 };
2667 if let Some(env) = matches.get_one::<String>("env") {
2668 environments.source(env)?;
2669 middleware.env = env.clone();
2670 }
2671 Ok(())
2672 }
2673
2674 fn run_pre_run(
2675 &self,
2676 middleware: &mut Middleware,
2677 command_path: &str,
2678 args: &crate::middleware::ValueMap,
2679 ) -> Result<()> {
2680 if let Some(pre_run) = &self.pre_run {
2681 pre_run(middleware, command_path, args)?;
2682 }
2683 Ok(())
2684 }
2685
2686 fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
2687 if let Some(resolver) = &self.meta_resolver {
2688 resolver(command_path, meta)
2689 } else {
2690 meta
2691 }
2692 }
2693
2694 fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
2695 crate::config::clear_credential_store_flag();
2698 if let Some(on_shutdown) = &self.on_shutdown {
2699 on_shutdown();
2700 }
2701 output
2702 }
2703}
2704
2705fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
2706 middleware.output_format = flags.output_format.clone();
2707 middleware.verbose = flags.verbose.clone();
2708 middleware.dry_run = flags.dry_run;
2709 middleware.fields = flags.fields.clone();
2710 middleware.filter = flags.filter.clone();
2711 middleware.expr = flags.expr.clone();
2712 middleware.reason = flags.reason.clone();
2713 middleware.schema = flags.schema;
2714 middleware.timeout = timeout;
2715 middleware.debug = flags.debug.clone();
2716 middleware.interactive = flags.interactive;
2717}
2718
2719fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) {
2722 let Some(pagination) = spec.pagination else {
2723 return;
2724 };
2725 middleware.limit = leaf
2726 .get_one::<i64>("limit")
2727 .copied()
2728 .unwrap_or(pagination.default_limit);
2729 middleware.offset = leaf.get_one::<i64>("offset").copied().unwrap_or(0);
2730}
2731
2732fn pagination_command_base(
2759 binary_name: &str,
2760 command_path: &str,
2761 spec: &CommandSpec,
2762 user_args: &crate::middleware::ValueMap,
2763 flags: &GlobalFlags,
2764) -> String {
2765 let mut parts = vec![
2766 quote_pagination_value(binary_name),
2767 command_path.replace(':', " "),
2768 ];
2769 for arg in &spec.args {
2770 let id = arg.get_id().as_str();
2771 if let Some(value) = user_args.get(id) {
2772 push_pagination_arg(&mut parts, arg, value);
2773 }
2774 }
2775 for (flag, value) in [
2776 ("--filter", &flags.filter),
2777 ("--expr", &flags.expr),
2778 ("--fields", &flags.fields),
2779 ] {
2780 if !value.is_empty() {
2781 parts.push(flag.to_owned());
2782 parts.push(quote_pagination_value(value));
2783 }
2784 }
2785 parts.join(" ")
2786}
2787
2788fn push_pagination_arg(parts: &mut Vec<String>, arg: &Arg, value: &serde_json::Value) {
2789 let flag = arg
2790 .get_long()
2791 .map(|long| format!("--{long}"))
2792 .or_else(|| arg.get_short().map(|short| format!("-{short}")));
2793 match value {
2794 serde_json::Value::Bool(enabled) => {
2795 if matches!(
2796 arg.get_action(),
2797 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
2798 ) {
2799 if let Some(flag) = flag {
2805 parts.push(flag);
2806 }
2807 } else {
2808 push_flagged_value(parts, flag, &enabled.to_string());
2812 }
2813 }
2814 serde_json::Value::Array(items) => {
2815 for item in items {
2824 push_flagged_value(parts, flag.clone(), &pagination_arg_display(item));
2825 }
2826 }
2827 serde_json::Value::Null => {}
2828 other => push_flagged_value(parts, flag, &pagination_arg_display(other)),
2829 }
2830}
2831
2832fn push_flagged_value(parts: &mut Vec<String>, flag: Option<String>, value: &str) {
2833 if let Some(flag) = flag {
2834 parts.push(flag);
2835 }
2836 parts.push(quote_pagination_value(value));
2837}
2838
2839fn pagination_arg_display(value: &serde_json::Value) -> String {
2840 match value {
2841 serde_json::Value::String(text) => text.clone(),
2842 other => other.to_string(),
2843 }
2844}
2845
2846fn quote_pagination_value(value: &str) -> String {
2855 let safe_unquoted =
2856 |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@');
2857 if value.is_empty() || !value.chars().all(safe_unquoted) {
2858 let escaped = value
2859 .replace('\\', "\\\\")
2860 .replace('"', "\\\"")
2861 .replace('$', "\\$")
2862 .replace('`', "\\`");
2863 format!("\"{escaped}\"")
2864 } else {
2865 value.to_owned()
2866 }
2867}
2868
2869fn debug_transport_logger_for(
2878 debug: &str,
2879 extra_redacted: &[String],
2880) -> Arc<dyn crate::transport::TransportLogger> {
2881 if crate::debug_component_enabled(debug, "transport") {
2882 Arc::new(
2883 crate::transport::StderrTransportLogger::new()
2884 .with_redacted_headers(extra_redacted.iter().cloned()),
2885 )
2886 } else {
2887 Arc::new(crate::transport::NoopTransportLogger)
2888 }
2889}
2890
2891fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2902 crate::transport::set_default_transport_logger(debug_transport_logger_for(
2903 debug,
2904 extra_redacted,
2905 ));
2906}
2907
2908async fn run_with_timeout<F, T>(
2909 timeout: Option<Duration>,
2910 timeout_label: &str,
2911 future: F,
2912) -> Result<T>
2913where
2914 F: Future<Output = Result<T>>,
2915{
2916 let Some(timeout) = timeout else {
2917 return future.await;
2918 };
2919 match tokio::time::timeout(timeout, future).await {
2920 Ok(result) => result,
2921 Err(_) => Err(CliCoreError::message(format!(
2922 "command timed out after {timeout_label}"
2923 ))),
2924 }
2925}
2926
2927async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2928where
2929 Run: Future<Output = CliRunOutput>,
2930 Shutdown: Future<Output = ()>,
2931{
2932 tokio::pin!(run);
2933 tokio::pin!(shutdown);
2934 tokio::select! {
2935 output = &mut run => output,
2936 () = &mut shutdown => CliRunOutput {
2937 exit_code: 130,
2938 rendered: "command interrupted\n".to_owned(),
2939 },
2940 }
2941}
2942
2943#[cfg(unix)]
2944async fn shutdown_signal() {
2945 let ctrl_c = tokio::signal::ctrl_c();
2946 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2947 Ok(mut sigterm) => {
2948 tokio::select! {
2949 _ = ctrl_c => {},
2950 _ = sigterm.recv() => {},
2951 }
2952 }
2953 Err(_) => {
2954 drop(ctrl_c.await);
2955 }
2956 }
2957}
2958
2959#[cfg(not(unix))]
2960async fn shutdown_signal() {
2961 drop(tokio::signal::ctrl_c().await);
2962}
2963
2964fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2965 let raw = raw.trim();
2966 if raw.is_empty() {
2967 return Ok(Some(Duration::from_secs(60)));
2968 }
2969 let Some(seconds) = parse_duration_seconds(raw) else {
2970 return Err(CliCoreError::message(format!(
2971 "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2972 )));
2973 };
2974 if seconds <= 0.0 {
2975 Ok(None)
2976 } else {
2977 Ok(Some(Duration::from_secs_f64(seconds)))
2978 }
2979}
2980
2981fn parse_duration_seconds(raw: &str) -> Option<f64> {
2982 for (suffix, seconds) in [
2983 ("ns", 0.000_000_001_f64),
2984 ("us", 0.000_001_f64),
2985 ("µs", 0.000_001_f64),
2986 ("ms", 0.001_f64),
2987 ("s", 1.0_f64),
2988 ("m", 60.0_f64),
2989 ("h", 3600.0_f64),
2990 ] {
2991 if let Some(number) = raw.strip_suffix(suffix) {
2992 let value = number.parse::<f64>().ok()?;
2993 if !value.is_finite() {
2994 return None;
2995 }
2996 return Some(value * seconds);
2997 }
2998 }
2999 None
3000}
3001
3002fn global_min_stage_override(app_id: &str) -> Option<Stage> {
3009 let var = min_stage_env_var(app_id);
3010 let value = std::env::var(&var).ok()?;
3011 value.parse::<Stage>().map_or_else(
3012 |err| {
3013 tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override");
3014 None
3015 },
3016 Some,
3017 )
3018}
3019
3020fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option<String> {
3041 let mut result = None;
3042 while let Some(arg) = args.next() {
3043 if arg == "--" {
3048 break;
3049 }
3050 let value = if let Some(v) = arg.strip_prefix("--env=") {
3051 Some(v.to_owned())
3052 } else if arg == "--env" {
3053 args.next().filter(|v| !v.starts_with('-'))
3060 } else {
3061 None
3062 };
3063 if let Some(v) = value.filter(|v| !v.is_empty()) {
3064 result = Some(v);
3065 }
3066 }
3067 result
3068}
3069
3070fn render_cli_error(
3071 middleware: &Middleware,
3072 err: &(dyn std::error::Error + 'static),
3073 system: &str,
3074) -> CliRunOutput {
3075 let format = middleware
3076 .output_format
3077 .parse::<crate::output::OutputFormat>()
3078 .unwrap_or(crate::output::OutputFormat::Json);
3079 let envelope =
3080 crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
3081 match crate::output::render(format, &envelope) {
3082 Ok(rendered) => CliRunOutput {
3083 exit_code: exit_code_for_error(err),
3084 rendered,
3085 },
3086 Err(render_err) => CliRunOutput {
3087 exit_code: exit_code_for_error(err),
3088 rendered: render_err.to_string(),
3089 },
3090 }
3091}
3092
3093fn find_command_by_colon_path<'command>(
3094 root: &'command Command,
3095 path: &str,
3096) -> Option<&'command Command> {
3097 find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
3098}
3099
3100fn find_help_target<'command>(
3101 root: &'command Command,
3102 parts: &[&str],
3103) -> Option<&'command Command> {
3104 let mut current = root;
3105 let mut matched_any = false;
3106 for part in parts {
3107 let Some(next) = current.find_subcommand(part) else {
3108 break;
3109 };
3110 current = next;
3111 matched_any = true;
3112 }
3113 matched_any.then_some(current)
3114}
3115
3116fn find_command_and_canonical_path_by_colon_path<'command>(
3117 root: &'command Command,
3118 path: &str,
3119) -> Option<(&'command Command, Vec<String>)> {
3120 if path.is_empty() {
3121 return Some((root, Vec::new()));
3122 }
3123 let mut current = root;
3124 let mut canonical = Vec::new();
3125 for part in path.split(':') {
3126 current = current.find_subcommand(part)?;
3127 canonical.push(current.get_name().to_owned());
3128 }
3129 Some((current, canonical))
3130}
3131
3132fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
3133 if parts.is_empty() {
3134 return Some(String::new());
3135 }
3136 let mut current = root;
3137 let mut canonical = Vec::new();
3138 for part in parts {
3139 current = current.find_subcommand(part)?;
3140 canonical.push(current.get_name().to_owned());
3141 }
3142 Some(canonical.join(":"))
3143}
3144
3145fn warn_unresolvable_search_scope(scope_path: &str) {
3154 let mut stderr = std::io::stderr().lock();
3155 stderr
3156 .write_all(
3157 format!(
3158 "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n"
3159 )
3160 .as_bytes(),
3161 )
3162 .ok();
3163}
3164
3165fn collect_command_search_documents(
3166 command: &Command,
3167 prefix: &mut Vec<String>,
3168 aliases: &mut Vec<String>,
3169 docs: &mut Vec<SearchDocument>,
3170) {
3171 if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
3172 return;
3173 }
3174 if command.get_subcommands().next().is_some() {
3175 for child in command.get_subcommands() {
3176 prefix.push(child.get_name().to_owned());
3177 let alias_len = aliases.len();
3178 append_command_alias_terms(child, aliases);
3179 collect_command_search_documents(child, prefix, aliases, docs);
3180 aliases.truncate(alias_len);
3181 prefix.pop();
3182 }
3183 return;
3184 }
3185 if prefix.is_empty() {
3186 prefix.push(command.get_name().to_owned());
3187 append_command_alias_terms(command, aliases);
3188 }
3189 let path = prefix.join(" ");
3190 let alias_text = aliases.join(" ");
3191 docs.push(SearchDocument {
3192 id: format!("cmd:{path}"),
3193 kind: "command".to_owned(),
3194 title: path,
3195 summary: command
3196 .get_about()
3197 .map(ToString::to_string)
3198 .unwrap_or_default(),
3199 content: format!(
3200 "{} {} {} {}",
3201 command
3202 .get_about()
3203 .map(ToString::to_string)
3204 .unwrap_or_default(),
3205 command
3206 .get_long_about()
3207 .map(ToString::to_string)
3208 .unwrap_or_default(),
3209 command_flag_text(command),
3210 alias_text
3211 ),
3212 });
3213 if prefix.len() == 1 && prefix[0] == command.get_name() {
3214 prefix.pop();
3215 }
3216}
3217
3218fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
3219 aliases.extend(command.get_all_aliases().map(str::to_owned));
3220 aliases.extend(
3221 command
3222 .get_all_short_flag_aliases()
3223 .map(|alias| alias.to_string()),
3224 );
3225 aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
3226}
3227
3228fn command_flag_text(command: &Command) -> String {
3229 command
3230 .get_arguments()
3231 .filter(|arg| !arg.is_hide_set())
3232 .filter_map(|arg| {
3233 let mut names = Vec::new();
3234 if let Some(short) = arg.get_short() {
3235 names.push(format!("-{short}"));
3236 }
3237 if let Some(long) = arg.get_long() {
3238 names.push(format!("--{long}"));
3239 }
3240 if let Some(short_aliases) = arg.get_all_short_aliases() {
3241 names.extend(
3242 short_aliases
3243 .into_iter()
3244 .map(|short_alias| format!("-{short_alias}")),
3245 );
3246 }
3247 if let Some(aliases) = arg.get_all_aliases() {
3248 names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
3249 }
3250 (!names.is_empty()).then(|| names.join(" "))
3251 })
3252 .collect::<Vec<_>>()
3253 .join(" ")
3254}
3255
3256fn has_subcommand(command: &Command, name: &str) -> bool {
3257 command
3258 .get_subcommands()
3259 .any(|child| child.get_name() == name)
3260}
3261
3262fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
3263 let bool_flags = derive_bool_flags(root);
3264 let value_flags = derive_value_flags(root);
3265 let mut iter = args.iter().peekable();
3266 if iter
3267 .peek()
3268 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3269 {
3270 iter.next();
3271 }
3272
3273 while let Some(arg) = iter.next() {
3274 match arg.as_str() {
3275 "--version" | "-v" => return true,
3276 "--" => return false,
3277 value if value.contains('=') || bool_flags.contains(value) => continue,
3278 value
3279 if value_flags.contains(value)
3280 || unknown_flag_consumes_value(value, iter.peek()) =>
3281 {
3282 iter.next();
3283 }
3284 value if value.starts_with('-') => {}
3285 _ => return false,
3286 }
3287 }
3288 false
3289}
3290
3291fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
3292 let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
3293 let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
3294 let mut normalized = Vec::with_capacity(args.len());
3295 let mut index = 0;
3296 let mut current = root;
3297 while index < args.len() {
3298 let arg = &args[index];
3299 if index == 0 && arg_matches_root_name(arg, root.get_name()) {
3300 normalized.push(arg.clone());
3301 index += 1;
3302 continue;
3303 }
3304
3305 if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
3306 normalized.push(format!("{arg}={default}"));
3307 index += 1;
3308 continue;
3309 }
3310
3311 if let Some(default) = optional_string_defaults.get(arg.as_str()) {
3312 match args.get(index + 1) {
3313 None => {
3314 normalized.push(format!("{arg}={default}"));
3315 index += 1;
3316 continue;
3317 }
3318 Some(next)
3319 if current.get_name() == root.get_name()
3320 || next.starts_with('-')
3321 || direct_subcommand(current, next).is_some() =>
3322 {
3323 normalized.push(format!("{arg}={default}"));
3324 index += 1;
3325 continue;
3326 }
3327 Some(next) => {
3328 normalized.push(arg.clone());
3329 normalized.push(next.clone());
3330 index += 2;
3331 continue;
3332 }
3333 }
3334 }
3335
3336 normalized.push(arg.clone());
3337 if !arg.starts_with('-')
3338 && let Some(next_command) = direct_subcommand(current, arg)
3339 {
3340 current = next_command;
3341 }
3342 index += 1;
3343 }
3344 normalized
3345}
3346
3347fn direct_subcommand<'command>(
3348 command: &'command Command,
3349 token: &str,
3350) -> Option<&'command Command> {
3351 command.get_subcommands().find(|child| {
3352 child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
3353 })
3354}
3355
3356fn format_did_you_mean(base: &str, suggestion: &str) -> String {
3358 format!("{base} — did you mean {suggestion:?}?")
3359}
3360
3361struct UnknownGroupCommand {
3363 base: String,
3364}
3365
3366fn detect_unknown_group_command(
3369 root: &Command,
3370 positionals: &[String],
3371) -> Option<UnknownGroupCommand> {
3372 if positionals.is_empty() {
3373 return None;
3374 }
3375
3376 let mut current = root;
3377 let mut path = vec![root.get_name().to_owned()];
3378 for token in positionals {
3379 if let Some(next) = current.find_subcommand(token) {
3380 current = next;
3381 path.push(next.get_name().to_owned());
3382 continue;
3383 }
3384 if current.get_subcommands().next().is_some() {
3385 let base = format!("unknown command {token:?} for {:?}", path.join(" "));
3386 return Some(UnknownGroupCommand { base });
3387 }
3388 return None;
3389 }
3390 None
3391}
3392
3393fn command_keyword_count(
3395 args: &[String],
3396 root_name: &str,
3397 bool_flags: &BTreeSet<String>,
3398 value_flags: &BTreeSet<String>,
3399) -> usize {
3400 let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags);
3401 match args.iter().position(|arg| arg == "--") {
3402 Some(end) => {
3403 positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len()
3404 }
3405 None => positionals.len(),
3406 }
3407}
3408
3409fn rewrite_group_help_if_needed(
3412 root: &Command,
3413 clap_args: &[String],
3414 root_name: &str,
3415 bool_flags: &BTreeSet<String>,
3416 value_flags: &BTreeSet<String>,
3417) -> Vec<String> {
3418 let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags);
3419 let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags);
3420 let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else {
3421 return clap_args.to_vec();
3422 };
3423 rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts)
3424}
3425
3426fn replace_positional_command_token(
3429 args: &[String],
3430 root_name: &str,
3431 bool_flags: &BTreeSet<String>,
3432 value_flags: &BTreeSet<String>,
3433 target: usize,
3434 replacement: &str,
3435) -> Vec<String> {
3436 let mut out = args.to_vec();
3437 let mut index = 0;
3438 if out
3439 .first()
3440 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3441 {
3442 index = 1;
3443 }
3444
3445 let mut positional = 0;
3446 while index < out.len() {
3447 let arg = &out[index];
3448 if arg == "--" {
3449 break;
3450 }
3451 if arg.contains('=') {
3452 index += 1;
3453 continue;
3454 }
3455 if bool_flags.contains(arg) {
3456 index += 1;
3457 continue;
3458 }
3459 if value_flags.contains(arg)
3460 || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref())
3461 {
3462 index += 2;
3463 continue;
3464 }
3465 if arg.starts_with('-') {
3466 index += 1;
3467 continue;
3468 }
3469 if positional == target {
3470 out[index] = replacement.to_owned();
3471 break;
3472 }
3473 positional += 1;
3474 index += 1;
3475 }
3476 out
3477}
3478
3479fn nearest_subcommand(command: &Command, token: &str) -> Option<String> {
3482 let token = token.to_ascii_lowercase();
3483 let max_distance = 1.max(token.chars().count() / 3);
3484
3485 command
3486 .get_subcommands()
3487 .filter(|child| !child.is_hide_set())
3488 .filter_map(|child| {
3489 let best = std::iter::once(child.get_name())
3490 .chain(child.get_all_aliases())
3491 .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase()))
3492 .min()?;
3493 (best <= max_distance).then(|| (best, child.get_name().to_owned()))
3494 })
3495 .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)))
3496 .map(|(_, name)| name)
3497}
3498
3499fn full_command_correction(root: &Command, positionals: &[String]) -> Option<Vec<(usize, String)>> {
3503 let mut current = root;
3504 let mut corrections = Vec::new();
3505 for (index, token) in positionals.iter().enumerate() {
3506 if let Some(next) = current.find_subcommand(token) {
3507 current = next;
3508 continue;
3509 }
3510 if current.get_subcommands().next().is_none() {
3511 break;
3512 }
3513 if token == "help" && current.find_subcommand("help").is_none() {
3514 break;
3515 }
3516 let suggestion = nearest_subcommand(current, token)?;
3517 let next = current.find_subcommand(&suggestion)?;
3518 corrections.push((index, suggestion));
3519 current = next;
3520 }
3521 (!corrections.is_empty()).then_some(corrections)
3522}
3523
3524fn correction_display(
3527 root_name: &str,
3528 positionals: &[String],
3529 corrections: &[(usize, String)],
3530) -> String {
3531 if let [(index, only)] = corrections
3532 && *index + 1 == positionals.len()
3533 {
3534 return only.clone();
3535 }
3536 let mut tokens = vec![root_name.to_owned()];
3537 for (index, token) in positionals.iter().enumerate() {
3538 let corrected = corrections
3539 .iter()
3540 .find(|(i, _)| *i == index)
3541 .map(|(_, replacement)| replacement.clone())
3542 .unwrap_or_else(|| token.clone());
3543 tokens.push(corrected);
3544 }
3545 tokens.join(" ")
3546}
3547
3548#[cfg(test)]
3549mod unknown_command_suggestion_tests {
3550 use super::*;
3551
3552 fn sample_group() -> Command {
3553 Command::new("gddy").subcommand(
3554 Command::new("domain")
3555 .alias("dns-domain")
3556 .subcommand(Command::new("list"))
3557 .subcommand(Command::new("available")),
3558 )
3559 }
3560
3561 #[test]
3562 fn osa_distance_treats_adjacent_transposition_as_one_edit() {
3563 assert_eq!(strsim::osa_distance("domain", "domain"), 0);
3565 assert_eq!(strsim::osa_distance("domian", "domain"), 1);
3566 assert_eq!(strsim::osa_distance("lst", "list"), 1);
3567 assert_eq!(strsim::osa_distance("lsit", "list"), 1);
3568 assert_eq!(strsim::osa_distance("cat", "set"), 2);
3569 }
3570
3571 #[test]
3572 fn nearest_subcommand_matches_close_typos() {
3573 let root = sample_group();
3574 let domain = root.find_subcommand("domain").expect("domain registered");
3575 assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list"));
3576 assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list"));
3577 assert_eq!(
3578 nearest_subcommand(domain, "avaliable").as_deref(),
3579 Some("available")
3580 );
3581 }
3582
3583 #[test]
3584 fn nearest_subcommand_rejects_unrelated_tokens() {
3585 let root = sample_group();
3586 let domain = root.find_subcommand("domain").expect("domain registered");
3587 assert_eq!(nearest_subcommand(domain, "missing"), None);
3588 }
3589
3590 #[test]
3591 fn nearest_subcommand_returns_canonical_name_for_alias_typos() {
3592 let root = sample_group();
3593 assert_eq!(
3594 nearest_subcommand(&root, "dns-domian").as_deref(),
3595 Some("domain")
3596 );
3597 }
3598
3599 #[test]
3600 fn nearest_subcommand_skips_hidden_commands() {
3601 let root = Command::new("gddy")
3602 .subcommand(Command::new("visible"))
3603 .subcommand(Command::new("hiddeen").hide(true));
3604 assert_eq!(nearest_subcommand(&root, "hidden"), None);
3605 }
3606
3607 #[test]
3608 fn nearest_subcommand_rejects_short_unrelated_tokens() {
3609 let root = Command::new("gddy").subcommand(
3610 Command::new("config")
3611 .subcommand(Command::new("get"))
3612 .subcommand(Command::new("set"))
3613 .subcommand(Command::new("add")),
3614 );
3615 let config = root.find_subcommand("config").expect("config registered");
3616 assert_eq!(nearest_subcommand(config, "cat"), None);
3617 assert_eq!(nearest_subcommand(config, "x"), None);
3618 assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set"));
3619 }
3620
3621 #[test]
3622 fn unknown_group_command_formats_did_you_mean_suffix() {
3623 let root = sample_group();
3624 let unknown = detect_unknown_group_command(&root, &["domian".to_owned()])
3625 .expect("domian is an unknown top-level command");
3626 assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\"");
3627 assert_eq!(
3628 format_did_you_mean(&unknown.base, "domain"),
3629 "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?"
3630 );
3631 }
3632
3633 #[test]
3634 fn detect_unknown_group_command_reports_nested_typos() {
3635 let root = sample_group();
3636 let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()])
3637 .expect("lst is an unknown subcommand of domain");
3638 assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\"");
3639 assert_eq!(
3640 format_did_you_mean(&unknown.base, "list"),
3641 "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?"
3642 );
3643 }
3644
3645 #[test]
3646 fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() {
3647 let root = sample_group();
3648 let unknown = detect_unknown_group_command(&root, &["missing".to_owned()])
3649 .expect("missing is an unknown top-level command");
3650 assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\"");
3651 }
3652
3653 #[test]
3654 fn full_command_correction_fixes_a_single_group_typo() {
3655 let root = sample_group();
3656 let corrections = full_command_correction(&root, &["domian".to_owned()])
3657 .expect("domian is correctable to domain");
3658 assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3659 }
3660
3661 #[test]
3662 fn full_command_correction_fixes_every_typo_in_a_nested_path() {
3663 let root = sample_group();
3664 let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()])
3665 .expect("both tokens are correctable");
3666 assert_eq!(
3667 corrections,
3668 vec![(0, "domain".to_owned()), (1, "list".to_owned())]
3669 );
3670 }
3671
3672 #[test]
3673 fn full_command_correction_bails_when_a_token_has_no_near_match() {
3674 let root = sample_group();
3675 assert_eq!(
3676 full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]),
3677 None
3678 );
3679 }
3680
3681 #[test]
3682 fn full_command_correction_is_none_when_there_is_nothing_to_correct() {
3683 let root = sample_group();
3684 assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None);
3685 assert_eq!(full_command_correction(&root, &[]), None);
3686 }
3687
3688 #[test]
3689 fn full_command_correction_corrects_the_group_before_curated_help() {
3690 let root = sample_group();
3691 let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()])
3692 .expect("domian is correctable even ahead of a help token");
3693 assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3694 }
3695
3696 #[test]
3697 fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() {
3698 let root = sample_group();
3699 let corrections = full_command_correction(
3700 &root,
3701 &[
3702 "domain".to_owned(),
3703 "avaliable".to_owned(),
3704 "example.com".to_owned(),
3705 ],
3706 )
3707 .expect("avaliable is correctable to available");
3708 assert_eq!(corrections, vec![(1, "available".to_owned())]);
3709 }
3710
3711 #[test]
3712 fn correction_display_shows_the_bare_token_for_a_single_fix() {
3713 let corrections = vec![(1, "list".to_owned())];
3714 assert_eq!(
3715 correction_display(
3716 "gddy",
3717 &["domain".to_owned(), "lst".to_owned()],
3718 &corrections
3719 ),
3720 "list"
3721 );
3722 }
3723
3724 #[test]
3725 fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() {
3726 let corrections = vec![(0, "domain".to_owned())];
3727 assert_eq!(
3728 correction_display(
3729 "gddy",
3730 &["domian".to_owned(), "list".to_owned()],
3731 &corrections
3732 ),
3733 "gddy domain list"
3734 );
3735 }
3736
3737 #[test]
3738 fn correction_display_shows_the_full_command_for_multiple_fixes() {
3739 let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())];
3740 assert_eq!(
3741 correction_display(
3742 "gddy",
3743 &["domian".to_owned(), "lst".to_owned()],
3744 &corrections
3745 ),
3746 "gddy domain list"
3747 );
3748 }
3749
3750 #[test]
3751 fn replace_positional_command_token_rewrites_only_the_target() {
3752 let bool_flags: BTreeSet<String> = ["--verbose".to_owned()].into_iter().collect();
3753 let value_flags: BTreeSet<String> = ["--output".to_owned()].into_iter().collect();
3754 let args = vec![
3755 "gddy".to_owned(),
3756 "--output".to_owned(),
3757 "json".to_owned(),
3758 "domain".to_owned(),
3759 "lst".to_owned(),
3760 ];
3761 let corrected =
3762 replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list");
3763 assert_eq!(
3764 corrected,
3765 vec!["gddy", "--output", "json", "domain", "list"]
3766 );
3767 }
3768
3769 #[test]
3770 fn rewrite_group_help_if_needed_runs_after_typo_correction() {
3771 let root = sample_group();
3772 let bool_flags = derive_bool_flags(&root);
3773 let value_flags = derive_value_flags(&root);
3774 let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()];
3775 let corrected =
3776 replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain");
3777 assert_eq!(corrected, vec!["gddy", "domain", "help"]);
3778 let rewritten =
3779 rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags);
3780 assert_eq!(rewritten, vec!["gddy", "help", "domain"]);
3781 }
3782}
3783
3784fn group_help_target_parts(
3807 root: &Command,
3808 positionals: &[String],
3809 command_keyword_count: usize,
3810) -> Option<Vec<String>> {
3811 let help_index = positionals.iter().position(|token| token == "help")?;
3812 if help_index == 0 {
3814 return None;
3815 }
3816 if help_index >= command_keyword_count {
3818 return None;
3819 }
3820 let prefix = &positionals[..help_index];
3821 let mut current = root;
3822 for token in prefix {
3823 current = current.find_subcommand(token)?;
3824 }
3825 current.get_subcommands().next()?;
3827 if current.find_subcommand("help").is_some() {
3829 return None;
3830 }
3831 let suffix = &positionals[help_index + 1..];
3833 Some(prefix.iter().chain(suffix).cloned().collect())
3834}
3835
3836fn rewrite_group_help_args(
3847 clap_args: &[String],
3848 root_name: &str,
3849 bool_flags: &BTreeSet<String>,
3850 value_flags: &BTreeSet<String>,
3851 parts: &[String],
3852) -> Vec<String> {
3853 let mut next_positional = std::iter::once("help".to_owned())
3855 .chain(parts.iter().cloned())
3856 .peekable();
3857 let mut out = Vec::with_capacity(clap_args.len());
3858 let mut iter = clap_args.iter().peekable();
3859 if iter
3860 .peek()
3861 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3862 && let Some(program) = iter.next()
3863 {
3864 out.push(program.clone());
3865 }
3866
3867 let mut take_positional =
3868 |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
3869
3870 while let Some(arg) = iter.next() {
3871 if arg == "--" {
3872 out.push(arg.clone());
3873 for rest in iter.by_ref() {
3875 out.push(take_positional(rest));
3876 }
3877 break;
3878 }
3879 if arg.contains('=') || bool_flags.contains(arg) {
3880 out.push(arg.clone());
3881 continue;
3882 }
3883 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3884 out.push(arg.clone());
3885 if let Some(value) = iter.next() {
3886 out.push(value.clone());
3887 }
3888 continue;
3889 }
3890 if arg.starts_with('-') {
3891 out.push(arg.clone());
3892 continue;
3893 }
3894 out.push(take_positional(arg));
3895 }
3896 out.extend(next_positional);
3898 out
3899}
3900
3901fn positional_command_tokens(
3902 args: &[String],
3903 root_name: &str,
3904 bool_flags: &BTreeSet<String>,
3905 value_flags: &BTreeSet<String>,
3906) -> Vec<String> {
3907 let mut tokens = Vec::new();
3908 let mut iter = args.iter().peekable();
3909 if iter
3910 .peek()
3911 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3912 {
3913 iter.next();
3914 }
3915
3916 while let Some(arg) = iter.next() {
3917 if arg == "--" {
3918 tokens.extend(iter.cloned());
3919 break;
3920 }
3921 if arg.contains('=') {
3922 continue;
3923 }
3924 if bool_flags.contains(arg) {
3925 continue;
3926 }
3927 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3928 iter.next();
3929 continue;
3930 }
3931 if arg.starts_with('-') {
3932 continue;
3933 }
3934 tokens.push(arg.clone());
3935 }
3936 tokens
3937}
3938
3939fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
3940 arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
3941}
3942
3943fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
3944 arg == root_name
3945 || Path::new(arg)
3946 .file_stem()
3947 .and_then(|n| n.to_str())
3948 .is_some_and(|n| n == root_name)
3949}
3950
3951enum Argv0Outcome {
3954 Proceed(Vec<String>),
3956 Handled(CliRunOutput),
3958}
3959
3960fn program_basename(arg: &str) -> String {
3964 Path::new(arg)
3965 .file_stem()
3966 .and_then(|stem| stem.to_str())
3967 .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
3968}
3969
3970fn is_valid_argv0_name(name: &str) -> bool {
3975 !name.is_empty()
3976 && name.chars().all(|character| {
3977 character.is_ascii_alphanumeric() || character == '-' || character == '_'
3978 })
3979}
3980
3981fn argv0_link_matches(
3986 link: &Path,
3987 target: &Path,
3988 name: &str,
3989 method: Argv0LinkMethod,
3990) -> std::io::Result<bool> {
3991 let metadata = std::fs::symlink_metadata(link)?;
3992 match method {
3993 Argv0LinkMethod::SoftLink => {
3994 Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
3995 }
3996 Argv0LinkMethod::HardLink => {
3997 if metadata.file_type().is_symlink() {
3998 return Ok(false);
3999 }
4000 Ok(std::fs::read(link)? == std::fs::read(target)?)
4003 }
4004 Argv0LinkMethod::Script => {
4005 if metadata.file_type().is_symlink() {
4006 return Ok(false);
4007 }
4008 Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
4009 }
4010 }
4011}
4012
4013fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
4015 let extension = match method {
4016 Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
4017 Argv0LinkMethod::Script => "",
4019 _ if cfg!(windows) => ".exe",
4020 _ => "",
4021 };
4022 format!("{name}{extension}")
4023}
4024
4025fn argv0_script_contents(target: &Path, name: &str) -> String {
4029 let target = target.display();
4030 if cfg!(windows) {
4031 format!("@\"{target}\" argv0 {name} %*\r\n")
4032 } else {
4033 format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
4034 }
4035}
4036
4037#[cfg(unix)]
4038fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4039 std::os::unix::fs::symlink(target, link)
4040}
4041
4042#[cfg(windows)]
4043fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4044 std::os::windows::fs::symlink_file(target, link)
4045}
4046
4047#[cfg(not(any(unix, windows)))]
4048fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
4049 Err(std::io::Error::new(
4050 std::io::ErrorKind::Unsupported,
4051 "symlink creation is not supported on this platform",
4052 ))
4053}
4054
4055#[cfg(unix)]
4057fn make_executable(path: &Path) -> std::io::Result<()> {
4058 use std::os::unix::fs::PermissionsExt;
4059 let mut permissions = std::fs::metadata(path)?.permissions();
4060 permissions.set_mode(0o755);
4061 std::fs::set_permissions(path, permissions)
4062}
4063
4064#[cfg(not(unix))]
4065fn make_executable(_path: &Path) -> std::io::Result<()> {
4066 Ok(())
4067}
4068
4069fn prune_feature_flag_tree(
4093 mut group: RuntimeGroupSpec,
4094 inherited: Option<&FeatureFlag>,
4095 policy: &FlagPolicy,
4096 prefix: &mut Vec<String>,
4097 registry: &mut FlagRegistry,
4098) -> Option<RuntimeGroupSpec> {
4099 prefix.push(group.group.name.clone());
4100
4101 let effective = group
4102 .group
4103 .feature_flag
4104 .clone()
4105 .or_else(|| inherited.cloned());
4106 if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) {
4107 prefix.pop();
4108 return None;
4109 }
4110
4111 let mut kept_groups = Vec::with_capacity(group.groups.len());
4112 for child in std::mem::take(&mut group.groups) {
4113 if let Some(pruned) =
4114 prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry)
4115 {
4116 kept_groups.push(pruned);
4117 }
4118 }
4119 group.groups = kept_groups;
4120
4121 let mut kept_commands = Vec::with_capacity(group.commands.len());
4122 for command in std::mem::take(&mut group.commands) {
4123 prefix.push(command.spec.name.clone());
4124 let command_effective = command
4125 .spec
4126 .feature_flag
4127 .clone()
4128 .or_else(|| effective.clone());
4129 let visible =
4130 record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry);
4131 prefix.pop();
4132 if visible {
4133 kept_commands.push(command);
4134 }
4135 }
4136 group.commands = kept_commands;
4137
4138 prefix.pop();
4139
4140 if group.commands.is_empty() && group.groups.is_empty() {
4141 None
4142 } else {
4143 Some(group)
4144 }
4145}
4146
4147fn record_and_check_visibility(
4151 effective: Option<&FeatureFlag>,
4152 policy: &FlagPolicy,
4153 prefix: &[String],
4154 registry: &mut FlagRegistry,
4155) -> bool {
4156 let Some(flag) = effective else {
4157 return true;
4158 };
4159 let visible = policy.visible(Some(flag.key.as_str()), flag.stage);
4160 registry.record(FlagEntry {
4161 path: prefix.join(":"),
4162 key: flag.key.clone(),
4163 stage: flag.stage,
4164 visible,
4165 });
4166 visible
4167}
4168
4169fn register_runtime_group_metadata(
4170 group: &RuntimeGroupSpec,
4171 prefix: &mut Vec<String>,
4172 schemas: &mut SchemaRegistry,
4173 views: &mut HumanViewRegistry,
4174) {
4175 prefix.push(group.group.name.clone());
4176 for child_group in &group.groups {
4177 register_runtime_group_metadata(child_group, prefix, schemas, views);
4178 }
4179 for child in &group.commands {
4180 prefix.push(child.spec.name.clone());
4181 let command_path = prefix.join(":");
4182 register_command_schema(&child.spec, &command_path, schemas);
4183 if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
4189 views.register(HumanViewDef::new(
4190 command_path,
4191 child.spec.view_columns.clone(),
4192 ));
4193 }
4194 prefix.pop();
4195 }
4196 prefix.pop();
4197}
4198
4199fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
4200 if let Some(schema) = &spec.output_schema {
4201 schemas.register_info(command_path.to_owned(), schema.clone());
4202 }
4203}
4204
4205fn runtime_group_clap_command_with_schema_help(
4206 group: &RuntimeGroupSpec,
4207 prefix: &mut Vec<String>,
4208 schemas: &SchemaRegistry,
4209) -> Command {
4210 let mut command = group_clap_command_without_children(&group.group);
4211 prefix.push(group.group.name.clone());
4212 for child_group in &group.groups {
4213 command = command.subcommand(runtime_group_clap_command_with_schema_help(
4214 child_group,
4215 prefix,
4216 schemas,
4217 ));
4218 }
4219 for child in &group.commands {
4220 prefix.push(child.spec.name.clone());
4221 let command_path = prefix.join(":");
4222 command = command.subcommand(command_clap_command_with_schema_help(
4223 &child.spec,
4224 &command_path,
4225 schemas,
4226 ));
4227 prefix.pop();
4228 }
4229 prefix.pop();
4230 command
4231}
4232
4233fn group_clap_command_without_children(group: &GroupSpec) -> Command {
4234 let mut command = Command::new(group.name.clone())
4235 .about(group.short.clone())
4236 .help_template(GROUP_HELP_TEMPLATE);
4237 if let Some(long) = &group.long
4238 && !long.is_empty()
4239 {
4240 command = command.long_about(long.clone());
4241 }
4242 for alias in &group.aliases {
4243 command = command.alias(alias.clone());
4244 }
4245 if group.hidden {
4246 command = command.hide(true);
4247 }
4248 command
4249}
4250
4251fn command_clap_command_with_schema_help(
4252 spec: &CommandSpec,
4253 command_path: &str,
4254 schemas: &SchemaRegistry,
4255) -> Command {
4256 debug_assert!(
4257 !(spec.raw_output && spec.pagination.is_some()),
4258 "command {:?} sets both raw_output and with_pagination; a single verbatim string \
4259 has no pages, so the two are mutually exclusive",
4260 spec.name
4261 );
4262 let mut command = spec.clap_command();
4263 command = apply_dry_run_visibility(command, spec);
4264 command = apply_pagination_args(command, spec);
4265 let schema = schemas.get_by_path(command_path);
4266 let default_fields = default_field_names(spec);
4267 command = apply_fields_arg(
4268 command,
4269 spec,
4270 schema.as_ref().map(|schema| schema.fields.as_slice()),
4271 &default_fields,
4272 );
4273 command = apply_output_format_visibility(command, spec);
4274 let filter_expr_fields = schema
4275 .as_ref()
4276 .map_or(&[][..], |schema| schema.fields.as_slice());
4277 apply_filter_and_expr_examples(command, spec, filter_expr_fields)
4278}
4279
4280fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command {
4283 if !spec.raw_output {
4284 return command;
4285 }
4286 use std::io::IsTerminal;
4287 command.arg(
4288 Arg::new("output")
4289 .long("output")
4290 .short('o')
4291 .value_name("FORMAT")
4292 .default_value(if std::io::stdout().is_terminal() {
4293 "human"
4294 } else {
4295 "json"
4296 })
4297 .conflicts_with_all(["json", "toon", "human"])
4298 .display_order(crate::flags::global_flag_order::OUTPUT)
4299 .hide(true)
4300 .help("Ignored — this command always prints raw text"),
4301 )
4302}
4303
4304fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command {
4314 let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating);
4315 if mutates {
4316 return command;
4317 }
4318 command.arg(
4319 Arg::new("dry-run")
4320 .long("dry-run")
4321 .num_args(0..=1)
4322 .require_equals(true)
4323 .default_missing_value("true")
4324 .default_value("false")
4325 .value_parser(crate::flags::compat_bool_value_parser())
4326 .display_order(crate::flags::global_flag_order::DRY_RUN)
4327 .hide(true)
4328 .help("Preview mutations without executing"),
4329 )
4330}
4331
4332fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command {
4337 let Some(pagination) = spec.pagination else {
4338 return command;
4339 };
4340 crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit)
4341}
4342
4343fn default_field_names(spec: &CommandSpec) -> Vec<&str> {
4347 spec.default_fields
4348 .as_deref()
4349 .map(|fields| {
4350 fields
4351 .split(',')
4352 .map(str::trim)
4353 .filter(|field| !field.is_empty() && *field != "all" && *field != "*")
4354 .collect()
4355 })
4356 .unwrap_or_default()
4357}
4358
4359fn apply_fields_arg(
4369 command: Command,
4370 spec: &CommandSpec,
4371 schema_fields: Option<&[FieldInfo]>,
4372 default_fields: &[&str],
4373) -> Command {
4374 if spec.raw_output {
4375 return command.arg(
4376 Arg::new("fields")
4377 .long("fields")
4378 .value_name("FIELDS")
4379 .display_order(crate::flags::global_flag_order::FIELDS)
4380 .hide(true)
4381 .help("Ignored — this command always prints raw text"),
4382 );
4383 }
4384 let default_value = spec
4385 .default_fields
4386 .as_deref()
4387 .filter(|fields| !fields.is_empty());
4388 let table = schema_fields
4389 .filter(|fields| !fields.is_empty())
4390 .map(|fields| format_help_section(fields, default_fields));
4391 if default_value.is_none() && table.is_none() {
4392 return command;
4393 }
4394
4395 let mut help = String::from(
4396 "Comma-separated fields to include in output (use 'all' or '*' for everything)",
4397 );
4398 if let Some(table) = &table {
4399 help.push_str("\n\n");
4400 help.push_str(table.trim_end());
4401 }
4402
4403 let mut arg = Arg::new("fields")
4404 .long("fields")
4405 .value_name("FIELDS")
4406 .display_order(crate::flags::global_flag_order::FIELDS)
4411 .help(help);
4412 if let Some(default_value) = default_value {
4413 arg = arg.default_value(default_value.to_owned());
4414 }
4415 command.arg(arg)
4416}
4417
4418fn apply_filter_and_expr_examples(
4426 mut command: Command,
4427 spec: &CommandSpec,
4428 fields: &[FieldInfo],
4429) -> Command {
4430 if spec.raw_output {
4431 return command
4432 .arg(
4433 Arg::new("filter")
4434 .long("filter")
4435 .value_name("EXPR")
4436 .display_order(crate::flags::global_flag_order::FILTER)
4437 .hide(true)
4438 .help("Ignored — this command always prints raw text"),
4439 )
4440 .arg(
4441 Arg::new("expr")
4442 .long("expr")
4443 .value_name("EXPR")
4444 .display_order(crate::flags::global_flag_order::EXPR)
4445 .hide(true)
4446 .help("Ignored — this command always prints raw text"),
4447 );
4448 }
4449 if fields.is_empty() {
4450 return command;
4451 }
4452 let first_string = fields
4453 .iter()
4454 .find(|field| field.field_type == "string")
4455 .map(|field| field.name.as_str());
4456 let first_bool = fields
4457 .iter()
4458 .find(|field| field.field_type == "bool")
4459 .map(|field| field.name.as_str());
4460
4461 if first_string.is_some() || first_bool.is_some() {
4462 let mut help = String::from("Per-item JMESPath predicate for list data");
4463 if let Some(name) = first_string {
4464 help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\""));
4465 }
4466 if let Some(name) = first_bool {
4467 help.push_str(&format!("\ne.g. --filter '{name}'"));
4468 }
4469 command = command.arg(
4470 Arg::new("filter")
4471 .long("filter")
4472 .value_name("EXPR")
4473 .display_order(crate::flags::global_flag_order::FILTER)
4474 .help(help),
4475 );
4476 }
4477
4478 let mut expr_help = String::from("JMESPath query applied to the whole result");
4479 expr_help.push_str("\ne.g. --expr 'length(@)'");
4480 if let Some(name) = first_string {
4481 expr_help.push_str(&format!("\ne.g. --expr '[].{name}'"));
4482 }
4483 command.arg(
4484 Arg::new("expr")
4485 .long("expr")
4486 .value_name("EXPR")
4487 .display_order(crate::flags::global_flag_order::EXPR)
4488 .help(expr_help),
4489 )
4490}
4491
4492fn process_exit_code(code: i32) -> ExitCode {
4493 if code == 0 {
4494 return ExitCode::SUCCESS;
4495 }
4496 match u8::try_from(code) {
4497 Ok(code) if code != 0 => ExitCode::from(code),
4498 Ok(_) | Err(_) => ExitCode::from(1),
4499 }
4500}
4501
4502async fn run_streaming_command(
4503 middleware: &Middleware,
4504 request: MiddlewareRequest<'_>,
4505 raw_matches: Arc<ArgMatches>,
4506 streaming_handler: crate::command::StreamingCommandHandler,
4507) -> Result<CliRunOutput> {
4508 use tokio::{io::AsyncWriteExt, sync::mpsc};
4509
4510 let args_for_handler = request.args.clone();
4511 let user_args_for_handler = request.user_args.clone();
4512 let handler_path = request.command_path.to_owned();
4513 let middleware_for_handler = middleware.clone();
4514 let raw_matches_for_handler = raw_matches;
4515
4516 let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
4517 let sender = StreamSender(tx);
4518
4519 let writer = tokio::spawn(async move {
4523 let mut stdout = tokio::io::stdout();
4524 while let Some(event) = rx.recv().await {
4525 let Ok(line) = serde_json::to_string(&event) else {
4526 continue;
4527 };
4528 if stdout.write_all(line.as_bytes()).await.is_err()
4529 || stdout.write_all(b"\n").await.is_err()
4530 || stdout.flush().await.is_err()
4531 {
4532 break;
4533 }
4534 }
4535 });
4536
4537 let output = middleware
4538 .run(request, async move |credential| {
4539 streaming_handler(
4540 CommandContext {
4541 credential,
4542 args: args_for_handler,
4543 user_args: user_args_for_handler,
4544 command_path: handler_path,
4545 middleware: middleware_for_handler,
4546 raw_matches: raw_matches_for_handler,
4547 },
4548 sender,
4549 )
4550 .await?;
4551 Ok(crate::CommandResult::new(serde_json::Value::Null))
4552 })
4553 .await;
4554
4555 let _write_result = writer.await;
4558
4559 match output {
4560 Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
4561 exit_code: 0,
4562 rendered: String::new(),
4563 }),
4564 Ok(out) => Ok(out.into()),
4565 Err(err) => Ok(CliRunOutput {
4566 exit_code: exit_code_for_error(&err),
4567 rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
4568 }),
4569 }
4570}
4571
4572#[cfg(test)]
4573mod user_agent_tests {
4574 use super::*;
4575
4576 #[test]
4577 fn user_agent_string_derives_name_and_version_by_default() {
4578 let config =
4579 CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
4580 assert_eq!(config.user_agent_string(), "gdx/1.2.3");
4581 }
4582
4583 #[test]
4584 fn user_agent_string_prefers_explicit_override() {
4585 let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
4586 .with_build(BuildInfo::new("1.2.3"))
4587 .with_user_agent("gdx-cli/9.9 (custom)");
4588 assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
4589 }
4590
4591 #[test]
4592 fn user_agent_string_omits_version_when_absent() {
4593 let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
4594 assert_eq!(config.user_agent_string(), "gdx");
4595 }
4596
4597 #[test]
4598 fn install_default_user_agent_publishes_config_value() {
4599 let _guard = crate::transport::client::UA_TEST_LOCK
4600 .lock()
4601 .unwrap_or_else(std::sync::PoisonError::into_inner);
4602 let _restore = crate::transport::client::RestoreDefaultUserAgent;
4603 crate::transport::set_default_user_agent("cli/dev");
4604 let cli = Cli::new(
4605 CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
4606 );
4607 cli.install_default_user_agent();
4608 assert_eq!(
4609 crate::transport::client::default_user_agent(),
4610 "uatest/4.5.6"
4611 );
4612 }
4613
4614 #[test]
4615 fn install_debug_transport_logger_tracks_the_debug_pattern() {
4616 assert!(debug_transport_logger_for("transport", &[]).enabled());
4624
4625 assert!(!debug_transport_logger_for("*,-transport", &[]).enabled());
4627
4628 assert!(!debug_transport_logger_for("", &[]).enabled());
4630 }
4631}
4632
4633#[cfg(test)]
4634mod env_config_tests {
4635 use super::*;
4636
4637 #[test]
4638 fn with_environments_stores_shared_arc_with_consumer_app_id() {
4639 let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
4643 crate::environments::Environments::new("prod")
4644 .with_app_id("gddy")
4645 .with_config_file(true),
4646 ));
4647 let envs = cfg.environments.as_ref().expect("environments set");
4648 assert!(envs.config_file_path().is_some());
4649 }
4650
4651 #[tokio::test]
4652 async fn env_flag_overrides_default_and_reaches_middleware_env() {
4653 use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
4654 use serde_json::json;
4655 let mut cli = Cli::new(
4656 CliConfig::new("envtest", "Env test", "envtest")
4657 .with_environments(Arc::new(
4658 crate::environments::Environments::new("prod")
4659 .with_environment("prod", crate::environments::EnvTable::new())
4660 .with_environment("ote", crate::environments::EnvTable::new()),
4661 ))
4662 .with_startup_args(Vec::<&str>::new()),
4663 );
4664 cli.add_command(RuntimeCommandSpec::new_with_context(
4665 CommandSpec::new("whichenv", "echo env").no_auth(true),
4666 async |ctx| {
4667 Ok(CommandResult::new(
4668 json!({ "env": ctx.environment()?.name().to_owned() }),
4669 ))
4670 },
4671 ));
4672 let out = cli
4673 .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
4674 .await;
4675 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
4676 assert!(out.rendered.contains("\"env\""));
4677 assert!(out.rendered.contains("ote"));
4678 }
4679
4680 #[tokio::test]
4681 async fn unknown_env_flag_produces_error_envelope() {
4682 let cli = Cli::new(
4683 CliConfig::new("envtest2", "Env test", "envtest2")
4684 .with_environments(Arc::new(
4685 crate::environments::Environments::new("prod")
4686 .with_environment("prod", crate::environments::EnvTable::new()),
4687 ))
4688 .with_startup_args(Vec::<&str>::new()),
4689 );
4690 let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
4691 assert_ne!(out.exit_code, 0);
4692 assert!(out.rendered.contains("nope"));
4693 }
4694}
4695
4696#[cfg(test)]
4697mod prescan_env_flag_tests {
4698 use super::*;
4699
4700 fn argv(args: &[&str]) -> impl Iterator<Item = String> {
4701 args.iter()
4702 .map(|s| s.to_string())
4703 .collect::<Vec<_>>()
4704 .into_iter()
4705 }
4706
4707 #[test]
4708 fn finds_space_separated_value() {
4709 assert_eq!(
4710 prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])),
4711 Some("dev".to_owned())
4712 );
4713 }
4714
4715 #[test]
4716 fn finds_equals_separated_value() {
4717 assert_eq!(
4718 prescan_env_flag(argv(&["--env=dev", "list"])),
4719 Some("dev".to_owned())
4720 );
4721 }
4722
4723 #[test]
4724 fn is_none_without_the_flag() {
4725 assert_eq!(prescan_env_flag(argv(&["env", "list"])), None);
4726 }
4727
4728 #[test]
4729 fn trailing_env_flag_with_no_value_is_none() {
4730 assert_eq!(prescan_env_flag(argv(&["--env"])), None);
4731 }
4732
4733 #[test]
4734 fn keeps_the_last_of_multiple_occurrences() {
4735 assert_eq!(
4739 prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])),
4740 Some("foo".to_owned())
4741 );
4742 }
4743
4744 #[test]
4745 fn ignores_an_empty_equals_value() {
4746 assert_eq!(prescan_env_flag(argv(&["--env="])), None);
4747 }
4748
4749 #[test]
4750 fn empty_occurrence_does_not_clobber_an_earlier_real_value() {
4751 assert_eq!(
4752 prescan_env_flag(argv(&["--env", "dev", "--env="])),
4753 Some("dev".to_owned())
4754 );
4755 }
4756
4757 #[test]
4758 fn space_separated_value_starting_with_dash_is_not_a_value() {
4759 assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None);
4763 }
4764
4765 #[test]
4766 fn equals_form_accepts_a_value_starting_with_dash() {
4767 assert_eq!(
4770 prescan_env_flag(argv(&["--env=-foo"])),
4771 Some("-foo".to_owned())
4772 );
4773 }
4774
4775 #[test]
4776 fn stops_at_the_end_of_options_sentinel() {
4777 assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None);
4780 }
4781
4782 #[test]
4783 fn a_real_flag_before_the_sentinel_is_still_found() {
4784 assert_eq!(
4785 prescan_env_flag(argv(&["--env", "dev", "--", "positional"])),
4786 Some("dev".to_owned())
4787 );
4788 }
4789}
4790
4791#[cfg(test)]
4792mod feature_flag_pruning_tests {
4793 use super::*;
4794 use crate::CommandResult;
4795
4796 fn trivial_command(name: &str) -> RuntimeCommandSpec {
4797 RuntimeCommandSpec::new(
4798 CommandSpec::new(name, "short").no_auth(true),
4799 async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
4800 )
4801 }
4802
4803 fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec {
4804 let mut command = trivial_command(name);
4805 command.spec = command.spec.with_feature_flag(key, stage);
4806 command
4807 }
4808
4809 fn empty_policy() -> FlagPolicy {
4810 FlagPolicy::default()
4811 }
4812
4813 #[test]
4814 fn no_flags_anywhere_keeps_everything() {
4815 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4816 .with_command(trivial_command("a"))
4817 .with_command(trivial_command("b"))
4818 .with_group(
4819 RuntimeGroupSpec::new(GroupSpec::new("child", "short"))
4820 .with_command(trivial_command("c")),
4821 );
4822
4823 let mut prefix = Vec::new();
4824 let mut registry = FlagRegistry::new();
4825 let pruned =
4826 prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4827
4828 let pruned = pruned.expect("unflagged tree should never be dropped");
4829 assert_eq!(pruned.commands.len(), 2);
4830 assert_eq!(pruned.groups.len(), 1);
4831 assert_eq!(pruned.groups[0].commands.len(), 1);
4832 assert!(registry.entries().is_empty());
4833 }
4834
4835 #[test]
4836 fn experimental_command_is_pruned_sibling_is_not() {
4837 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4838 .with_command(flagged_command("gated", "gated-flag", Stage::Experimental))
4839 .with_command(trivial_command("sibling"));
4840
4841 let mut prefix = Vec::new();
4842 let mut registry = FlagRegistry::new();
4843 let pruned =
4844 prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry)
4845 .expect("group still has a visible command left");
4846
4847 assert_eq!(pruned.commands.len(), 1);
4848 assert_eq!(pruned.commands[0].spec.name, "sibling");
4849
4850 let entries = registry.entries();
4851 assert_eq!(entries.len(), 1);
4852 assert_eq!(entries[0].path, "root:gated");
4853 assert_eq!(entries[0].key, "gated-flag");
4854 assert!(!entries[0].visible);
4855 }
4856
4857 #[test]
4858 fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() {
4859 let build_tree = || {
4860 RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4861 .with_command(trivial_command("keep-me"))
4862 .with_group(
4863 RuntimeGroupSpec::new(
4864 GroupSpec::new("flagged-group", "short")
4865 .with_feature_flag("group-flag", Stage::Beta),
4866 )
4867 .with_command(trivial_command("cmd-default"))
4868 .with_command(flagged_command(
4869 "cmd-ga",
4870 "cmd-ga-flag",
4871 Stage::Ga,
4872 )),
4873 )
4874 };
4875
4876 let mut prefix = Vec::new();
4881 let mut registry = FlagRegistry::new();
4882 let pruned = prune_feature_flag_tree(
4883 build_tree(),
4884 None,
4885 &empty_policy(),
4886 &mut prefix,
4887 &mut registry,
4888 )
4889 .expect("root keeps its unflagged sibling command");
4890 assert!(pruned.groups.is_empty());
4891 assert_eq!(pruned.commands.len(), 1);
4892 assert_eq!(pruned.commands[0].spec.name, "keep-me");
4893 assert_eq!(registry.entries().len(), 1);
4895 assert_eq!(registry.entries()[0].path, "root:flagged-group");
4896 assert!(!registry.entries()[0].visible);
4897
4898 let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4900 let mut prefix = Vec::new();
4901 let mut registry = FlagRegistry::new();
4902 let pruned =
4903 prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry)
4904 .expect("root is kept");
4905 assert_eq!(pruned.groups.len(), 1);
4906 assert_eq!(pruned.groups[0].commands.len(), 2);
4907 assert!(registry.entries().iter().all(|entry| entry.visible));
4908 }
4909
4910 #[test]
4911 fn ancestor_invisibility_short_circuits_before_children_are_visited() {
4912 let group = RuntimeGroupSpec::new(
4919 GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta),
4920 )
4921 .with_command(flagged_command("child", "child-flag", Stage::Ga));
4922
4923 let mut prefix = Vec::new();
4924 let mut registry = FlagRegistry::new();
4925 let pruned =
4926 prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4927
4928 assert!(
4929 pruned.is_none(),
4930 "invisible ancestor drops its whole subtree"
4931 );
4932 assert_eq!(registry.entries().len(), 1);
4934 assert_eq!(registry.entries()[0].path, "ancestor");
4935 assert!(registry.by_key("child-flag").is_empty());
4936 }
4937
4938 #[test]
4939 fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() {
4940 let module_flag = FeatureFlag::new("module-flag", Stage::Beta);
4944 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4945 .with_command(trivial_command("unflagged-child"));
4946
4947 let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4948 let mut prefix = Vec::new();
4949 let mut registry = FlagRegistry::new();
4950 let pruned = prune_feature_flag_tree(
4951 group,
4952 Some(&module_flag),
4953 &policy,
4954 &mut prefix,
4955 &mut registry,
4956 )
4957 .expect("Beta-permissive policy keeps a Beta-inherited tree");
4958 assert_eq!(pruned.commands.len(), 1);
4959
4960 let entries = registry.entries();
4964 assert_eq!(entries.len(), 2);
4965 assert_eq!(entries[0].path, "root");
4966 assert_eq!(entries[0].key, "module-flag");
4967 assert_eq!(entries[0].stage, Stage::Beta);
4968 assert_eq!(entries[1].path, "root:unflagged-child");
4969 assert_eq!(entries[1].key, "module-flag");
4970 assert_eq!(entries[1].stage, Stage::Beta);
4971
4972 let mut prefix = Vec::new();
4976 let mut registry = FlagRegistry::new();
4977 let pruned = prune_feature_flag_tree(
4978 RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4979 .with_command(trivial_command("unflagged-child")),
4980 Some(&module_flag),
4981 &empty_policy(),
4982 &mut prefix,
4983 &mut registry,
4984 );
4985 assert!(pruned.is_none());
4986 }
4987
4988 #[test]
4989 fn registry_records_only_named_flags_not_unflagged_nodes() {
4990 let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group(
4991 RuntimeGroupSpec::new(
4992 GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta),
4993 )
4994 .with_command(trivial_command("c1"))
4995 .with_command(flagged_command("c2", "c2-flag", Stage::Ga)),
4996 );
4997
4998 let policy = FlagPolicy::default().with_min_stage(Stage::Experimental);
5000 let mut prefix = Vec::new();
5001 let mut registry = FlagRegistry::new();
5002 let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry)
5003 .expect("permissive policy keeps everything");
5004 assert_eq!(pruned.groups[0].commands.len(), 2);
5005
5006 let entries = registry.entries();
5007 assert_eq!(entries.len(), 3, "root has no flag and is not recorded");
5008 assert_eq!(entries[0].path, "root:g");
5009 assert_eq!(entries[0].key, "g-flag");
5010 assert_eq!(entries[1].path, "root:g:c1");
5011 assert_eq!(entries[1].key, "g-flag");
5012 assert_eq!(entries[1].stage, Stage::Beta);
5013 assert_eq!(entries[2].path, "root:g:c2");
5014 assert_eq!(entries[2].key, "c2-flag");
5015 assert_eq!(entries[2].stage, Stage::Ga);
5016 assert!(entries.iter().all(|entry| entry.visible));
5017 }
5018
5019 #[test]
5020 fn module_feature_flag_cascades_into_its_group_via_add_module() {
5021 let module = Module::new("Test Category", |_ctx| {
5027 RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short"))
5028 .with_command(trivial_command("list"))
5029 })
5030 .with_feature_flag("module-flag", Stage::Experimental);
5031
5032 let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest"));
5033 cli.add_module(module);
5034
5035 assert!(
5036 !cli.commands.contains_key("gated-mod:list"),
5037 "module-level Experimental flag should have pruned the whole group under the default Ga policy"
5038 );
5039 assert!(
5040 !has_subcommand(&cli.root, "gated-mod"),
5041 "the pruned group must not be mounted in the clap tree either"
5042 );
5043 }
5044
5045 #[test]
5046 fn module_feature_flag_keeps_group_when_policy_allows_it() {
5047 let module = Module::new("Test Category", |_ctx| {
5048 RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short"))
5049 .with_command(trivial_command("list"))
5050 })
5051 .with_feature_flag("module-flag-2", Stage::Experimental);
5052
5053 let mut cli = Cli::new(
5054 CliConfig::new("modtest2", "Module test", "modtest2")
5055 .with_min_stage(Stage::Experimental),
5056 );
5057 cli.add_module(module);
5058
5059 assert!(cli.commands.contains_key("gated-mod-2:list"));
5060 assert!(has_subcommand(&cli.root, "gated-mod-2"));
5061 }
5062
5063 #[test]
5064 fn active_environment_min_stage_loosens_consumer_level_policy() {
5065 let module = Module::new("Test Category", |_ctx| {
5070 RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short"))
5071 .with_command(trivial_command("list"))
5072 })
5073 .with_feature_flag("module-flag-3", Stage::Experimental);
5074
5075 let mut cli = Cli::new(
5076 CliConfig::new("modtest3", "Module test", "modtest3")
5077 .with_environments(Arc::new(
5078 crate::environments::Environments::new("prod").with_environment(
5079 "prod",
5080 crate::environments::EnvTable::new().with("min_stage", "experimental"),
5081 ),
5082 ))
5083 .with_startup_args(Vec::<&str>::new()),
5084 );
5085 cli.add_module(module);
5086
5087 assert!(cli.commands.contains_key("gated-mod-3:list"));
5088 assert!(has_subcommand(&cli.root, "gated-mod-3"));
5089 }
5090
5091 #[test]
5100 fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() {
5101 fn gated_module() -> Module {
5102 Module::new("Test Category", |_ctx| {
5103 RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short"))
5104 .with_command(trivial_command("list"))
5105 })
5106 .with_feature_flag("module-flag-4", Stage::Experimental)
5107 }
5108 fn environments() -> Arc<crate::environments::Environments> {
5109 Arc::new(
5110 crate::environments::Environments::new("prod")
5111 .with_environment("prod", crate::environments::EnvTable::new())
5112 .with_environment(
5113 "dev",
5114 crate::environments::EnvTable::new().with("min_stage", "experimental"),
5115 ),
5116 )
5117 }
5118
5119 let mut with_dev_flag = Cli::new(
5120 CliConfig::new("modtest4a", "Module test", "modtest4a")
5121 .with_environments(environments())
5122 .with_startup_args(["modtest4a", "--env", "dev"]),
5123 );
5124 with_dev_flag.add_module(gated_module());
5125 assert!(
5126 with_dev_flag.commands.contains_key("gated-mod-4:list"),
5127 "--env dev in startup_args should reveal the Experimental module"
5128 );
5129 assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4"));
5130
5131 let mut without_flag = Cli::new(
5134 CliConfig::new("modtest4b", "Module test", "modtest4b")
5135 .with_environments(environments())
5136 .with_startup_args(Vec::<&str>::new()),
5137 );
5138 without_flag.add_module(gated_module());
5139 assert!(
5140 !without_flag.commands.contains_key("gated-mod-4:list"),
5141 "without --env, the default env's Ga policy should still prune the module"
5142 );
5143 assert!(!has_subcommand(&without_flag.root, "gated-mod-4"));
5144 }
5145
5146 static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(());
5147
5148 struct GlobalMinStageEnvGuard {
5151 key: &'static str,
5152 prev: Option<std::ffi::OsString>,
5153 }
5154 impl GlobalMinStageEnvGuard {
5155 #[allow(unsafe_code)]
5158 fn set(key: &'static str, value: &str) -> Self {
5159 let prev = std::env::var_os(key);
5160 unsafe { std::env::set_var(key, value) };
5163 Self { key, prev }
5164 }
5165
5166 #[allow(unsafe_code)]
5169 fn unset(key: &'static str) -> Self {
5170 let prev = std::env::var_os(key);
5171 unsafe { std::env::remove_var(key) };
5174 Self { key, prev }
5175 }
5176 }
5177 impl Drop for GlobalMinStageEnvGuard {
5178 #[allow(unsafe_code)]
5179 fn drop(&mut self) {
5180 unsafe {
5183 match &self.prev {
5184 Some(v) => std::env::set_var(self.key, v),
5185 None => std::env::remove_var(self.key),
5186 }
5187 }
5188 }
5189 }
5190
5191 #[test]
5192 #[allow(unsafe_code)]
5193 fn global_min_stage_override_is_a_noop_when_unset() {
5194 let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5195 .lock()
5196 .unwrap_or_else(std::sync::PoisonError::into_inner);
5197 const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE";
5198 let _guard = GlobalMinStageEnvGuard::unset(VAR);
5202
5203 assert_eq!(global_min_stage_override("unset-min-stage-app"), None);
5204 }
5205
5206 #[test]
5207 #[allow(unsafe_code)]
5208 fn global_min_stage_override_parses_a_valid_value() {
5209 let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5210 .lock()
5211 .unwrap_or_else(std::sync::PoisonError::into_inner);
5212 const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE";
5213 let _guard = GlobalMinStageEnvGuard::set(VAR, "beta");
5214
5215 assert_eq!(
5216 global_min_stage_override("valid-min-stage-app"),
5217 Some(Stage::Beta)
5218 );
5219 }
5220
5221 #[test]
5222 #[allow(unsafe_code)]
5223 fn global_min_stage_override_ignores_a_malformed_value() {
5224 let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5225 .lock()
5226 .unwrap_or_else(std::sync::PoisonError::into_inner);
5227 const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE";
5228 let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly");
5229
5230 assert_eq!(global_min_stage_override("bad-min-stage-app"), None);
5231 }
5232}
5233
5234#[cfg(test)]
5235mod flags_command_tests {
5236 use super::*;
5237 use crate::CommandResult;
5238
5239 fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
5243 Module::new("Test Category", move |_ctx| {
5244 RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command(
5245 RuntimeCommandSpec::new(
5246 CommandSpec::new("list", "short").no_auth(true),
5247 async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
5248 ),
5249 )
5250 })
5251 .with_feature_flag(key, stage)
5252 }
5253
5254 #[tokio::test]
5255 async fn flags_list_reports_flagged_entries() {
5256 let mut cli = Cli::new(
5257 CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta),
5258 );
5259 cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta));
5260
5261 let out = cli
5262 .run(["flagtest", "flags", "list", "--output", "json"])
5263 .await;
5264 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5265 let rendered: serde_json::Value =
5266 serde_json::from_str(&out.rendered).expect("stdout should contain json");
5267 let entries = rendered["data"].as_array().expect("data should be array");
5268 let command_entry = entries
5269 .iter()
5270 .find(|entry| entry["path"] == "flagged-mod:list")
5271 .expect("flagged command entry should be present");
5272 assert_eq!(command_entry["key"], "list-flag");
5273 assert_eq!(command_entry["stage"], "beta");
5274 assert_eq!(command_entry["visible"], true);
5275 }
5276
5277 #[tokio::test]
5278 async fn flags_info_returns_policy_and_entries_for_known_key() {
5279 let mut cli = Cli::new(
5280 CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta),
5281 );
5282 cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta));
5283
5284 let out = cli
5285 .run([
5286 "flagtest2",
5287 "flags",
5288 "info",
5289 "info-flag",
5290 "--output",
5291 "json",
5292 ])
5293 .await;
5294 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5295 let rendered: serde_json::Value =
5296 serde_json::from_str(&out.rendered).expect("stdout should contain json");
5297 let data = &rendered["data"];
5298 assert_eq!(data["key"], "info-flag");
5299 assert_eq!(data["policy"]["min_stage"], "beta");
5300 assert!(data["policy"]["override"].is_null());
5301 let entries = data["entries"].as_array().expect("entries should be array");
5302 assert!(!entries.is_empty());
5303 assert!(entries.iter().any(|entry| {
5304 entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage"
5305 }));
5306 }
5307
5308 #[tokio::test]
5309 async fn flags_info_reports_override_decided_by() {
5310 let mut cli = Cli::new(
5315 CliConfig::new("flagtest3", "Flag test", "flagtest3")
5316 .with_feature_override("override-flag", Stage::Ga),
5317 );
5318 cli.add_module(flagged_module(
5319 "flagged-mod-3",
5320 "override-flag",
5321 Stage::Experimental,
5322 ));
5323
5324 let out = cli
5325 .run([
5326 "flagtest3",
5327 "flags",
5328 "info",
5329 "override-flag",
5330 "--output",
5331 "json",
5332 ])
5333 .await;
5334 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5335 let rendered: serde_json::Value =
5336 serde_json::from_str(&out.rendered).expect("stdout should contain json");
5337 let data = &rendered["data"];
5338 assert_eq!(data["policy"]["min_stage"], "ga");
5339 assert_eq!(data["policy"]["override"], "ga");
5340 let entries = data["entries"].as_array().expect("entries should be array");
5341 assert!(!entries.is_empty());
5342 assert!(
5343 entries
5344 .iter()
5345 .all(|entry| entry["decided_by"] == "override")
5346 );
5347 assert!(entries.iter().all(|entry| entry["visible"] == true));
5348 assert!(entries.iter().all(|entry| entry["stage"] == "experimental"));
5349 }
5350
5351 #[tokio::test]
5352 async fn flags_info_unknown_key_errors() {
5353 let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4"));
5354
5355 let out = cli
5356 .run(["flagtest4", "flags", "info", "no-such-flag"])
5357 .await;
5358 assert_ne!(out.exit_code, 0);
5359 assert!(out.rendered.contains("no such flag"));
5360 }
5361}