use std::{
collections::{BTreeMap, BTreeSet},
future::Future,
io::Write,
path::{Path, PathBuf},
process::ExitCode,
sync::{Arc, Mutex},
time::Duration,
};
mod builtins;
mod completion;
mod help;
mod tree_render;
use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser};
use crate::{
ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
FeatureFlag, GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec,
RuntimeGroupSpec,
auth::commands::auth_command_group,
command::{
CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
leaf_matches,
},
error::exit_code_for_error,
feature_flags::{FlagEntry, FlagPolicy, FlagRegistry, Stage},
flags::{
GlobalFlags, derive_bool_flags, derive_value_flags, extract_command_path,
extract_output_format, global_flags_from_matches, has_true_schema_flag, min_stage_env_var,
output_env_var, register_global_flags, register_reason_flag, resolve_default_output_format,
},
guide::{guide_content, render_guide_human},
module::{Module, ModuleContext},
output::{
FieldInfo, HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry,
format_help_section, global_human_view_registry_snapshot, global_schema_registry_snapshot,
},
search::{SearchDocument, SearchIndex},
};
use builtins::{
completion_args, completion_command, guide_args, guide_command, help_args, help_command,
search_args, search_command,
};
use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BuildInfo {
pub version: String,
pub commit: Option<String>,
pub date: Option<String>,
}
impl BuildInfo {
#[must_use]
pub fn new(version: impl Into<String>) -> Self {
Self {
version: version.into(),
commit: None,
date: None,
}
}
#[must_use]
pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
self.commit = Some(commit.into());
self
}
#[must_use]
pub fn with_date(mut self, date: impl Into<String>) -> Self {
self.date = Some(date.into());
self
}
#[must_use]
pub fn version_string(&self) -> String {
let commit = self.commit.as_deref().unwrap_or_default();
let date = self.date.as_deref().unwrap_or_default();
if commit.is_empty() && date.is_empty() {
self.version.clone()
} else {
format!("{} (commit {commit}, built {date})", self.version)
}
}
}
pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
pub type PreRun =
Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
const MAX_ARGV0_DEPTH: usize = 16;
#[derive(Clone)]
#[non_exhaustive]
pub enum Argv0Route {
Alias(Vec<String>),
Personality(Arc<dyn Fn() -> CliConfig + Send + Sync>),
}
impl std::fmt::Debug for Argv0Route {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(),
Self::Personality(_) => formatter.write_str("Personality(..)"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Argv0LinkMethod {
SoftLink,
HardLink,
Script,
}
pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 5] =
["help", "guide", "tree", "completion", "search"];
#[derive(Clone, Default)]
pub struct CliConfig {
pub name: String,
pub short: String,
pub long: Option<String>,
pub build: BuildInfo,
pub app_id: String,
pub default_auth_provider: Option<String>,
pub modules: Vec<Module>,
pub commands: Vec<RuntimeCommandSpec>,
pub auth_extra_commands: Vec<RuntimeCommandSpec>,
pub guides: Vec<GuideEntry>,
pub views: Vec<HumanViewDef>,
pub auth_providers: Vec<Arc<dyn AuthProvider>>,
pub user_agent: Option<String>,
pub redacted_debug_headers: Vec<String>,
pub authz: Option<Arc<dyn Authorizer>>,
pub auditor: Option<Arc<dyn Auditor>>,
pub activity: Option<Arc<dyn ActivityEmitter>>,
pub init_deps: Option<InitDeps>,
pub register_flags: Option<RegisterFlags>,
pub apply_flags: Option<ApplyFlags>,
pub pre_run: Option<PreRun>,
pub meta_resolver: Option<ResolveMeta>,
pub on_shutdown: Option<OnShutdown>,
pub extra_search_docs: Option<ExtraSearchDocs>,
pub root_next_actions: Option<RootNextActions>,
pub admin_category: Option<String>,
pub config_commands: bool,
pub argv0_routes: BTreeMap<String, Argv0Route>,
pub environments: Option<Arc<crate::environments::Environments>>,
pub startup_args: Option<Vec<std::ffi::OsString>>,
pub min_stage: Stage,
pub feature_overrides: BTreeMap<String, Stage>,
pub auto_interactive: bool,
}
impl CliConfig {
#[must_use]
pub fn new(
name: impl Into<String>,
short: impl Into<String>,
app_id: impl Into<String>,
) -> Self {
Self {
name: name.into(),
short: short.into(),
app_id: app_id.into(),
..Self::default()
}
}
#[must_use]
pub fn with_long(mut self, long: impl Into<String>) -> Self {
self.long = Some(long.into());
self
}
#[must_use]
pub fn with_build(mut self, build: BuildInfo) -> Self {
self.build = build;
self
}
#[must_use]
pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
self.default_auth_provider = Some(provider.into());
self
}
#[must_use]
pub fn with_environments(
mut self,
environments: Arc<crate::environments::Environments>,
) -> Self {
self.environments = Some(environments);
self
}
#[must_use]
pub fn with_startup_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString>,
{
self.startup_args = Some(args.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn with_min_stage(mut self, stage: Stage) -> Self {
self.min_stage = stage;
self
}
#[must_use]
pub fn with_auto_interactive(mut self, enabled: bool) -> Self {
self.auto_interactive = enabled;
self
}
#[must_use]
pub fn with_feature_override(mut self, key: impl Into<String>, stage: Stage) -> Self {
self.feature_overrides.insert(key.into(), stage);
self
}
fn flag_policy(&self) -> FlagPolicy {
FlagPolicy {
min_stage: self.min_stage,
overrides: self.feature_overrides.clone(),
}
}
#[must_use]
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
#[must_use]
pub fn with_redacted_debug_headers(
mut self,
names: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.redacted_debug_headers
.extend(names.into_iter().filter_map(|name| {
let name = name.into().trim().to_owned();
(!name.is_empty()).then_some(name)
}));
self
}
#[must_use]
pub fn user_agent_string(&self) -> String {
if let Some(user_agent) = &self.user_agent {
return user_agent.clone();
}
if self.build.version.is_empty() {
self.name.clone()
} else {
format!("{}/{}", self.name, self.build.version)
}
}
#[must_use]
pub fn with_module(mut self, module: Module) -> Self {
self.modules.push(module);
self
}
#[must_use]
pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
self.modules.extend(modules);
self
}
#[must_use]
pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn with_auth_extra_commands(
mut self,
commands: impl IntoIterator<Item = RuntimeCommandSpec>,
) -> Self {
self.auth_extra_commands.extend(commands);
self
}
#[must_use]
pub fn with_guide(mut self, guide: GuideEntry) -> Self {
self.guides.push(guide);
self
}
#[must_use]
pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
self.guides.extend(guides);
self
}
#[must_use]
pub fn with_view(mut self, view: HumanViewDef) -> Self {
self.views.push(view);
self
}
#[must_use]
pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
self.auth_providers.push(provider);
self
}
#[must_use]
pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
self.authz = Some(authz);
self
}
#[must_use]
pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
self.auditor = Some(auditor);
self
}
#[must_use]
pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
self.activity = Some(activity);
self
}
#[must_use]
pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
self.init_deps = Some(init_deps);
self
}
#[must_use]
pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
self.register_flags = Some(register_flags);
self
}
#[must_use]
pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
self.apply_flags = Some(apply_flags);
self
}
#[must_use]
pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
self.pre_run = Some(pre_run);
self
}
#[must_use]
pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
self.meta_resolver = Some(meta_resolver);
self
}
#[must_use]
pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
self.on_shutdown = Some(on_shutdown);
self
}
#[must_use]
pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
self.extra_search_docs = Some(extra_search_docs);
self
}
#[must_use]
pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
self.root_next_actions = Some(root_next_actions);
self
}
#[must_use]
pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
self.admin_category = Some(category.into());
self
}
#[must_use]
pub fn with_config_commands(mut self) -> Self {
self.config_commands = true;
self
}
#[must_use]
pub fn with_argv0_alias(
mut self,
name: impl Into<String>,
command_path: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let name = name.into();
debug_assert!(
is_valid_argv0_name(&name),
"argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
);
debug_assert!(
name != self.name,
"argv0 route name {name:?} must differ from the CLI's own name {:?}",
self.name
);
let tokens = command_path.into_iter().map(Into::into).collect();
self.argv0_routes.insert(name, Argv0Route::Alias(tokens));
self
}
#[must_use]
pub fn with_argv0_personality(
mut self,
name: impl Into<String>,
build: impl Fn() -> CliConfig + Send + Sync + 'static,
) -> Self {
let name = name.into();
debug_assert!(
is_valid_argv0_name(&name),
"argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
);
debug_assert!(
name != self.name,
"argv0 route name {name:?} must differ from the CLI's own name {:?}",
self.name
);
self.argv0_routes
.insert(name, Argv0Route::Personality(Arc::new(build)));
self
}
}
impl std::fmt::Debug for CliConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CliConfig")
.field("name", &self.name)
.field("short", &self.short)
.field("long", &self.long)
.field("build", &self.build)
.field("app_id", &self.app_id)
.field("default_auth_provider", &self.default_auth_provider)
.field("modules", &self.modules)
.field("commands", &self.commands)
.field("guides", &self.guides)
.field("views", &self.views)
.field("auth_providers_len", &self.auth_providers.len())
.field("has_authz", &self.authz.is_some())
.field("has_auditor", &self.auditor.is_some())
.field("has_activity", &self.activity.is_some())
.field("has_init_deps", &self.init_deps.is_some())
.field("has_register_flags", &self.register_flags.is_some())
.field("has_apply_flags", &self.apply_flags.is_some())
.field("has_pre_run", &self.pre_run.is_some())
.field("has_meta_resolver", &self.meta_resolver.is_some())
.field("has_on_shutdown", &self.on_shutdown.is_some())
.field("has_extra_search_docs", &self.extra_search_docs.is_some())
.field("has_root_next_actions", &self.root_next_actions.is_some())
.field("admin_category", &self.admin_category)
.field(
"argv0_routes",
&self.argv0_routes.keys().collect::<Vec<_>>(),
)
.field("min_stage", &self.min_stage)
.field("feature_overrides", &self.feature_overrides)
.finish()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CliRunOutput {
pub exit_code: i32,
pub rendered: String,
}
impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
fn from(o: crate::middleware::MiddlewareOutput) -> Self {
Self {
exit_code: o.exit_code,
rendered: o.rendered,
}
}
}
#[derive(Clone)]
pub struct Cli {
config: CliConfig,
middleware: Middleware,
root: Command,
commands: BTreeMap<String, RuntimeCommandSpec>,
module_entries: Vec<ModuleHelpEntry>,
guide_entries: Vec<GuideEntry>,
init_deps: Option<InitDeps>,
apply_flags: Option<ApplyFlags>,
pre_run: Option<PreRun>,
meta_resolver: Option<ResolveMeta>,
on_shutdown: Option<OnShutdown>,
extra_search_docs: Option<ExtraSearchDocs>,
root_next_actions: Option<RootNextActions>,
init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct InitFailure {
message: String,
code: String,
system: String,
request_id: String,
fix: Option<String>,
exit_code: i32,
}
impl InitFailure {
fn capture(err: &CliCoreError) -> Self {
let envelope = crate::output::build_error_envelope(err, "");
let (code, system, request_id) = envelope.error.map_or_else(
|| ("ERROR".to_owned(), String::new(), String::new()),
|error| (error.code, error.system, error.request_id),
);
Self {
message: err.to_string(),
code,
system,
request_id,
fix: envelope.fix,
exit_code: exit_code_for_error(err),
}
}
fn into_error(self) -> CliCoreError {
let message = CliCoreError::SystemMessage {
message: self.message,
system: self.system,
code: self.code,
request_id: self.request_id,
};
CliCoreError::with_exit_code(
self.exit_code,
CliCoreError::with_fix(self.fix.unwrap_or_default(), message),
)
}
}
impl std::fmt::Debug for Cli {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Cli")
.field("config", &self.config)
.field("middleware", &self.middleware)
.field("root", &self.root)
.field("commands", &self.commands)
.field("module_entries", &self.module_entries)
.field("guide_entries", &self.guide_entries)
.field("has_init_deps", &self.init_deps.is_some())
.field("has_apply_flags", &self.apply_flags.is_some())
.field("has_pre_run", &self.pre_run.is_some())
.field("has_meta_resolver", &self.meta_resolver.is_some())
.field("has_on_shutdown", &self.on_shutdown.is_some())
.field("has_extra_search_docs", &self.extra_search_docs.is_some())
.field("has_root_next_actions", &self.root_next_actions.is_some())
.finish()
}
}
impl Cli {
#[must_use]
pub fn new(config: CliConfig) -> Self {
let auth_providers = config.auth_providers.clone();
let guides = config.guides.clone();
let views = config.views.clone();
let modules = config.modules.clone();
let commands = config.commands.clone();
let init_deps = config.init_deps.clone();
let apply_flags = config.apply_flags.clone();
let pre_run = config.pre_run.clone();
let meta_resolver = config.meta_resolver.clone();
let on_shutdown = config.on_shutdown.clone();
let extra_search_docs = config.extra_search_docs.clone();
let root_next_actions = config.root_next_actions.clone();
let mut root = Command::new(config.name.clone())
.about(config.short.clone())
.disable_help_subcommand(true)
.version(config.build.version_string());
if let Some(long) = &config.long
&& !long.is_empty()
{
root = root.long_about(long.clone());
}
root = register_global_flags(root)
.subcommand(help_command())
.subcommand(guide_command())
.subcommand(Command::new("tree").about("Display full command tree"))
.subcommand(completion_command())
.subcommand(search_command());
if let Some(register_flags) = &config.register_flags {
root = register_flags(root);
}
if config.authz.is_some() || config.auditor.is_some() || config.activity.is_some() {
root = register_reason_flag(root);
}
if config.environments.is_some() {
root = root.arg(
Arg::new("env")
.long("env")
.global(true)
.value_name("ENV")
.display_order(crate::flags::global_flag_order::ENV)
.help("Override the active environment (see: env list)"),
);
}
let intro = config
.long
.as_deref()
.filter(|long| !long.is_empty())
.unwrap_or(config.short.as_str());
root = root
.long_about(build_root_long(intro, &[], false))
.help_template(ROOT_HELP_TEMPLATE);
let mut middleware = Middleware::new();
middleware.app_id = config.app_id.clone();
crate::fs::migrate_macos_config_dir(&config.app_id);
middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id));
middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
middleware.authz = config.authz.clone();
middleware.auditor = config.auditor.clone();
middleware.activity = config.activity.clone();
middleware
.schema_registry
.merge(&global_schema_registry_snapshot());
middleware
.human_views
.merge(&global_human_view_registry_snapshot());
if let Some(environments) = &config.environments {
let startup_args = config
.startup_args
.clone()
.unwrap_or_else(|| std::env::args_os().collect());
let startup_env_flag = prescan_env_flag(
startup_args
.iter()
.skip(1) .map(|arg| arg.to_string_lossy().into_owned()),
);
middleware.env =
environments.effective_active(startup_env_flag.as_deref(), &middleware.config);
middleware.environments = Some(Arc::clone(environments));
}
let mut flag_policy = config.flag_policy();
if let Some(min_stage) = global_min_stage_override(&config.app_id) {
flag_policy.min_stage = min_stage;
}
if let Some(environments) = &middleware.environments
&& let Ok(source) = environments.source(&middleware.env)
{
let chain = crate::env_config::SourceChain::new().push(&source);
match crate::env_config::resolve_field::<Stage>(
&chain,
"min_stage",
"min_stage",
None,
false,
crate::env_config::default_from_toml::<Stage>,
|_raw: &str| -> std::result::Result<Stage, String> { Err(String::new()) },
) {
Ok(Some(min_stage)) => flag_policy.min_stage = min_stage,
Ok(None) => {}
Err(err) => {
tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment min_stage");
}
}
match crate::env_config::resolve_field::<BTreeMap<String, Stage>>(
&chain,
"feature_overrides",
"feature_overrides",
None,
false,
crate::env_config::default_from_toml::<BTreeMap<String, Stage>>,
|_raw: &str| -> std::result::Result<BTreeMap<String, Stage>, String> {
Err(String::new())
},
) {
Ok(Some(overrides)) => flag_policy.overrides.extend(overrides),
Ok(None) => {}
Err(err) => {
tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment feature_overrides");
}
}
}
middleware.flag_policy = flag_policy;
let mut cli = Self {
config,
middleware,
root,
commands: BTreeMap::new(),
module_entries: Vec::new(),
guide_entries: Vec::new(),
init_deps,
apply_flags,
pre_run,
meta_resolver,
on_shutdown,
extra_search_docs,
root_next_actions,
init_state: Arc::new(Mutex::new(None)),
};
for provider in auth_providers {
cli.register_auth_provider(provider);
}
if cli.middleware.default_auth_provider.is_empty()
&& let Some(provider) = cli.middleware.auth.registered_names().first()
{
cli.middleware.default_auth_provider = provider.clone();
}
if !cli.middleware.default_auth_provider.is_empty() {
cli.ensure_auth_command();
}
for view in views {
cli.middleware.human_views.register(view);
}
cli.add_guides(guides);
for module in modules {
cli.add_module(module);
}
for command in commands {
cli.add_command(command);
}
if cli.config.config_commands {
cli.ensure_config_command();
}
if cli.config.environments.is_some() {
cli.ensure_env_command();
}
cli.ensure_flags_command();
cli
}
fn register_auth_help_entry(&mut self) {
let category = self
.config
.admin_category
.clone()
.unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
let short = self
.root
.find_subcommand("auth")
.filter(|auth| !auth.is_hide_set())
.map(|auth| {
auth.get_about()
.map(ToString::to_string)
.unwrap_or_default()
});
if !already_listed && let Some(short) = short {
self.module_entries.push(ModuleHelpEntry {
category,
name: "auth".to_owned(),
short,
});
}
self.refresh_root_long();
}
#[must_use]
pub fn middleware(&self) -> &Middleware {
&self.middleware
}
pub fn middleware_mut(&mut self) -> &mut Middleware {
&mut self.middleware
}
pub async fn execute(&self) -> ExitCode {
let mut stdout = std::io::stdout().lock();
let mut stderr = std::io::stderr().lock();
match self
.execute_from(std::env::args_os(), &mut stdout, &mut stderr)
.await
{
Ok(code) => code,
Err(err) => {
drop(writeln!(stderr, "{err}"));
ExitCode::from(1)
}
}
}
pub async fn execute_from<I, S, O, E>(
&self,
args: I,
stdout: &mut O,
stderr: &mut E,
) -> std::io::Result<ExitCode>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
O: Write,
E: Write,
{
self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
.await
}
pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
&self,
args: I,
stdout: &mut O,
stderr: &mut E,
shutdown: Shutdown,
) -> std::io::Result<ExitCode>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
O: Write,
E: Write,
Shutdown: Future<Output = ()>,
{
self.install_default_user_agent();
let output = run_until_signal(self.run(args), shutdown).await;
if output.exit_code == 130
&& output.rendered == "command interrupted\n"
&& let Some(on_shutdown) = &self.on_shutdown
{
on_shutdown();
}
if output.exit_code == 0 {
stdout.write_all(output.rendered.as_bytes())?;
} else {
stderr.write_all(output.rendered.as_bytes())?;
}
Ok(process_exit_code(output.exit_code))
}
fn install_default_user_agent(&self) {
crate::transport::set_default_user_agent(self.config.user_agent_string());
}
pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
self.middleware.auth.register(provider);
self.ensure_auth_command();
self.refresh_root_long();
self
}
#[must_use]
pub fn root_command(&self) -> &Command {
&self.root
}
pub fn add_module_group(
&mut self,
category: impl Into<String>,
group: RuntimeGroupSpec,
) -> &mut Self {
self.add_module_group_inner(category, group, None)
}
fn add_module_group_inner(
&mut self,
category: impl Into<String>,
group: RuntimeGroupSpec,
inherited: Option<FeatureFlag>,
) -> &mut Self {
if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
tracing::warn!(
name = %group.group.name,
"module group name is reserved by cli-engine built-ins; the group will not be registered"
);
return self;
}
let mut prefix = Vec::new();
let Some(group) = prune_feature_flag_tree(
group,
inherited.as_ref(),
&self.middleware.flag_policy,
&mut prefix,
&mut self.middleware.flag_registry,
) else {
return self;
};
let category = category.into();
if !group.group.hidden {
self.module_entries.push(ModuleHelpEntry {
category,
name: group.group.name.clone(),
short: group.group.short.clone(),
});
}
let mut prefix = Vec::new();
register_runtime_group_metadata(
&group,
&mut prefix,
&mut self.middleware.schema_registry,
&mut self.middleware.human_views,
);
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
&mut prefix,
&self.middleware.schema_registry,
);
self.root = self.root.clone().subcommand(clap_group);
self.refresh_root_long();
self
}
pub fn add_module(&mut self, module: Module) -> &mut Self {
for view in module.views.clone() {
self.middleware.human_views.register(view);
}
self.add_guides(module.guides.clone());
let mut context = ModuleContext::new(&mut self.middleware);
let group = (module.register)(&mut context);
let (guides, views) = context.into_parts();
for view in views {
self.middleware.human_views.register(view);
}
self.add_guides(guides);
self.add_module_group_inner(module.category, group, module.feature_flag.clone())
}
pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
let name = command.spec.name.clone();
register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
self.commands.insert(name, command.clone());
self.root = self
.root
.clone()
.subcommand(command_clap_command_with_schema_help(
&command.spec,
&command.spec.name,
&self.middleware.schema_registry,
));
self
}
pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
self.root = self.root.clone().subcommand(guide_command());
}
self.sync_guide_topic_values();
self.refresh_root_long();
self
}
pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
let mut seen = self
.guide_entries
.iter()
.map(|entry| entry.name.clone())
.collect::<BTreeSet<_>>();
for entry in entries {
if seen.insert(entry.name.clone()) {
self.guide_entries.push(entry);
}
}
if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
self.root = self.root.clone().subcommand(guide_command());
}
self.sync_guide_topic_values();
self.refresh_root_long();
self
}
fn sync_guide_topic_values(&mut self) {
if self.guide_entries.is_empty() {
return;
}
let names = self
.guide_entries
.iter()
.map(|entry| entry.name.clone())
.collect::<Vec<_>>();
if let Some(guide_cmd) = self.root.find_subcommand_mut("guide") {
let taken = std::mem::replace(guide_cmd, Command::new("guide"));
*guide_cmd = taken.mut_arg("topic", |arg| {
arg.value_parser(PossibleValuesParser::new(names))
});
}
}
async fn resolve_argv0(&self, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
if self.config.argv0_routes.is_empty() {
return Argv0Outcome::Proceed(text_args);
}
if depth > MAX_ARGV0_DEPTH {
return Argv0Outcome::Handled(
self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"),
);
}
let explicit = text_args.get(1).map(String::as_str) == Some("argv0");
let (name, rest) = if explicit {
match text_args.get(2) {
None => {
return Argv0Outcome::Handled(self.render_argv0_error(
&text_args,
"the argv0 command requires a name to dispatch as",
));
}
Some(name) => (
program_basename(name),
text_args
.get(3..)
.map(<[String]>::to_vec)
.unwrap_or_default(),
),
}
} else {
let name = text_args
.first()
.map(|arg| program_basename(arg))
.unwrap_or_default();
let rest = text_args
.get(1..)
.map(<[String]>::to_vec)
.unwrap_or_default();
(name, rest)
};
match self.config.argv0_routes.get(&name) {
Some(Argv0Route::Alias(tokens)) => {
let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len());
rewritten.push(self.config.name.clone());
rewritten.extend(tokens.iter().cloned());
rewritten.extend(rest);
Argv0Outcome::Proceed(rewritten)
}
Some(Argv0Route::Personality(build)) => {
let config = build();
let bin = config.name.clone();
let alt = Self::new(config);
let mut alt_args = Vec::with_capacity(1 + rest.len());
alt_args.push(bin);
alt_args.extend(rest);
Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await)
}
None if explicit => Argv0Outcome::Handled(self.render_argv0_error(
&text_args,
format!(
"{name:?} is not a registered argv0 name; known names: {}",
self.known_argv0_names()
),
)),
None => {
let mut rewritten = Vec::with_capacity(1 + rest.len());
rewritten.push(self.config.name.clone());
rewritten.extend(rest);
Argv0Outcome::Proceed(rewritten)
}
}
}
fn resolve_run_output_format(&self) -> String {
use std::io::IsTerminal;
let env = std::env::var(output_env_var(&self.config.app_id)).ok();
let engine_config = self.middleware.config.engine();
resolve_default_output_format(
env.as_deref(),
engine_config.output.format.as_deref(),
std::io::stdout().is_terminal(),
)
}
fn known_argv0_names(&self) -> String {
self.config
.argv0_routes
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
}
fn render_argv0_error(&self, text_args: &[String], message: impl Into<String>) -> CliRunOutput {
let mut middleware = self.middleware.clone();
middleware.output_format =
extract_output_format(text_args, &self.resolve_run_output_format());
let err = CliCoreError::message(message);
self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id))
}
#[must_use]
pub fn argv0_names(&self) -> Vec<&str> {
self.config
.argv0_routes
.keys()
.map(String::as_str)
.collect()
}
pub fn create_link(
&self,
name: &str,
dir: impl AsRef<Path>,
target: Option<&Path>,
method: Argv0LinkMethod,
) -> std::io::Result<PathBuf> {
if !self.config.argv0_routes.contains_key(name) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{name:?} is not a registered argv0 name"),
));
}
let dir = dir.as_ref();
std::fs::create_dir_all(dir)?;
let link = dir.join(argv0_link_file_name(name, method));
let resolved_target;
let target = match target {
Some(target) => target,
None => {
resolved_target = std::env::current_exe()?;
resolved_target.as_path()
}
};
if std::fs::symlink_metadata(&link).is_ok() {
if argv0_link_matches(&link, target, name, method)? {
return Ok(link);
}
std::fs::remove_file(&link)?;
}
match method {
Argv0LinkMethod::SoftLink => create_symlink(target, &link)?,
Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?,
Argv0LinkMethod::Script => {
std::fs::write(&link, argv0_script_contents(target, name))?;
make_executable(&link)?;
}
}
Ok(link)
}
pub async fn run<I, S>(&self, args: I) -> CliRunOutput
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
self.run_with_depth(args, 0).await
}
async fn run_with_depth<I, S>(&self, args: I, depth: usize) -> CliRunOutput
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
let raw_args = args
.into_iter()
.map(Into::into)
.collect::<Vec<std::ffi::OsString>>();
let text_args = raw_args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let text_args = match self.resolve_argv0(text_args, depth).await {
Argv0Outcome::Handled(output) => return output,
Argv0Outcome::Proceed(args) => args,
};
let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
if has_root_version_flag(&text_args, &self.root, &self.config.name) {
return self.finish_run(CliRunOutput {
exit_code: 0,
rendered: format!(
"{} version {}\n",
self.config.name,
self.config.build.version_string()
),
});
}
if let Some(output) = self.try_run_schema_bypass(&text_args) {
return output;
}
let bool_flags = derive_bool_flags(&self.root);
let value_flags = derive_value_flags(&self.root);
let positionals =
positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
let command_keyword_count =
command_keyword_count(&text_args, &self.config.name, &bool_flags, &value_flags);
if let Some(parts) =
group_help_target_parts(&self.root, &positionals, command_keyword_count)
{
clap_args = rewrite_group_help_args(
&clap_args,
&self.config.name,
&bool_flags,
&value_flags,
&parts,
);
} else if let Some(unknown) =
detect_unknown_group_command(&self.root, &positionals[..command_keyword_count])
{
if let Some(corrections) =
full_command_correction(&self.root, &positionals[..command_keyword_count])
{
let display = correction_display(
&self.config.name,
&positionals[..command_keyword_count],
&corrections,
);
let full_fix_message = format_did_you_mean(&unknown.base, &display);
match crate::prompt::confirm_command_correction(
&clap_args,
&display,
self.config.auto_interactive,
) {
crate::prompt::CommandCorrection::Accepted => {
for (index, replacement) in &corrections {
clap_args = replace_positional_command_token(
&clap_args,
&self.config.name,
&bool_flags,
&value_flags,
*index,
replacement,
);
}
clap_args = rewrite_group_help_if_needed(
&self.root,
&clap_args,
&self.config.name,
&bool_flags,
&value_flags,
);
}
crate::prompt::CommandCorrection::Declined => {
return self.finish_run(CliRunOutput {
exit_code: 1,
rendered: full_fix_message,
});
}
crate::prompt::CommandCorrection::Cancelled => {
return self.finish_run(CliRunOutput {
exit_code: 130,
rendered: "Cancelled.".to_owned(),
});
}
}
} else {
return self.finish_run(CliRunOutput {
exit_code: 1,
rendered: unknown.base,
});
}
}
let matches = match self.root.clone().try_get_matches_from(&clap_args) {
Ok(matches) => matches,
Err(err) => {
if let Some(recovery) = crate::prompt::try_recover_missing_args(
&err,
&clap_args,
&self.root,
&self.config.name,
self.config.auto_interactive,
) {
match recovery {
crate::prompt::RecoveryResult::Recovered { args } => {
match self.root.clone().try_get_matches_from(args) {
Ok(m) => m,
Err(retry_err) => {
return self.finish_run(CliRunOutput {
exit_code: retry_err.exit_code(),
rendered: retry_err.to_string(),
});
}
}
}
crate::prompt::RecoveryResult::Cancelled { resume } => {
return self.finish_run(CliRunOutput {
exit_code: 130,
rendered: format!("Cancelled. Resume with:\n {resume}\n"),
});
}
}
} else {
return self.finish_run(CliRunOutput {
exit_code: err.exit_code(),
rendered: err.to_string(),
});
}
}
};
let default_format = self.resolve_run_output_format();
let flags =
global_flags_from_matches(&matches, &default_format, self.config.auto_interactive);
crate::config::set_credential_store_flag(flags.credential_store);
let command_timeout = match parse_command_timeout(&flags.timeout) {
Ok(timeout) => timeout,
Err(err) => {
return self.finish_run(render_cli_error(
&self.middleware,
&err,
&self.config.app_id,
));
}
};
let mut middleware = self.middleware.clone();
apply_global_flags(&mut middleware, &flags, command_timeout);
install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
let command_path = command_path_from_matches(&self.config.name, &matches);
if command_path == "help" {
if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
{
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
return self.finish_run(self.render_help_command(&matches));
}
if command_path == "tree" {
if let Err(err) = self.run_pre_run(
&mut middleware,
&command_path,
&crate::middleware::ValueMap::new(),
) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
return self.finish_run(tree_render::render_tree(
&self.root,
&self.config.app_id,
&middleware,
));
}
if command_path == "guide" {
if let Err(err) =
self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
{
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
return self.finish_run(self.render_guide(&matches, &flags.output_format));
}
if command_path == "search" {
let args = search_args(&matches);
if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
let query = args
.get("query")
.and_then(|v| v.as_str())
.unwrap_or_default();
let scope_path = args
.get("scope")
.and_then(|v| v.as_str())
.unwrap_or_default();
let scope = self.resolve_search_scope(scope_path);
return self.finish_run(self.render_search(query, &scope, &flags.output_format));
}
if command_path == "completion" {
let args = completion_args(&matches);
if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
let install = args
.get("install")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let shell_opt = args
.get("shell")
.and_then(|v| v.as_str())
.map(str::to_owned);
if install {
use crate::cli::completion::{detect_shell, parse_shell};
let shell = match shell_opt {
Some(ref s) => match parse_shell(s) {
Ok(s) => s,
Err(e) => {
return self.finish_run(render_cli_error(
&middleware,
&e,
&self.config.app_id,
));
}
},
None => match detect_shell() {
Ok(s) => s,
Err(e) => {
return self.finish_run(render_cli_error(
&middleware,
&e,
&self.config.app_id,
));
}
},
};
return self.finish_run(
completion::install(&self.root, &self.config.name, shell)
.await
.unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
);
}
return self.finish_run(self.render_completion_print(shell_opt, &middleware));
}
let Some(command) = self.commands.get(&command_path) else {
if !command_path.is_empty()
&& let Some(group) = find_command_by_colon_path(&self.root, &command_path)
&& group.get_subcommands().next().is_some()
{
if let Err(err) = self.run_pre_run(
&mut middleware,
&command_path,
&crate::middleware::ValueMap::new(),
) {
return self.finish_run(render_cli_error(
&middleware,
&err,
&self.config.app_id,
));
}
return self.finish_run(self.render_bare_group_discovery(
group,
&command_path,
&middleware,
));
}
if command_path.is_empty()
&& let Some(root_next_actions) = &self.root_next_actions
{
let actions = root_next_actions();
return self.finish_run(self.render_root(&middleware, actions));
}
return self.finish_run(CliRunOutput {
exit_code: if command_path.is_empty() { 0 } else { 1 },
rendered: if command_path.is_empty() {
self.root.clone().render_long_help().to_string()
} else {
format!("unknown command {command_path:?}")
},
});
};
let mut middleware = match self.initialized_middleware() {
Ok(middleware) => middleware,
Err(err) => {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
};
apply_global_flags(&mut middleware, &flags, command_timeout);
install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
let leaf = leaf_matches(&matches);
apply_pagination_flags(&mut middleware, &command.spec, leaf);
let args = command_args_from_matches(leaf, &command.spec, false);
let user_args = command_args_from_matches(leaf, &command.spec, true);
let pagination_command = command.spec.pagination.is_some().then(|| {
pagination_command_base(
&self.config.name,
&command_path,
&command.spec,
&user_args,
&flags,
)
});
if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
}
let meta = self.resolve_meta(&command_path, command.spec.metadata());
let default_fields = command.spec.default_fields.clone().unwrap_or_default();
let system = command.spec.system.clone().unwrap_or_default();
let view_id = command
.spec
.view_id
.clone()
.or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
if let Some(streaming_handler) = command.streaming_handler.clone() {
let result = run_with_timeout(
command_timeout,
&flags.timeout,
run_streaming_command(
&middleware,
MiddlewareRequest {
meta,
command_path: &command_path,
system: &system,
user_args,
args,
default_fields: &default_fields,
view_id: view_id.as_deref(),
auth: command.spec.auth,
raw_output: command.spec.raw_output,
pagination_command,
},
Arc::new(leaf.clone()),
streaming_handler,
),
)
.await;
return self.finish_run(match result {
Ok(output) => output,
Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
});
}
let handler = command.handler.clone();
let args_for_handler = args.clone();
let user_args_for_handler = user_args.clone();
let handler_path = command_path.clone();
let middleware_for_handler = middleware.clone();
let raw_matches_for_handler = Arc::new(leaf.clone());
let result = run_with_timeout(
command_timeout,
&flags.timeout,
middleware.run(
MiddlewareRequest {
meta,
command_path: &command_path,
system: &system,
user_args,
args,
default_fields: &default_fields,
view_id: view_id.as_deref(),
auth: command.spec.auth,
raw_output: command.spec.raw_output,
pagination_command,
},
async move |credential| {
handler(CommandContext {
credential,
args: args_for_handler,
user_args: user_args_for_handler,
command_path: handler_path,
middleware: middleware_for_handler,
raw_matches: raw_matches_for_handler,
})
.await
},
),
)
.await;
match result {
Ok(output) => self.finish_run(output.into()),
Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
}
}
fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
if !has_true_schema_flag(args) {
return None;
}
let bool_flags = derive_bool_flags(&self.root);
let value_flags = derive_value_flags(&self.root);
let command_path =
self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
let command = find_command_by_colon_path(&self.root, &command_path)?;
if command.get_subcommands().next().is_some() {
return None;
}
let output_format = extract_output_format(args, &self.resolve_run_output_format());
match self.middleware.schema_registry.get_by_path(&command_path) {
Some(schema) => Some(self.render_schema(schema, &output_format)),
None => Some(self.render_schema(
crate::output::no_schema_response(&command_path),
&output_format,
)),
}
}
fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
let format: crate::output::OutputFormat = match output_format.parse() {
Ok(format) => format,
Err(err) => {
return CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
};
}
};
let envelope =
crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
match crate::output::render(format, &envelope) {
Ok(rendered) => CliRunOutput {
exit_code: 0,
rendered,
},
Err(err) => CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
},
}
}
fn render_bare_group_discovery(
&self,
group: &Command,
command_path: &str,
middleware: &Middleware,
) -> CliRunOutput {
let format: crate::output::OutputFormat = match middleware.output_format.parse() {
Ok(format) => format,
Err(err) => {
return CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
};
}
};
if format == crate::output::OutputFormat::Human {
return CliRunOutput {
exit_code: 0,
rendered: group.clone().render_long_help().to_string(),
};
}
let path = format!("{} {}", self.config.name, command_path.replace(':', " "));
let tree = crate::tree::build_tree_from_clap_with_path(group, path);
tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format)
}
fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
let format: crate::output::OutputFormat = match output_format.parse() {
Ok(format) => format,
Err(err) => {
return CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
};
}
};
let docs = self.search_documents(scope);
let results = SearchIndex::new(docs).search(query, 10);
let envelope =
crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
match crate::output::render(format, &envelope) {
Ok(rendered) => CliRunOutput {
exit_code: 0,
rendered,
},
Err(err) => CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
},
}
}
fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
if !crate::output::is_valid_output_format(&middleware.output_format) {
let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
return CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
};
}
let format = middleware
.output_format
.parse()
.unwrap_or(crate::output::OutputFormat::Json);
if format == crate::output::OutputFormat::Human {
let base_long = self
.root
.get_long_about()
.map(ToString::to_string)
.unwrap_or_default();
let long = format!("{base_long}{}", render_next_actions_human(&actions));
let rendered = self
.root
.clone()
.long_about(long)
.render_long_help()
.to_string();
return CliRunOutput {
exit_code: 0,
rendered,
};
}
let description = self
.config
.long
.as_deref()
.filter(|long| !long.is_empty())
.unwrap_or(self.config.short.as_str());
let data = serde_json::json!({
"description": description,
"version": self.config.build.version,
});
let envelope = crate::Envelope::success(data, self.config.app_id.clone())
.with_next_actions(actions)
.prepare_for_render(&middleware.verbose);
match crate::output::render(format, &envelope) {
Ok(rendered) => CliRunOutput {
exit_code: 0,
rendered,
},
Err(err) => CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
},
}
}
fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
.unwrap_or((&self.root, Vec::new()));
let mut docs = Vec::new();
let mut aliases = Vec::new();
append_command_alias_terms(scoped, &mut aliases);
collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
if scope.is_empty() {
for entry in &self.guide_entries {
docs.push(SearchDocument {
id: format!("guide:{}", entry.name),
kind: "guide".to_owned(),
title: format!("guide {}", entry.name),
summary: entry.summary.clone(),
content: format!("{} {}", entry.summary, entry.content),
});
}
if let Some(extra_search_docs) = &self.extra_search_docs {
docs.extend(extra_search_docs());
}
}
docs
}
fn resolve_search_scope(&self, scope_path: &str) -> String {
if scope_path.is_empty() {
return String::new();
}
let parts: Vec<String> = scope_path.split(':').map(str::to_owned).collect();
match canonical_path_from_parts(&self.root, &parts) {
Some(scope) => scope,
None => {
warn_unresolvable_search_scope(scope_path);
String::new()
}
}
}
fn canonical_command_path(&self, command_path: &str) -> String {
find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
|| command_path.to_owned(),
|(_, canonical)| canonical.join(":"),
)
}
fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput {
use std::io::IsTerminal;
if !crate::output::is_valid_output_format(output_format) {
let err = CliCoreError::InvalidOutputFormat(output_format.to_owned());
return CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
};
}
let leaf = leaf_matches(matches);
let topic = leaf.get_one::<String>("topic").map(String::as_str);
match guide_content(&self.guide_entries, topic) {
Ok(rendered) => {
let rendered = if topic.is_some() && output_format == "human" {
let is_tty = std::io::stdout().is_terminal();
render_guide_human(&rendered, crate::output::terminal_width(), is_tty)
} else {
rendered
};
CliRunOutput {
exit_code: 0,
rendered,
}
}
Err(err) => CliRunOutput {
exit_code: 1,
rendered: err,
},
}
}
fn render_completion_print(
&self,
shell_opt: Option<String>,
middleware: &Middleware,
) -> CliRunOutput {
use crate::cli::completion::{detect_shell, generate_script, parse_shell};
let shell = match shell_opt {
Some(s) => match parse_shell(&s) {
Ok(s) => s,
Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
},
None => match detect_shell() {
Ok(s) => s,
Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
},
};
match generate_script(&self.root, &self.config.name, shell) {
Ok(script) => CliRunOutput {
exit_code: 0,
rendered: script,
},
Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
}
}
fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
let leaf = leaf_matches(matches);
let parts = leaf
.get_many::<String>("command")
.map(|values| values.map(String::as_str).collect::<Vec<_>>())
.unwrap_or_default();
self.render_help_for_parts(&parts)
}
fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
if parts.is_empty() {
return CliRunOutput {
exit_code: 0,
rendered: self.root.clone().render_long_help().to_string(),
};
}
let Some(command) = find_help_target(&self.root, parts) else {
return CliRunOutput {
exit_code: 1,
rendered: format!(
"unknown command {:?} — run '{} help' for available commands",
parts.join(" "),
self.config.name
),
};
};
CliRunOutput {
exit_code: 0,
rendered: command.clone().render_long_help().to_string(),
}
}
fn refresh_root_long(&mut self) {
let builtins = BUILTIN_COMMAND_NAMES;
let categorized: BTreeSet<&str> = self
.module_entries
.iter()
.map(|entry| entry.name.as_str())
.collect();
let mut generic: Vec<ModuleHelpEntry> = self
.root
.get_subcommands()
.filter(|command| !command.is_hide_set())
.filter(|command| !builtins.contains(&command.get_name()))
.filter(|command| !categorized.contains(command.get_name()))
.map(|command| ModuleHelpEntry {
category: "Commands".to_owned(),
name: command.get_name().to_owned(),
short: command
.get_about()
.map(ToString::to_string)
.unwrap_or_default(),
})
.collect();
generic.sort_by(|left, right| left.name.cmp(&right.name));
let mut entries = self.module_entries.clone();
entries.extend(generic);
let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
let intro = self
.config
.long
.as_deref()
.filter(|long| !long.is_empty())
.unwrap_or(self.config.short.as_str());
self.root = self
.root
.clone()
.long_about(build_root_long(intro, &entries, has_guide));
}
fn ensure_auth_command(&mut self) {
let default_provider = self.default_auth_provider();
let registered_names = self.middleware.auth.registered_names();
if default_provider.is_empty() && registered_names.is_empty() {
return;
}
let replacing_builtin = self.commands.contains_key("auth:login");
if has_subcommand(&self.root, "auth") && !replacing_builtin {
return;
}
let mut group = auth_command_group(&default_provider, ®istered_names);
let mut seen_names: std::collections::HashSet<String> =
group.commands.iter().map(|c| c.spec.name.clone()).collect();
for extra in self.config.auth_extra_commands.clone() {
if !seen_names.insert(extra.spec.name.clone()) {
tracing::warn!(
command = %extra.spec.name,
"auth_extra_commands entry collides with a built-in auth subcommand or an \
earlier auth_extra_commands entry; ignoring"
);
continue;
}
group = group.with_command(extra);
}
let mut prefix = Vec::new();
register_runtime_group_metadata(
&group,
&mut prefix,
&mut self.middleware.schema_registry,
&mut self.middleware.human_views,
);
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
&mut prefix,
&self.middleware.schema_registry,
);
self.root = if replacing_builtin {
self.root.clone().mut_subcommand("auth", |_| clap_group)
} else {
self.root.clone().subcommand(clap_group)
};
self.register_auth_help_entry();
}
fn ensure_config_command(&mut self) {
if has_subcommand(&self.root, "config") {
return;
}
let group = crate::config_commands::config_command_group();
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
&mut prefix,
&self.middleware.schema_registry,
);
self.root = self.root.clone().subcommand(clap_group);
let category = self
.config
.admin_category
.clone()
.unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
if !self
.module_entries
.iter()
.any(|entry| entry.name == "config")
{
self.module_entries.push(ModuleHelpEntry {
category,
name: "config".to_owned(),
short: "Read and write the CLI config file".to_owned(),
});
}
self.refresh_root_long();
}
fn ensure_env_command(&mut self) {
if has_subcommand(&self.root, "env") {
return;
}
let group = crate::env_commands::env_command_group();
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
&mut prefix,
&self.middleware.schema_registry,
);
self.root = self.root.clone().subcommand(clap_group);
let category = self
.config
.admin_category
.clone()
.unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
if !self.module_entries.iter().any(|e| e.name == "env") {
self.module_entries.push(ModuleHelpEntry {
category,
name: "env".to_owned(),
short: "Manage the active environment".to_owned(),
});
}
self.refresh_root_long();
}
fn ensure_flags_command(&mut self) {
if has_subcommand(&self.root, "flags") {
return;
}
let group = crate::flag_commands::flags_command_group();
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
&mut prefix,
&self.middleware.schema_registry,
);
self.root = self.root.clone().subcommand(clap_group);
let category = self
.config
.admin_category
.clone()
.unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
if !self.module_entries.iter().any(|e| e.name == "flags") {
self.module_entries.push(ModuleHelpEntry {
category,
name: "flags".to_owned(),
short: "Inspect declared feature flags".to_owned(),
});
}
self.refresh_root_long();
}
fn default_auth_provider(&self) -> String {
if !self.middleware.default_auth_provider.is_empty() {
return self.middleware.default_auth_provider.clone();
}
self.middleware
.auth
.registered_names()
.into_iter()
.next()
.unwrap_or_default()
}
fn initialized_middleware(&self) -> Result<Middleware> {
let Some(init_deps) = &self.init_deps else {
return Ok(self.middleware.clone());
};
let mut guard = self
.init_state
.lock()
.map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
if let Some(result) = guard.as_ref() {
return result.clone().map_err(InitFailure::into_error);
}
let mut middleware = self.middleware.clone();
let result = init_deps(&mut middleware)
.map(|()| middleware)
.map_err(|err| InitFailure::capture(&err));
*guard = Some(result.clone());
result.map_err(InitFailure::into_error)
}
fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
if let Some(apply_flags) = &self.apply_flags {
apply_flags(matches, middleware)?;
}
Ok(())
}
fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
let Some(environments) = middleware.environments.as_ref() else {
return Ok(());
};
if let Some(env) = matches.get_one::<String>("env") {
environments.source(env)?;
middleware.env = env.clone();
}
Ok(())
}
fn run_pre_run(
&self,
middleware: &mut Middleware,
command_path: &str,
args: &crate::middleware::ValueMap,
) -> Result<()> {
if let Some(pre_run) = &self.pre_run {
pre_run(middleware, command_path, args)?;
}
Ok(())
}
fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
if let Some(resolver) = &self.meta_resolver {
resolver(command_path, meta)
} else {
meta
}
}
fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
crate::config::clear_credential_store_flag();
if let Some(on_shutdown) = &self.on_shutdown {
on_shutdown();
}
output
}
}
fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
middleware.output_format = flags.output_format.clone();
middleware.verbose = flags.verbose.clone();
middleware.dry_run = flags.dry_run;
middleware.fields = flags.fields.clone();
middleware.fields_explicit = flags.fields_explicit;
middleware.filter = flags.filter.clone();
middleware.expr = flags.expr.clone();
middleware.reason = flags.reason.clone();
middleware.schema = flags.schema;
middleware.timeout = timeout;
middleware.debug = flags.debug.clone();
middleware.interactive = flags.interactive;
}
fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) {
let Some(pagination) = spec.pagination else {
return;
};
middleware.limit = leaf
.get_one::<i64>("limit")
.copied()
.unwrap_or(pagination.default_limit);
middleware.offset = leaf.get_one::<i64>("offset").copied().unwrap_or(0);
}
fn pagination_command_base(
binary_name: &str,
command_path: &str,
spec: &CommandSpec,
user_args: &crate::middleware::ValueMap,
flags: &GlobalFlags,
) -> String {
let mut parts = vec![
quote_pagination_value(binary_name),
command_path.replace(':', " "),
];
for arg in &spec.args {
let id = arg.get_id().as_str();
if let Some(value) = user_args.get(id) {
push_pagination_arg(&mut parts, arg, value);
}
}
for (flag, value) in [
("--filter", &flags.filter),
("--expr", &flags.expr),
("--fields", &flags.fields),
] {
if !value.is_empty() {
parts.push(flag.to_owned());
parts.push(quote_pagination_value(value));
}
}
parts.join(" ")
}
fn push_pagination_arg(parts: &mut Vec<String>, arg: &Arg, value: &serde_json::Value) {
let flag = arg
.get_long()
.map(|long| format!("--{long}"))
.or_else(|| arg.get_short().map(|short| format!("-{short}")));
match value {
serde_json::Value::Bool(enabled) => {
if matches!(
arg.get_action(),
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
) {
if let Some(flag) = flag {
parts.push(flag);
}
} else {
push_flagged_value(parts, flag, &enabled.to_string());
}
}
serde_json::Value::Array(items) => {
for item in items {
push_flagged_value(parts, flag.clone(), &pagination_arg_display(item));
}
}
serde_json::Value::Null => {}
other => push_flagged_value(parts, flag, &pagination_arg_display(other)),
}
}
fn push_flagged_value(parts: &mut Vec<String>, flag: Option<String>, value: &str) {
if let Some(flag) = flag {
parts.push(flag);
}
parts.push(quote_pagination_value(value));
}
fn pagination_arg_display(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn quote_pagination_value(value: &str) -> String {
let safe_unquoted =
|c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@');
if value.is_empty() || !value.chars().all(safe_unquoted) {
let escaped = value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`");
format!("\"{escaped}\"")
} else {
value.to_owned()
}
}
fn debug_transport_logger_for(
debug: &str,
extra_redacted: &[String],
) -> Arc<dyn crate::transport::TransportLogger> {
if crate::debug_component_enabled(debug, "transport") {
Arc::new(
crate::transport::StderrTransportLogger::new()
.with_redacted_headers(extra_redacted.iter().cloned()),
)
} else {
Arc::new(crate::transport::NoopTransportLogger)
}
}
fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
crate::transport::set_default_transport_logger(debug_transport_logger_for(
debug,
extra_redacted,
));
}
async fn run_with_timeout<F, T>(
timeout: Option<Duration>,
timeout_label: &str,
future: F,
) -> Result<T>
where
F: Future<Output = Result<T>>,
{
let Some(timeout) = timeout else {
return future.await;
};
match tokio::time::timeout(timeout, future).await {
Ok(result) => result,
Err(_) => Err(CliCoreError::message(format!(
"command timed out after {timeout_label}"
))),
}
}
async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
where
Run: Future<Output = CliRunOutput>,
Shutdown: Future<Output = ()>,
{
tokio::pin!(run);
tokio::pin!(shutdown);
tokio::select! {
output = &mut run => output,
() = &mut shutdown => CliRunOutput {
exit_code: 130,
rendered: "command interrupted\n".to_owned(),
},
}
}
#[cfg(unix)]
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
_ = ctrl_c => {},
_ = sigterm.recv() => {},
}
}
Err(_) => {
drop(ctrl_c.await);
}
}
}
#[cfg(not(unix))]
async fn shutdown_signal() {
drop(tokio::signal::ctrl_c().await);
}
fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
let raw = raw.trim();
if raw.is_empty() {
return Ok(Some(Duration::from_secs(60)));
}
let Some(seconds) = parse_duration_seconds(raw) else {
return Err(CliCoreError::message(format!(
"invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
)));
};
if seconds <= 0.0 {
Ok(None)
} else {
Ok(Some(Duration::from_secs_f64(seconds)))
}
}
fn parse_duration_seconds(raw: &str) -> Option<f64> {
for (suffix, seconds) in [
("ns", 0.000_000_001_f64),
("us", 0.000_001_f64),
("µs", 0.000_001_f64),
("ms", 0.001_f64),
("s", 1.0_f64),
("m", 60.0_f64),
("h", 3600.0_f64),
] {
if let Some(number) = raw.strip_suffix(suffix) {
let value = number.parse::<f64>().ok()?;
if !value.is_finite() {
return None;
}
return Some(value * seconds);
}
}
None
}
fn global_min_stage_override(app_id: &str) -> Option<Stage> {
let var = min_stage_env_var(app_id);
let value = std::env::var(&var).ok()?;
value.parse::<Stage>().map_or_else(
|err| {
tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override");
None
},
Some,
)
}
fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option<String> {
let mut result = None;
while let Some(arg) = args.next() {
if arg == "--" {
break;
}
let value = if let Some(v) = arg.strip_prefix("--env=") {
Some(v.to_owned())
} else if arg == "--env" {
args.next().filter(|v| !v.starts_with('-'))
} else {
None
};
if let Some(v) = value.filter(|v| !v.is_empty()) {
result = Some(v);
}
}
result
}
fn render_cli_error(
middleware: &Middleware,
err: &(dyn std::error::Error + 'static),
system: &str,
) -> CliRunOutput {
let format = middleware
.output_format
.parse::<crate::output::OutputFormat>()
.unwrap_or(crate::output::OutputFormat::Json);
let envelope =
crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
match crate::output::render(format, &envelope) {
Ok(rendered) => CliRunOutput {
exit_code: exit_code_for_error(err),
rendered,
},
Err(render_err) => CliRunOutput {
exit_code: exit_code_for_error(err),
rendered: render_err.to_string(),
},
}
}
fn find_command_by_colon_path<'command>(
root: &'command Command,
path: &str,
) -> Option<&'command Command> {
find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
}
fn find_help_target<'command>(
root: &'command Command,
parts: &[&str],
) -> Option<&'command Command> {
let mut current = root;
let mut matched_any = false;
for part in parts {
let Some(next) = current.find_subcommand(part) else {
break;
};
current = next;
matched_any = true;
}
matched_any.then_some(current)
}
fn find_command_and_canonical_path_by_colon_path<'command>(
root: &'command Command,
path: &str,
) -> Option<(&'command Command, Vec<String>)> {
if path.is_empty() {
return Some((root, Vec::new()));
}
let mut current = root;
let mut canonical = Vec::new();
for part in path.split(':') {
current = current.find_subcommand(part)?;
canonical.push(current.get_name().to_owned());
}
Some((current, canonical))
}
fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
if parts.is_empty() {
return Some(String::new());
}
let mut current = root;
let mut canonical = Vec::new();
for part in parts {
current = current.find_subcommand(part)?;
canonical.push(current.get_name().to_owned());
}
Some(canonical.join(":"))
}
fn warn_unresolvable_search_scope(scope_path: &str) {
let mut stderr = std::io::stderr().lock();
stderr
.write_all(
format!(
"warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n"
)
.as_bytes(),
)
.ok();
}
fn collect_command_search_documents(
command: &Command,
prefix: &mut Vec<String>,
aliases: &mut Vec<String>,
docs: &mut Vec<SearchDocument>,
) {
if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
return;
}
if command.get_subcommands().next().is_some() {
for child in command.get_subcommands() {
prefix.push(child.get_name().to_owned());
let alias_len = aliases.len();
append_command_alias_terms(child, aliases);
collect_command_search_documents(child, prefix, aliases, docs);
aliases.truncate(alias_len);
prefix.pop();
}
return;
}
if prefix.is_empty() {
prefix.push(command.get_name().to_owned());
append_command_alias_terms(command, aliases);
}
let path = prefix.join(" ");
let alias_text = aliases.join(" ");
docs.push(SearchDocument {
id: format!("cmd:{path}"),
kind: "command".to_owned(),
title: path,
summary: command
.get_about()
.map(ToString::to_string)
.unwrap_or_default(),
content: format!(
"{} {} {} {}",
command
.get_about()
.map(ToString::to_string)
.unwrap_or_default(),
command
.get_long_about()
.map(ToString::to_string)
.unwrap_or_default(),
command_flag_text(command),
alias_text
),
});
if prefix.len() == 1 && prefix[0] == command.get_name() {
prefix.pop();
}
}
fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
aliases.extend(command.get_all_aliases().map(str::to_owned));
aliases.extend(
command
.get_all_short_flag_aliases()
.map(|alias| alias.to_string()),
);
aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
}
fn command_flag_text(command: &Command) -> String {
command
.get_arguments()
.filter(|arg| !arg.is_hide_set())
.filter_map(|arg| {
let mut names = Vec::new();
if let Some(short) = arg.get_short() {
names.push(format!("-{short}"));
}
if let Some(long) = arg.get_long() {
names.push(format!("--{long}"));
}
if let Some(short_aliases) = arg.get_all_short_aliases() {
names.extend(
short_aliases
.into_iter()
.map(|short_alias| format!("-{short_alias}")),
);
}
if let Some(aliases) = arg.get_all_aliases() {
names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
}
(!names.is_empty()).then(|| names.join(" "))
})
.collect::<Vec<_>>()
.join(" ")
}
fn has_subcommand(command: &Command, name: &str) -> bool {
command
.get_subcommands()
.any(|child| child.get_name() == name)
}
fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
let bool_flags = derive_bool_flags(root);
let value_flags = derive_value_flags(root);
let mut iter = args.iter().peekable();
if iter
.peek()
.is_some_and(|arg| arg_matches_root_name(arg, root_name))
{
iter.next();
}
while let Some(arg) = iter.next() {
match arg.as_str() {
"--version" | "-v" => return true,
"--" => return false,
value if value.contains('=') || bool_flags.contains(value) => continue,
value
if value_flags.contains(value)
|| unknown_flag_consumes_value(value, iter.peek()) =>
{
iter.next();
}
value if value.starts_with('-') => {}
_ => return false,
}
}
false
}
fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
let mut normalized = Vec::with_capacity(args.len());
let mut index = 0;
let mut current = root;
while index < args.len() {
let arg = &args[index];
if index == 0 && arg_matches_root_name(arg, root.get_name()) {
normalized.push(arg.clone());
index += 1;
continue;
}
if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
normalized.push(format!("{arg}={default}"));
index += 1;
continue;
}
if let Some(default) = optional_string_defaults.get(arg.as_str()) {
match args.get(index + 1) {
None => {
normalized.push(format!("{arg}={default}"));
index += 1;
continue;
}
Some(next)
if current.get_name() == root.get_name()
|| next.starts_with('-')
|| direct_subcommand(current, next).is_some() =>
{
normalized.push(format!("{arg}={default}"));
index += 1;
continue;
}
Some(next) => {
normalized.push(arg.clone());
normalized.push(next.clone());
index += 2;
continue;
}
}
}
normalized.push(arg.clone());
if !arg.starts_with('-')
&& let Some(next_command) = direct_subcommand(current, arg)
{
current = next_command;
}
index += 1;
}
normalized
}
fn direct_subcommand<'command>(
command: &'command Command,
token: &str,
) -> Option<&'command Command> {
command.get_subcommands().find(|child| {
child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
})
}
fn format_did_you_mean(base: &str, suggestion: &str) -> String {
format!("{base} — did you mean {suggestion:?}?")
}
struct UnknownGroupCommand {
base: String,
}
fn detect_unknown_group_command(
root: &Command,
positionals: &[String],
) -> Option<UnknownGroupCommand> {
if positionals.is_empty() {
return None;
}
let mut current = root;
let mut path = vec![root.get_name().to_owned()];
for token in positionals {
if let Some(next) = current.find_subcommand(token) {
current = next;
path.push(next.get_name().to_owned());
continue;
}
if current.get_subcommands().next().is_some() {
let base = format!("unknown command {token:?} for {:?}", path.join(" "));
return Some(UnknownGroupCommand { base });
}
return None;
}
None
}
fn command_keyword_count(
args: &[String],
root_name: &str,
bool_flags: &BTreeSet<String>,
value_flags: &BTreeSet<String>,
) -> usize {
let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags);
match args.iter().position(|arg| arg == "--") {
Some(end) => {
positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len()
}
None => positionals.len(),
}
}
fn rewrite_group_help_if_needed(
root: &Command,
clap_args: &[String],
root_name: &str,
bool_flags: &BTreeSet<String>,
value_flags: &BTreeSet<String>,
) -> Vec<String> {
let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags);
let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags);
let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else {
return clap_args.to_vec();
};
rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts)
}
fn replace_positional_command_token(
args: &[String],
root_name: &str,
bool_flags: &BTreeSet<String>,
value_flags: &BTreeSet<String>,
target: usize,
replacement: &str,
) -> Vec<String> {
let mut out = args.to_vec();
let mut index = 0;
if out
.first()
.is_some_and(|arg| arg_matches_root_name(arg, root_name))
{
index = 1;
}
let mut positional = 0;
while index < out.len() {
let arg = &out[index];
if arg == "--" {
break;
}
if arg.contains('=') {
index += 1;
continue;
}
if bool_flags.contains(arg) {
index += 1;
continue;
}
if value_flags.contains(arg)
|| unknown_flag_consumes_value(arg, out.get(index + 1).as_ref())
{
index += 2;
continue;
}
if arg.starts_with('-') {
index += 1;
continue;
}
if positional == target {
out[index] = replacement.to_owned();
break;
}
positional += 1;
index += 1;
}
out
}
fn nearest_subcommand(command: &Command, token: &str) -> Option<String> {
let token = token.to_ascii_lowercase();
let max_distance = 1.max(token.chars().count() / 3);
command
.get_subcommands()
.filter(|child| !child.is_hide_set())
.filter_map(|child| {
let best = std::iter::once(child.get_name())
.chain(child.get_all_aliases())
.map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase()))
.min()?;
(best <= max_distance).then(|| (best, child.get_name().to_owned()))
})
.min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)))
.map(|(_, name)| name)
}
fn full_command_correction(root: &Command, positionals: &[String]) -> Option<Vec<(usize, String)>> {
let mut current = root;
let mut corrections = Vec::new();
for (index, token) in positionals.iter().enumerate() {
if let Some(next) = current.find_subcommand(token) {
current = next;
continue;
}
if current.get_subcommands().next().is_none() {
break;
}
if token == "help" && current.find_subcommand("help").is_none() {
break;
}
let suggestion = nearest_subcommand(current, token)?;
let next = current.find_subcommand(&suggestion)?;
corrections.push((index, suggestion));
current = next;
}
(!corrections.is_empty()).then_some(corrections)
}
fn correction_display(
root_name: &str,
positionals: &[String],
corrections: &[(usize, String)],
) -> String {
if let [(index, only)] = corrections
&& *index + 1 == positionals.len()
{
return only.clone();
}
let mut tokens = vec![root_name.to_owned()];
for (index, token) in positionals.iter().enumerate() {
let corrected = corrections
.iter()
.find(|(i, _)| *i == index)
.map(|(_, replacement)| replacement.clone())
.unwrap_or_else(|| token.clone());
tokens.push(corrected);
}
tokens.join(" ")
}
#[cfg(test)]
mod unknown_command_suggestion_tests {
use super::*;
fn sample_group() -> Command {
Command::new("gddy").subcommand(
Command::new("domain")
.alias("dns-domain")
.subcommand(Command::new("list"))
.subcommand(Command::new("available")),
)
}
#[test]
fn osa_distance_treats_adjacent_transposition_as_one_edit() {
assert_eq!(strsim::osa_distance("domain", "domain"), 0);
assert_eq!(strsim::osa_distance("domian", "domain"), 1);
assert_eq!(strsim::osa_distance("lst", "list"), 1);
assert_eq!(strsim::osa_distance("lsit", "list"), 1);
assert_eq!(strsim::osa_distance("cat", "set"), 2);
}
#[test]
fn nearest_subcommand_matches_close_typos() {
let root = sample_group();
let domain = root.find_subcommand("domain").expect("domain registered");
assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list"));
assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list"));
assert_eq!(
nearest_subcommand(domain, "avaliable").as_deref(),
Some("available")
);
}
#[test]
fn nearest_subcommand_rejects_unrelated_tokens() {
let root = sample_group();
let domain = root.find_subcommand("domain").expect("domain registered");
assert_eq!(nearest_subcommand(domain, "missing"), None);
}
#[test]
fn nearest_subcommand_returns_canonical_name_for_alias_typos() {
let root = sample_group();
assert_eq!(
nearest_subcommand(&root, "dns-domian").as_deref(),
Some("domain")
);
}
#[test]
fn nearest_subcommand_skips_hidden_commands() {
let root = Command::new("gddy")
.subcommand(Command::new("visible"))
.subcommand(Command::new("hiddeen").hide(true));
assert_eq!(nearest_subcommand(&root, "hidden"), None);
}
#[test]
fn nearest_subcommand_rejects_short_unrelated_tokens() {
let root = Command::new("gddy").subcommand(
Command::new("config")
.subcommand(Command::new("get"))
.subcommand(Command::new("set"))
.subcommand(Command::new("add")),
);
let config = root.find_subcommand("config").expect("config registered");
assert_eq!(nearest_subcommand(config, "cat"), None);
assert_eq!(nearest_subcommand(config, "x"), None);
assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set"));
}
#[test]
fn unknown_group_command_formats_did_you_mean_suffix() {
let root = sample_group();
let unknown = detect_unknown_group_command(&root, &["domian".to_owned()])
.expect("domian is an unknown top-level command");
assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\"");
assert_eq!(
format_did_you_mean(&unknown.base, "domain"),
"unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?"
);
}
#[test]
fn detect_unknown_group_command_reports_nested_typos() {
let root = sample_group();
let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()])
.expect("lst is an unknown subcommand of domain");
assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\"");
assert_eq!(
format_did_you_mean(&unknown.base, "list"),
"unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?"
);
}
#[test]
fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() {
let root = sample_group();
let unknown = detect_unknown_group_command(&root, &["missing".to_owned()])
.expect("missing is an unknown top-level command");
assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\"");
}
#[test]
fn full_command_correction_fixes_a_single_group_typo() {
let root = sample_group();
let corrections = full_command_correction(&root, &["domian".to_owned()])
.expect("domian is correctable to domain");
assert_eq!(corrections, vec![(0, "domain".to_owned())]);
}
#[test]
fn full_command_correction_fixes_every_typo_in_a_nested_path() {
let root = sample_group();
let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()])
.expect("both tokens are correctable");
assert_eq!(
corrections,
vec![(0, "domain".to_owned()), (1, "list".to_owned())]
);
}
#[test]
fn full_command_correction_bails_when_a_token_has_no_near_match() {
let root = sample_group();
assert_eq!(
full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]),
None
);
}
#[test]
fn full_command_correction_is_none_when_there_is_nothing_to_correct() {
let root = sample_group();
assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None);
assert_eq!(full_command_correction(&root, &[]), None);
}
#[test]
fn full_command_correction_corrects_the_group_before_curated_help() {
let root = sample_group();
let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()])
.expect("domian is correctable even ahead of a help token");
assert_eq!(corrections, vec![(0, "domain".to_owned())]);
}
#[test]
fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() {
let root = sample_group();
let corrections = full_command_correction(
&root,
&[
"domain".to_owned(),
"avaliable".to_owned(),
"example.com".to_owned(),
],
)
.expect("avaliable is correctable to available");
assert_eq!(corrections, vec![(1, "available".to_owned())]);
}
#[test]
fn correction_display_shows_the_bare_token_for_a_single_fix() {
let corrections = vec![(1, "list".to_owned())];
assert_eq!(
correction_display(
"gddy",
&["domain".to_owned(), "lst".to_owned()],
&corrections
),
"list"
);
}
#[test]
fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() {
let corrections = vec![(0, "domain".to_owned())];
assert_eq!(
correction_display(
"gddy",
&["domian".to_owned(), "list".to_owned()],
&corrections
),
"gddy domain list"
);
}
#[test]
fn correction_display_shows_the_full_command_for_multiple_fixes() {
let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())];
assert_eq!(
correction_display(
"gddy",
&["domian".to_owned(), "lst".to_owned()],
&corrections
),
"gddy domain list"
);
}
#[test]
fn replace_positional_command_token_rewrites_only_the_target() {
let bool_flags: BTreeSet<String> = ["--verbose".to_owned()].into_iter().collect();
let value_flags: BTreeSet<String> = ["--output".to_owned()].into_iter().collect();
let args = vec![
"gddy".to_owned(),
"--output".to_owned(),
"json".to_owned(),
"domain".to_owned(),
"lst".to_owned(),
];
let corrected =
replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list");
assert_eq!(
corrected,
vec!["gddy", "--output", "json", "domain", "list"]
);
}
#[test]
fn rewrite_group_help_if_needed_runs_after_typo_correction() {
let root = sample_group();
let bool_flags = derive_bool_flags(&root);
let value_flags = derive_value_flags(&root);
let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()];
let corrected =
replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain");
assert_eq!(corrected, vec!["gddy", "domain", "help"]);
let rewritten =
rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags);
assert_eq!(rewritten, vec!["gddy", "help", "domain"]);
}
}
fn group_help_target_parts(
root: &Command,
positionals: &[String],
command_keyword_count: usize,
) -> Option<Vec<String>> {
let help_index = positionals.iter().position(|token| token == "help")?;
if help_index == 0 {
return None;
}
if help_index >= command_keyword_count {
return None;
}
let prefix = &positionals[..help_index];
let mut current = root;
for token in prefix {
current = current.find_subcommand(token)?;
}
current.get_subcommands().next()?;
if current.find_subcommand("help").is_some() {
return None;
}
let suffix = &positionals[help_index + 1..];
Some(prefix.iter().chain(suffix).cloned().collect())
}
fn rewrite_group_help_args(
clap_args: &[String],
root_name: &str,
bool_flags: &BTreeSet<String>,
value_flags: &BTreeSet<String>,
parts: &[String],
) -> Vec<String> {
let mut next_positional = std::iter::once("help".to_owned())
.chain(parts.iter().cloned())
.peekable();
let mut out = Vec::with_capacity(clap_args.len());
let mut iter = clap_args.iter().peekable();
if iter
.peek()
.is_some_and(|arg| arg_matches_root_name(arg, root_name))
&& let Some(program) = iter.next()
{
out.push(program.clone());
}
let mut take_positional =
|fallback: &String| next_positional.next().unwrap_or(fallback.clone());
while let Some(arg) = iter.next() {
if arg == "--" {
out.push(arg.clone());
for rest in iter.by_ref() {
out.push(take_positional(rest));
}
break;
}
if arg.contains('=') || bool_flags.contains(arg) {
out.push(arg.clone());
continue;
}
if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
out.push(arg.clone());
if let Some(value) = iter.next() {
out.push(value.clone());
}
continue;
}
if arg.starts_with('-') {
out.push(arg.clone());
continue;
}
out.push(take_positional(arg));
}
out.extend(next_positional);
out
}
fn positional_command_tokens(
args: &[String],
root_name: &str,
bool_flags: &BTreeSet<String>,
value_flags: &BTreeSet<String>,
) -> Vec<String> {
let mut tokens = Vec::new();
let mut iter = args.iter().peekable();
if iter
.peek()
.is_some_and(|arg| arg_matches_root_name(arg, root_name))
{
iter.next();
}
while let Some(arg) = iter.next() {
if arg == "--" {
tokens.extend(iter.cloned());
break;
}
if arg.contains('=') {
continue;
}
if bool_flags.contains(arg) {
continue;
}
if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
iter.next();
continue;
}
if arg.starts_with('-') {
continue;
}
tokens.push(arg.clone());
}
tokens
}
fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
}
fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
arg == root_name
|| Path::new(arg)
.file_stem()
.and_then(|n| n.to_str())
.is_some_and(|n| n == root_name)
}
enum Argv0Outcome {
Proceed(Vec<String>),
Handled(CliRunOutput),
}
fn program_basename(arg: &str) -> String {
Path::new(arg)
.file_stem()
.and_then(|stem| stem.to_str())
.map_or_else(|| arg.to_owned(), ToOwned::to_owned)
}
fn is_valid_argv0_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|character| {
character.is_ascii_alphanumeric() || character == '-' || character == '_'
})
}
fn argv0_link_matches(
link: &Path,
target: &Path,
name: &str,
method: Argv0LinkMethod,
) -> std::io::Result<bool> {
let metadata = std::fs::symlink_metadata(link)?;
match method {
Argv0LinkMethod::SoftLink => {
Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
}
Argv0LinkMethod::HardLink => {
if metadata.file_type().is_symlink() {
return Ok(false);
}
Ok(std::fs::read(link)? == std::fs::read(target)?)
}
Argv0LinkMethod::Script => {
if metadata.file_type().is_symlink() {
return Ok(false);
}
Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
}
}
}
fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
let extension = match method {
Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
Argv0LinkMethod::Script => "",
_ if cfg!(windows) => ".exe",
_ => "",
};
format!("{name}{extension}")
}
fn argv0_script_contents(target: &Path, name: &str) -> String {
let target = target.display();
if cfg!(windows) {
format!("@\"{target}\" argv0 {name} %*\r\n")
} else {
format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
}
}
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(target, link)
}
#[cfg(not(any(unix, windows)))]
fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlink creation is not supported on this platform",
))
}
#[cfg(unix)]
fn make_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions)
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn prune_feature_flag_tree(
mut group: RuntimeGroupSpec,
inherited: Option<&FeatureFlag>,
policy: &FlagPolicy,
prefix: &mut Vec<String>,
registry: &mut FlagRegistry,
) -> Option<RuntimeGroupSpec> {
prefix.push(group.group.name.clone());
let effective = group
.group
.feature_flag
.clone()
.or_else(|| inherited.cloned());
if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) {
prefix.pop();
return None;
}
let mut kept_groups = Vec::with_capacity(group.groups.len());
for child in std::mem::take(&mut group.groups) {
if let Some(pruned) =
prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry)
{
kept_groups.push(pruned);
}
}
group.groups = kept_groups;
let mut kept_commands = Vec::with_capacity(group.commands.len());
for command in std::mem::take(&mut group.commands) {
prefix.push(command.spec.name.clone());
let command_effective = command
.spec
.feature_flag
.clone()
.or_else(|| effective.clone());
let visible =
record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry);
prefix.pop();
if visible {
kept_commands.push(command);
}
}
group.commands = kept_commands;
prefix.pop();
if group.commands.is_empty() && group.groups.is_empty() {
None
} else {
Some(group)
}
}
fn record_and_check_visibility(
effective: Option<&FeatureFlag>,
policy: &FlagPolicy,
prefix: &[String],
registry: &mut FlagRegistry,
) -> bool {
let Some(flag) = effective else {
return true;
};
let visible = policy.visible(Some(flag.key.as_str()), flag.stage);
registry.record(FlagEntry {
path: prefix.join(":"),
key: flag.key.clone(),
stage: flag.stage,
visible,
});
visible
}
fn register_runtime_group_metadata(
group: &RuntimeGroupSpec,
prefix: &mut Vec<String>,
schemas: &mut SchemaRegistry,
views: &mut HumanViewRegistry,
) {
prefix.push(group.group.name.clone());
for child_group in &group.groups {
register_runtime_group_metadata(child_group, prefix, schemas, views);
}
for child in &group.commands {
prefix.push(child.spec.name.clone());
let command_path = prefix.join(":");
register_command_schema(&child.spec, &command_path, schemas);
if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
views.register(HumanViewDef::new(
command_path,
child.spec.view_columns.clone(),
));
}
prefix.pop();
}
prefix.pop();
}
fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
if let Some(schema) = &spec.output_schema {
schemas.register_info(command_path.to_owned(), schema.clone());
}
}
fn runtime_group_clap_command_with_schema_help(
group: &RuntimeGroupSpec,
prefix: &mut Vec<String>,
schemas: &SchemaRegistry,
) -> Command {
let mut command = group_clap_command_without_children(&group.group);
prefix.push(group.group.name.clone());
for child_group in &group.groups {
command = command.subcommand(runtime_group_clap_command_with_schema_help(
child_group,
prefix,
schemas,
));
}
for child in &group.commands {
prefix.push(child.spec.name.clone());
let command_path = prefix.join(":");
command = command.subcommand(command_clap_command_with_schema_help(
&child.spec,
&command_path,
schemas,
));
prefix.pop();
}
prefix.pop();
command
}
fn group_clap_command_without_children(group: &GroupSpec) -> Command {
let mut command = Command::new(group.name.clone())
.about(group.short.clone())
.help_template(GROUP_HELP_TEMPLATE);
if let Some(long) = &group.long
&& !long.is_empty()
{
command = command.long_about(long.clone());
}
for alias in &group.aliases {
command = command.alias(alias.clone());
}
if group.hidden {
command = command.hide(true);
}
command
}
fn command_clap_command_with_schema_help(
spec: &CommandSpec,
command_path: &str,
schemas: &SchemaRegistry,
) -> Command {
debug_assert!(
!(spec.raw_output && spec.pagination.is_some()),
"command {:?} sets both raw_output and with_pagination; a single verbatim string \
has no pages, so the two are mutually exclusive",
spec.name
);
let mut command = spec.clap_command();
command = apply_dry_run_visibility(command, spec);
command = apply_pagination_args(command, spec);
let schema = schemas.get_by_path(command_path);
let default_fields = default_field_names(spec);
command = apply_fields_arg(
command,
spec,
schema.as_ref().map(|schema| schema.fields.as_slice()),
&default_fields,
);
command = apply_output_format_visibility(command, spec);
let filter_expr_fields = schema
.as_ref()
.map_or(&[][..], |schema| schema.fields.as_slice());
apply_filter_and_expr_examples(command, spec, filter_expr_fields)
}
fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command {
if !spec.raw_output {
return command;
}
use std::io::IsTerminal;
command.arg(
Arg::new("output")
.long("output")
.short('o')
.value_name("FORMAT")
.default_value(if std::io::stdout().is_terminal() {
"human"
} else {
"json"
})
.conflicts_with_all(["json", "toon", "human"])
.display_order(crate::flags::global_flag_order::OUTPUT)
.hide(true)
.help("Ignored — this command always prints raw text"),
)
}
fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command {
let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating);
if mutates {
return command;
}
command.arg(
Arg::new("dry-run")
.long("dry-run")
.num_args(0..=1)
.require_equals(true)
.default_missing_value("true")
.default_value("false")
.value_parser(crate::flags::compat_bool_value_parser())
.display_order(crate::flags::global_flag_order::DRY_RUN)
.hide(true)
.help("Preview mutations without executing"),
)
}
fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command {
let Some(pagination) = spec.pagination else {
return command;
};
crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit)
}
fn default_field_names(spec: &CommandSpec) -> Vec<&str> {
spec.default_fields
.as_deref()
.map(|fields| {
fields
.split(',')
.map(str::trim)
.filter(|field| !field.is_empty() && *field != "all" && *field != "*")
.collect()
})
.unwrap_or_default()
}
fn apply_fields_arg(
command: Command,
spec: &CommandSpec,
schema_fields: Option<&[FieldInfo]>,
default_fields: &[&str],
) -> Command {
if spec.raw_output {
return command.arg(
Arg::new("fields")
.long("fields")
.value_name("FIELDS")
.display_order(crate::flags::global_flag_order::FIELDS)
.hide(true)
.help("Ignored — this command always prints raw text"),
);
}
let default_value = spec
.default_fields
.as_deref()
.filter(|fields| !fields.is_empty());
let table = schema_fields
.filter(|fields| !fields.is_empty())
.map(|fields| format_help_section(fields, default_fields));
if default_value.is_none() && table.is_none() {
return command;
}
let mut help = String::from(
"Comma-separated fields to include in output (use 'all' or '*' for everything)",
);
if let Some(table) = &table {
help.push_str("\n\n");
help.push_str(table.trim_end());
}
let mut arg = Arg::new("fields")
.long("fields")
.value_name("FIELDS")
.display_order(crate::flags::global_flag_order::FIELDS)
.help(help);
if let Some(default_value) = default_value {
arg = arg.default_value(default_value.to_owned());
}
command.arg(arg)
}
fn apply_filter_and_expr_examples(
mut command: Command,
spec: &CommandSpec,
fields: &[FieldInfo],
) -> Command {
if spec.raw_output {
return command
.arg(
Arg::new("filter")
.long("filter")
.value_name("EXPR")
.display_order(crate::flags::global_flag_order::FILTER)
.hide(true)
.help("Ignored — this command always prints raw text"),
)
.arg(
Arg::new("expr")
.long("expr")
.value_name("EXPR")
.display_order(crate::flags::global_flag_order::EXPR)
.hide(true)
.help("Ignored — this command always prints raw text"),
);
}
if fields.is_empty() {
return command;
}
let first_string = fields
.iter()
.find(|field| field.field_type == "string")
.map(|field| field.name.as_str());
let first_bool = fields
.iter()
.find(|field| field.field_type == "bool")
.map(|field| field.name.as_str());
if first_string.is_some() || first_bool.is_some() {
let mut help = String::from("Per-item JMESPath predicate for list data");
if let Some(name) = first_string {
help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\""));
}
if let Some(name) = first_bool {
help.push_str(&format!("\ne.g. --filter '{name}'"));
}
command = command.arg(
Arg::new("filter")
.long("filter")
.value_name("EXPR")
.display_order(crate::flags::global_flag_order::FILTER)
.help(help),
);
}
let mut expr_help = String::from("JMESPath query applied to the whole result");
expr_help.push_str("\ne.g. --expr 'length(@)'");
if let Some(name) = first_string {
expr_help.push_str(&format!("\ne.g. --expr '[].{name}'"));
}
command.arg(
Arg::new("expr")
.long("expr")
.value_name("EXPR")
.display_order(crate::flags::global_flag_order::EXPR)
.help(expr_help),
)
}
fn process_exit_code(code: i32) -> ExitCode {
if code == 0 {
return ExitCode::SUCCESS;
}
match u8::try_from(code) {
Ok(code) if code != 0 => ExitCode::from(code),
Ok(_) | Err(_) => ExitCode::from(1),
}
}
async fn run_streaming_command(
middleware: &Middleware,
request: MiddlewareRequest<'_>,
raw_matches: Arc<ArgMatches>,
streaming_handler: crate::command::StreamingCommandHandler,
) -> Result<CliRunOutput> {
use tokio::{io::AsyncWriteExt, sync::mpsc};
let args_for_handler = request.args.clone();
let user_args_for_handler = request.user_args.clone();
let handler_path = request.command_path.to_owned();
let middleware_for_handler = middleware.clone();
let raw_matches_for_handler = raw_matches;
let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
let sender = StreamSender(tx);
let writer = tokio::spawn(async move {
let mut stdout = tokio::io::stdout();
while let Some(event) = rx.recv().await {
let Ok(line) = serde_json::to_string(&event) else {
continue;
};
if stdout.write_all(line.as_bytes()).await.is_err()
|| stdout.write_all(b"\n").await.is_err()
|| stdout.flush().await.is_err()
{
break;
}
}
});
let output = middleware
.run(request, async move |credential| {
streaming_handler(
CommandContext {
credential,
args: args_for_handler,
user_args: user_args_for_handler,
command_path: handler_path,
middleware: middleware_for_handler,
raw_matches: raw_matches_for_handler,
},
sender,
)
.await?;
Ok(crate::CommandResult::new(serde_json::Value::Null))
})
.await;
let _write_result = writer.await;
match output {
Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
exit_code: 0,
rendered: String::new(),
}),
Ok(out) => Ok(out.into()),
Err(err) => Ok(CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
}),
}
}
#[cfg(test)]
mod user_agent_tests {
use super::*;
#[test]
fn user_agent_string_derives_name_and_version_by_default() {
let config =
CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
assert_eq!(config.user_agent_string(), "gdx/1.2.3");
}
#[test]
fn user_agent_string_prefers_explicit_override() {
let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
.with_build(BuildInfo::new("1.2.3"))
.with_user_agent("gdx-cli/9.9 (custom)");
assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
}
#[test]
fn user_agent_string_omits_version_when_absent() {
let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
assert_eq!(config.user_agent_string(), "gdx");
}
#[test]
fn install_default_user_agent_publishes_config_value() {
let _guard = crate::transport::client::UA_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _restore = crate::transport::client::RestoreDefaultUserAgent;
crate::transport::set_default_user_agent("cli/dev");
let cli = Cli::new(
CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
);
cli.install_default_user_agent();
assert_eq!(
crate::transport::client::default_user_agent(),
"uatest/4.5.6"
);
}
#[test]
fn install_debug_transport_logger_tracks_the_debug_pattern() {
assert!(debug_transport_logger_for("transport", &[]).enabled());
assert!(!debug_transport_logger_for("*,-transport", &[]).enabled());
assert!(!debug_transport_logger_for("", &[]).enabled());
}
}
#[cfg(test)]
mod env_config_tests {
use super::*;
#[test]
fn with_environments_stores_shared_arc_with_consumer_app_id() {
let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
crate::environments::Environments::new("prod")
.with_app_id("gddy")
.with_config_file(true),
));
let envs = cfg.environments.as_ref().expect("environments set");
assert!(envs.config_file_path().is_some());
}
#[tokio::test]
async fn env_flag_overrides_default_and_reaches_middleware_env() {
use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
use serde_json::json;
let mut cli = Cli::new(
CliConfig::new("envtest", "Env test", "envtest")
.with_environments(Arc::new(
crate::environments::Environments::new("prod")
.with_environment("prod", crate::environments::EnvTable::new())
.with_environment("ote", crate::environments::EnvTable::new()),
))
.with_startup_args(Vec::<&str>::new()),
);
cli.add_command(RuntimeCommandSpec::new_with_context(
CommandSpec::new("whichenv", "echo env").no_auth(true),
async |ctx| {
Ok(CommandResult::new(
json!({ "env": ctx.environment()?.name().to_owned() }),
))
},
));
let out = cli
.run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
.await;
assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
assert!(out.rendered.contains("\"env\""));
assert!(out.rendered.contains("ote"));
}
#[tokio::test]
async fn unknown_env_flag_produces_error_envelope() {
let cli = Cli::new(
CliConfig::new("envtest2", "Env test", "envtest2")
.with_environments(Arc::new(
crate::environments::Environments::new("prod")
.with_environment("prod", crate::environments::EnvTable::new()),
))
.with_startup_args(Vec::<&str>::new()),
);
let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
assert_ne!(out.exit_code, 0);
assert!(out.rendered.contains("nope"));
}
}
#[cfg(test)]
mod prescan_env_flag_tests {
use super::*;
fn argv(args: &[&str]) -> impl Iterator<Item = String> {
args.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.into_iter()
}
#[test]
fn finds_space_separated_value() {
assert_eq!(
prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])),
Some("dev".to_owned())
);
}
#[test]
fn finds_equals_separated_value() {
assert_eq!(
prescan_env_flag(argv(&["--env=dev", "list"])),
Some("dev".to_owned())
);
}
#[test]
fn is_none_without_the_flag() {
assert_eq!(prescan_env_flag(argv(&["env", "list"])), None);
}
#[test]
fn trailing_env_flag_with_no_value_is_none() {
assert_eq!(prescan_env_flag(argv(&["--env"])), None);
}
#[test]
fn keeps_the_last_of_multiple_occurrences() {
assert_eq!(
prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])),
Some("foo".to_owned())
);
}
#[test]
fn ignores_an_empty_equals_value() {
assert_eq!(prescan_env_flag(argv(&["--env="])), None);
}
#[test]
fn empty_occurrence_does_not_clobber_an_earlier_real_value() {
assert_eq!(
prescan_env_flag(argv(&["--env", "dev", "--env="])),
Some("dev".to_owned())
);
}
#[test]
fn space_separated_value_starting_with_dash_is_not_a_value() {
assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None);
}
#[test]
fn equals_form_accepts_a_value_starting_with_dash() {
assert_eq!(
prescan_env_flag(argv(&["--env=-foo"])),
Some("-foo".to_owned())
);
}
#[test]
fn stops_at_the_end_of_options_sentinel() {
assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None);
}
#[test]
fn a_real_flag_before_the_sentinel_is_still_found() {
assert_eq!(
prescan_env_flag(argv(&["--env", "dev", "--", "positional"])),
Some("dev".to_owned())
);
}
}
#[cfg(test)]
mod feature_flag_pruning_tests {
use super::*;
use crate::CommandResult;
fn trivial_command(name: &str) -> RuntimeCommandSpec {
RuntimeCommandSpec::new(
CommandSpec::new(name, "short").no_auth(true),
async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
)
}
fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec {
let mut command = trivial_command(name);
command.spec = command.spec.with_feature_flag(key, stage);
command
}
fn empty_policy() -> FlagPolicy {
FlagPolicy::default()
}
#[test]
fn no_flags_anywhere_keeps_everything() {
let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
.with_command(trivial_command("a"))
.with_command(trivial_command("b"))
.with_group(
RuntimeGroupSpec::new(GroupSpec::new("child", "short"))
.with_command(trivial_command("c")),
);
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned =
prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
let pruned = pruned.expect("unflagged tree should never be dropped");
assert_eq!(pruned.commands.len(), 2);
assert_eq!(pruned.groups.len(), 1);
assert_eq!(pruned.groups[0].commands.len(), 1);
assert!(registry.entries().is_empty());
}
#[test]
fn experimental_command_is_pruned_sibling_is_not() {
let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
.with_command(flagged_command("gated", "gated-flag", Stage::Experimental))
.with_command(trivial_command("sibling"));
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned =
prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry)
.expect("group still has a visible command left");
assert_eq!(pruned.commands.len(), 1);
assert_eq!(pruned.commands[0].spec.name, "sibling");
let entries = registry.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "root:gated");
assert_eq!(entries[0].key, "gated-flag");
assert!(!entries[0].visible);
}
#[test]
fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() {
let build_tree = || {
RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
.with_command(trivial_command("keep-me"))
.with_group(
RuntimeGroupSpec::new(
GroupSpec::new("flagged-group", "short")
.with_feature_flag("group-flag", Stage::Beta),
)
.with_command(trivial_command("cmd-default"))
.with_command(flagged_command(
"cmd-ga",
"cmd-ga-flag",
Stage::Ga,
)),
)
};
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned = prune_feature_flag_tree(
build_tree(),
None,
&empty_policy(),
&mut prefix,
&mut registry,
)
.expect("root keeps its unflagged sibling command");
assert!(pruned.groups.is_empty());
assert_eq!(pruned.commands.len(), 1);
assert_eq!(pruned.commands[0].spec.name, "keep-me");
assert_eq!(registry.entries().len(), 1);
assert_eq!(registry.entries()[0].path, "root:flagged-group");
assert!(!registry.entries()[0].visible);
let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned =
prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry)
.expect("root is kept");
assert_eq!(pruned.groups.len(), 1);
assert_eq!(pruned.groups[0].commands.len(), 2);
assert!(registry.entries().iter().all(|entry| entry.visible));
}
#[test]
fn ancestor_invisibility_short_circuits_before_children_are_visited() {
let group = RuntimeGroupSpec::new(
GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta),
)
.with_command(flagged_command("child", "child-flag", Stage::Ga));
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned =
prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
assert!(
pruned.is_none(),
"invisible ancestor drops its whole subtree"
);
assert_eq!(registry.entries().len(), 1);
assert_eq!(registry.entries()[0].path, "ancestor");
assert!(registry.by_key("child-flag").is_empty());
}
#[test]
fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() {
let module_flag = FeatureFlag::new("module-flag", Stage::Beta);
let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
.with_command(trivial_command("unflagged-child"));
let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned = prune_feature_flag_tree(
group,
Some(&module_flag),
&policy,
&mut prefix,
&mut registry,
)
.expect("Beta-permissive policy keeps a Beta-inherited tree");
assert_eq!(pruned.commands.len(), 1);
let entries = registry.entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "root");
assert_eq!(entries[0].key, "module-flag");
assert_eq!(entries[0].stage, Stage::Beta);
assert_eq!(entries[1].path, "root:unflagged-child");
assert_eq!(entries[1].key, "module-flag");
assert_eq!(entries[1].stage, Stage::Beta);
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned = prune_feature_flag_tree(
RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
.with_command(trivial_command("unflagged-child")),
Some(&module_flag),
&empty_policy(),
&mut prefix,
&mut registry,
);
assert!(pruned.is_none());
}
#[test]
fn registry_records_only_named_flags_not_unflagged_nodes() {
let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group(
RuntimeGroupSpec::new(
GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta),
)
.with_command(trivial_command("c1"))
.with_command(flagged_command("c2", "c2-flag", Stage::Ga)),
);
let policy = FlagPolicy::default().with_min_stage(Stage::Experimental);
let mut prefix = Vec::new();
let mut registry = FlagRegistry::new();
let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry)
.expect("permissive policy keeps everything");
assert_eq!(pruned.groups[0].commands.len(), 2);
let entries = registry.entries();
assert_eq!(entries.len(), 3, "root has no flag and is not recorded");
assert_eq!(entries[0].path, "root:g");
assert_eq!(entries[0].key, "g-flag");
assert_eq!(entries[1].path, "root:g:c1");
assert_eq!(entries[1].key, "g-flag");
assert_eq!(entries[1].stage, Stage::Beta);
assert_eq!(entries[2].path, "root:g:c2");
assert_eq!(entries[2].key, "c2-flag");
assert_eq!(entries[2].stage, Stage::Ga);
assert!(entries.iter().all(|entry| entry.visible));
}
#[test]
fn module_feature_flag_cascades_into_its_group_via_add_module() {
let module = Module::new("Test Category", |_ctx| {
RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short"))
.with_command(trivial_command("list"))
})
.with_feature_flag("module-flag", Stage::Experimental);
let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest"));
cli.add_module(module);
assert!(
!cli.commands.contains_key("gated-mod:list"),
"module-level Experimental flag should have pruned the whole group under the default Ga policy"
);
assert!(
!has_subcommand(&cli.root, "gated-mod"),
"the pruned group must not be mounted in the clap tree either"
);
}
#[test]
fn module_feature_flag_keeps_group_when_policy_allows_it() {
let module = Module::new("Test Category", |_ctx| {
RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short"))
.with_command(trivial_command("list"))
})
.with_feature_flag("module-flag-2", Stage::Experimental);
let mut cli = Cli::new(
CliConfig::new("modtest2", "Module test", "modtest2")
.with_min_stage(Stage::Experimental),
);
cli.add_module(module);
assert!(cli.commands.contains_key("gated-mod-2:list"));
assert!(has_subcommand(&cli.root, "gated-mod-2"));
}
#[test]
fn active_environment_min_stage_loosens_consumer_level_policy() {
let module = Module::new("Test Category", |_ctx| {
RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short"))
.with_command(trivial_command("list"))
})
.with_feature_flag("module-flag-3", Stage::Experimental);
let mut cli = Cli::new(
CliConfig::new("modtest3", "Module test", "modtest3")
.with_environments(Arc::new(
crate::environments::Environments::new("prod").with_environment(
"prod",
crate::environments::EnvTable::new().with("min_stage", "experimental"),
),
))
.with_startup_args(Vec::<&str>::new()),
);
cli.add_module(module);
assert!(cli.commands.contains_key("gated-mod-3:list"));
assert!(has_subcommand(&cli.root, "gated-mod-3"));
}
#[test]
fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() {
fn gated_module() -> Module {
Module::new("Test Category", |_ctx| {
RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short"))
.with_command(trivial_command("list"))
})
.with_feature_flag("module-flag-4", Stage::Experimental)
}
fn environments() -> Arc<crate::environments::Environments> {
Arc::new(
crate::environments::Environments::new("prod")
.with_environment("prod", crate::environments::EnvTable::new())
.with_environment(
"dev",
crate::environments::EnvTable::new().with("min_stage", "experimental"),
),
)
}
let mut with_dev_flag = Cli::new(
CliConfig::new("modtest4a", "Module test", "modtest4a")
.with_environments(environments())
.with_startup_args(["modtest4a", "--env", "dev"]),
);
with_dev_flag.add_module(gated_module());
assert!(
with_dev_flag.commands.contains_key("gated-mod-4:list"),
"--env dev in startup_args should reveal the Experimental module"
);
assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4"));
let mut without_flag = Cli::new(
CliConfig::new("modtest4b", "Module test", "modtest4b")
.with_environments(environments())
.with_startup_args(Vec::<&str>::new()),
);
without_flag.add_module(gated_module());
assert!(
!without_flag.commands.contains_key("gated-mod-4:list"),
"without --env, the default env's Ga policy should still prune the module"
);
assert!(!has_subcommand(&without_flag.root, "gated-mod-4"));
}
static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(());
struct GlobalMinStageEnvGuard {
key: &'static str,
prev: Option<std::ffi::OsString>,
}
impl GlobalMinStageEnvGuard {
#[allow(unsafe_code)]
fn set(key: &'static str, value: &str) -> Self {
let prev = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
Self { key, prev }
}
#[allow(unsafe_code)]
fn unset(key: &'static str) -> Self {
let prev = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, prev }
}
}
impl Drop for GlobalMinStageEnvGuard {
#[allow(unsafe_code)]
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
#[allow(unsafe_code)]
fn global_min_stage_override_is_a_noop_when_unset() {
let _g = GLOBAL_MIN_STAGE_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE";
let _guard = GlobalMinStageEnvGuard::unset(VAR);
assert_eq!(global_min_stage_override("unset-min-stage-app"), None);
}
#[test]
#[allow(unsafe_code)]
fn global_min_stage_override_parses_a_valid_value() {
let _g = GLOBAL_MIN_STAGE_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE";
let _guard = GlobalMinStageEnvGuard::set(VAR, "beta");
assert_eq!(
global_min_stage_override("valid-min-stage-app"),
Some(Stage::Beta)
);
}
#[test]
#[allow(unsafe_code)]
fn global_min_stage_override_ignores_a_malformed_value() {
let _g = GLOBAL_MIN_STAGE_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE";
let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly");
assert_eq!(global_min_stage_override("bad-min-stage-app"), None);
}
}
#[cfg(test)]
mod flags_command_tests {
use super::*;
use crate::CommandResult;
fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
Module::new("Test Category", move |_ctx| {
RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command(
RuntimeCommandSpec::new(
CommandSpec::new("list", "short").no_auth(true),
async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
),
)
})
.with_feature_flag(key, stage)
}
#[tokio::test]
async fn flags_list_reports_flagged_entries() {
let mut cli = Cli::new(
CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta),
);
cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta));
let out = cli
.run(["flagtest", "flags", "list", "--output", "json"])
.await;
assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
let rendered: serde_json::Value =
serde_json::from_str(&out.rendered).expect("stdout should contain json");
let entries = rendered["data"].as_array().expect("data should be array");
let command_entry = entries
.iter()
.find(|entry| entry["path"] == "flagged-mod:list")
.expect("flagged command entry should be present");
assert_eq!(command_entry["key"], "list-flag");
assert_eq!(command_entry["stage"], "beta");
assert_eq!(command_entry["visible"], true);
}
#[tokio::test]
async fn flags_info_returns_policy_and_entries_for_known_key() {
let mut cli = Cli::new(
CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta),
);
cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta));
let out = cli
.run([
"flagtest2",
"flags",
"info",
"info-flag",
"--output",
"json",
])
.await;
assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
let rendered: serde_json::Value =
serde_json::from_str(&out.rendered).expect("stdout should contain json");
let data = &rendered["data"];
assert_eq!(data["key"], "info-flag");
assert_eq!(data["policy"]["min_stage"], "beta");
assert!(data["policy"]["override"].is_null());
let entries = data["entries"].as_array().expect("entries should be array");
assert!(!entries.is_empty());
assert!(entries.iter().any(|entry| {
entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage"
}));
}
#[tokio::test]
async fn flags_info_reports_override_decided_by() {
let mut cli = Cli::new(
CliConfig::new("flagtest3", "Flag test", "flagtest3")
.with_feature_override("override-flag", Stage::Ga),
);
cli.add_module(flagged_module(
"flagged-mod-3",
"override-flag",
Stage::Experimental,
));
let out = cli
.run([
"flagtest3",
"flags",
"info",
"override-flag",
"--output",
"json",
])
.await;
assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
let rendered: serde_json::Value =
serde_json::from_str(&out.rendered).expect("stdout should contain json");
let data = &rendered["data"];
assert_eq!(data["policy"]["min_stage"], "ga");
assert_eq!(data["policy"]["override"], "ga");
let entries = data["entries"].as_array().expect("entries should be array");
assert!(!entries.is_empty());
assert!(
entries
.iter()
.all(|entry| entry["decided_by"] == "override")
);
assert!(entries.iter().all(|entry| entry["visible"] == true));
assert!(entries.iter().all(|entry| entry["stage"] == "experimental"));
}
#[tokio::test]
async fn flags_info_unknown_key_errors() {
let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4"));
let out = cli
.run(["flagtest4", "flags", "info", "no-such-flag"])
.await;
assert_ne!(out.exit_code, 0);
assert!(out.rendered.contains("no such flag"));
}
}