Skip to main content

cli_engine/
cli.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    future::Future,
4    io::Write,
5    path::{Path, PathBuf},
6    process::ExitCode,
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11mod builtins;
12mod completion;
13mod help;
14mod tree_render;
15
16use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser};
17
18use crate::{
19    ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
20    FeatureFlag, GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec,
21    RuntimeGroupSpec,
22    auth::commands::auth_command_group,
23    command::{
24        CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
25        leaf_matches,
26    },
27    error::exit_code_for_error,
28    feature_flags::{FlagEntry, FlagPolicy, FlagRegistry, Stage},
29    flags::{
30        GlobalFlags, derive_bool_flags, derive_value_flags, extract_command_path,
31        extract_output_format, global_flags_from_matches, has_true_schema_flag, min_stage_env_var,
32        output_env_var, register_global_flags, register_reason_flag, resolve_default_output_format,
33    },
34    guide::{guide_content, render_guide_human},
35    module::{Module, ModuleContext},
36    output::{
37        FieldInfo, HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry,
38        format_help_section, global_human_view_registry_snapshot, global_schema_registry_snapshot,
39    },
40    search::{SearchDocument, SearchIndex},
41};
42
43use builtins::{
44    completion_args, completion_command, guide_args, guide_command, help_args, help_command,
45    search_args, search_command,
46};
47use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
48pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
49
50/// Build metadata shown by the root `--version` flag.
51#[derive(Clone, Debug, Default, Eq, PartialEq)]
52pub struct BuildInfo {
53    /// Semantic version or other release label.
54    pub version: String,
55    /// Optional source control commit identifier.
56    pub commit: Option<String>,
57    /// Optional build date string.
58    pub date: Option<String>,
59}
60
61impl BuildInfo {
62    /// Creates build metadata with only a version string.
63    #[must_use]
64    pub fn new(version: impl Into<String>) -> Self {
65        Self {
66            version: version.into(),
67            commit: None,
68            date: None,
69        }
70    }
71
72    /// Adds a commit identifier to the version string shown by `--version`.
73    #[must_use]
74    pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
75        self.commit = Some(commit.into());
76        self
77    }
78
79    /// Adds a build date to the version string shown by `--version`.
80    #[must_use]
81    pub fn with_date(mut self, date: impl Into<String>) -> Self {
82        self.date = Some(date.into());
83        self
84    }
85
86    /// Returns the rendered version string used by the root `--version` flag.
87    #[must_use]
88    pub fn version_string(&self) -> String {
89        let commit = self.commit.as_deref().unwrap_or_default();
90        let date = self.date.as_deref().unwrap_or_default();
91
92        if commit.is_empty() && date.is_empty() {
93            self.version.clone()
94        } else {
95            format!("{} (commit {commit}, built {date})", self.version)
96        }
97    }
98}
99
100/// Late dependency initializer run once before real command execution.
101pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
102/// Hook used to add application-specific global flags to the root `clap` command.
103pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
104/// Hook used to copy parsed application-specific flags into middleware.
105pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
106/// Hook run immediately before executable commands and built-ins.
107pub type PreRun =
108    Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
109/// Hook used to adjust command metadata globally before middleware executes.
110pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
111/// Hook called after a CLI run completes.
112pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
113/// Hook that contributes extra root-scope `search` documents.
114pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
115/// Hook that supplies the suggested next actions shown when the CLI is invoked
116/// with no subcommand (bare root). The same actions drive a human "Next actions"
117/// section and the JSON discovery envelope.
118pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
119
120/// Default name for the admin help category, under which the engine files the
121/// built-in `auth` command when a consumer does not override it via
122/// [`CliConfig::with_admin_category`].
123const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
124
125/// Maximum number of chained `argv0` dispatch hand-offs before the engine
126/// refuses to recurse further. Real multi-call nesting is zero or one level;
127/// this bounds a pathologically long explicit `argv0 … argv0 …` chain so it
128/// errors cleanly instead of overflowing the stack.
129const MAX_ARGV0_DEPTH: usize = 16;
130
131/// How the engine behaves when invoked under a registered alternative `argv[0]`
132/// name (busybox/git-style multi-call dispatch).
133///
134/// A route is selected when the binary's `argv[0]` basename — or the name given
135/// to the hidden `argv0` command — matches a key registered via
136/// [`CliConfig::with_argv0_alias`] or [`CliConfig::with_argv0_personality`]. An
137/// `argv[0]` that matches no route falls through to the default CLI, so existing
138/// applications that register no routes are unaffected.
139///
140/// Non-exhaustive: more route kinds may be added in future releases. Register
141/// routes through the [`CliConfig`] builders rather than matching on variants.
142#[derive(Clone)]
143#[non_exhaustive]
144pub enum Argv0Route {
145    /// Rewrite the invocation into these canonical subcommand tokens and run it
146    /// through the normal command tree, with the real argument tail appended.
147    ///
148    /// For example, an `Alias(vec!["project".into(), "list".into()])` registered
149    /// under `pl` makes `pl --team x` behave exactly like `project list --team x`.
150    Alias(Vec<String>),
151    /// Run an entirely separate CLI application built from the returned
152    /// [`CliConfig`] (its own root name, commands, flags, and auth). The
153    /// configuration is built lazily, only when the route is actually dispatched,
154    /// so registering a personality costs nothing for invocations that never hit it.
155    Personality(Arc<dyn Fn() -> CliConfig + Send + Sync>),
156}
157
158impl std::fmt::Debug for Argv0Route {
159    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(),
162            Self::Personality(_) => formatter.write_str("Personality(..)"),
163        }
164    }
165}
166
167/// On-disk mechanism used by [`Cli::create_link`] to materialize an alternative
168/// `argv[0]` name so the binary can be invoked under it.
169///
170/// Installers pick the mechanism that suits the platform and environment;
171/// self-healing code can re-run [`Cli::create_link`] to restore a deleted link.
172///
173/// Non-exhaustive: more link mechanisms may be added in future releases.
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175#[non_exhaustive]
176pub enum Argv0LinkMethod {
177    /// A symbolic link to the target executable (`<name>` on Unix, `<name>.exe`
178    /// on Windows). On Windows this may require Developer Mode or elevation.
179    SoftLink,
180    /// A hard link to the target executable (`<name>` on Unix, `<name>.exe` on
181    /// Windows). The link must live on the same volume as the target.
182    HardLink,
183    /// A small shim script that forwards to the target via the `argv0` command:
184    /// a `<name>.cmd` batch file on Windows, or an executable `<name>` shell
185    /// script on Unix. Useful when links are unavailable or inconvenient.
186    Script,
187}
188
189/// Top-level subcommand names that are reserved by the engine and must not be
190/// used as module group names.  [`Cli::add_module_group`] rejects a group whose
191/// name matches a reserved name so the engine's built-in command always wins.
192pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 5] =
193    ["help", "guide", "tree", "completion", "search"];
194
195/// Declarative configuration for a CLI application.
196///
197/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for
198/// modules, auth providers, guides, views, and lifecycle hooks. Direct struct
199/// literals remain available for advanced setup and tests.
200#[derive(Clone, Default)]
201pub struct CliConfig {
202    /// Root command name shown in usage output.
203    pub name: String,
204    /// One-line root command description.
205    pub short: String,
206    /// Optional longer root command description. Defaults to `short`.
207    pub long: Option<String>,
208    /// Version/build metadata for `--version`.
209    pub build: BuildInfo,
210    /// Application id stored in middleware and output metadata.
211    pub app_id: String,
212    /// Fallback auth provider when a command does not select one explicitly.
213    pub default_auth_provider: Option<String>,
214    /// Domain modules mounted under the root command.
215    pub modules: Vec<Module>,
216    /// Additional top-level runtime commands.
217    pub commands: Vec<RuntimeCommandSpec>,
218    /// Additional commands mounted as siblings of the built-in `auth`
219    /// group's `login`/`status`/`logout` (e.g. `auth scopes`). Populate via
220    /// [`CliConfig::with_auth_extra_commands`]; folded in internally after
221    /// the built-in group is built, so the built-ins are never lost or
222    /// overwritten.
223    pub auth_extra_commands: Vec<RuntimeCommandSpec>,
224    /// Global guide entries mounted under `guide`.
225    pub guides: Vec<GuideEntry>,
226    /// Global human output views.
227    pub views: Vec<HumanViewDef>,
228    /// Providers registered before command execution starts.
229    pub auth_providers: Vec<Arc<dyn AuthProvider>>,
230    /// Optional override for the process-wide outbound User-Agent. When unset,
231    /// the engine derives `name/version` from this config. See
232    /// [`CliConfig::user_agent_string`].
233    pub user_agent: Option<String>,
234    /// Extra HTTP header names to redact in `--debug transport` output, on top
235    /// of the built-in sensitive set (`authorization`, `proxy-authorization`,
236    /// `cookie`, `set-cookie`, `x-api-key`). Set CLI-specific secret-bearing
237    /// headers here — e.g. a custom API-key header an auth injector adds.
238    /// Populate via [`CliConfig::with_redacted_debug_headers`].
239    pub redacted_debug_headers: Vec<String>,
240    /// Optional authorization gatekeeper injected into middleware.
241    pub authz: Option<Arc<dyn Authorizer>>,
242    /// Optional audit recorder injected into middleware.
243    pub auditor: Option<Arc<dyn Auditor>>,
244    /// Optional activity event sink injected into middleware.
245    pub activity: Option<Arc<dyn ActivityEmitter>>,
246    /// Optional late initializer for runtime dependencies.
247    pub init_deps: Option<InitDeps>,
248    /// Optional hook for adding application-specific global flags.
249    pub register_flags: Option<RegisterFlags>,
250    /// Optional hook for applying parsed application-specific flags.
251    pub apply_flags: Option<ApplyFlags>,
252    /// Optional hook run before executable commands and built-ins.
253    pub pre_run: Option<PreRun>,
254    /// Optional hook for global command metadata adjustments.
255    pub meta_resolver: Option<ResolveMeta>,
256    /// Optional hook called after each run.
257    pub on_shutdown: Option<OnShutdown>,
258    /// Optional root-scope search document provider.
259    pub extra_search_docs: Option<ExtraSearchDocs>,
260    /// Optional provider for the bare-root suggested next actions.
261    pub root_next_actions: Option<RootNextActions>,
262    /// Name of the admin help category. The engine files its built-in `auth`
263    /// command under this heading; apps should use the same name for their own
264    /// admin modules (e.g. godaddy's `env`). When unset, defaults to `"Admin"`;
265    /// set it to match a consumer's own taxonomy (e.g. gdx's "Administration").
266    pub admin_category: Option<String>,
267    /// Whether to mount the built-in `config` command group (`config
268    /// get`/`set`/`path`/`list`). Off by default to avoid colliding with a
269    /// consumer's own `config` noun. Enable via
270    /// [`CliConfig::with_config_commands`].
271    pub config_commands: bool,
272    /// Alternative `argv[0]` names this binary may be invoked as, mapped to the
273    /// behavior the engine should take (busybox/git-style multi-call dispatch).
274    ///
275    /// Keyed by the bare alternative name (no path, no extension). Empty by
276    /// default, in which case argv0 dispatch is inert and behavior is identical
277    /// to a binary that never opted in. Populate via [`CliConfig::with_argv0_alias`]
278    /// and [`CliConfig::with_argv0_personality`].
279    pub argv0_routes: BTreeMap<String, Argv0Route>,
280    /// Optional first-class environment system.
281    ///
282    /// Registered via [`CliConfig::with_environments`]. When set, the engine
283    /// registers a global `--env` flag, seeds the active environment into
284    /// middleware, and exposes it to handlers through
285    /// [`CommandContext::environment`](crate::command::CommandContext::environment).
286    pub environments: Option<Arc<crate::environments::Environments>>,
287    /// Explicit argv override for [`Cli::new`]'s startup `--env` prescan,
288    /// mainly used to make tests hermetic.
289    pub startup_args: Option<Vec<std::ffi::OsString>>,
290    /// Minimum feature stage required for a flagged command, group, or module
291    /// to remain mounted.
292    ///
293    /// Defaults to [`Stage::Ga`] via [`Stage`]'s own `Default`, which combined
294    /// with an empty [`feature_overrides`](Self::feature_overrides) is the
295    /// zero-config behavior: nothing is gated unless a command/group/module
296    /// opts in with `.with_feature_flag(...)`, and even then it stays visible
297    /// until this is lowered. Lower it (e.g. to [`Stage::Beta`] or
298    /// [`Stage::Experimental`]) to opt a build or environment into
299    /// pre-release commands. Set via [`CliConfig::with_min_stage`].
300    pub min_stage: Stage,
301    /// Per-key stage overrides that substitute a forced stage for a flag
302    /// key's own declared stage before comparing against
303    /// [`min_stage`](Self::min_stage).
304    ///
305    /// Empty by default. Populate via [`CliConfig::with_feature_override`] to
306    /// force one named flag to a specific effective stage — e.g. forcing a
307    /// single flag to [`Stage::Ga`] to turn it on for internal testing without
308    /// lowering [`min_stage`](Self::min_stage) for every other flagged
309    /// command, or forcing it to [`Stage::Experimental`] to disable it even
310    /// under a permissive `min_stage`. See [`FlagPolicy::visible`] for the
311    /// exact comparison.
312    pub feature_overrides: BTreeMap<String, Stage>,
313    /// Whether to auto-enable interactive mode when a TTY is detected.
314    ///
315    /// When `false` (the default), commands only run interactively if the user
316    /// passes `--interactive` explicitly. When `true`, the engine auto-detects
317    /// a TTY (stdin + stderr) and defaults to interactive mode — meaning
318    /// missing required arguments will be prompted for instead of erroring.
319    ///
320    /// Set via [`CliConfig::with_auto_interactive`]. Start with `false` for
321    /// backwards compatibility; flip to `true` once the CLI's commands have
322    /// been tested under interactive prompting.
323    pub auto_interactive: bool,
324}
325
326impl CliConfig {
327    /// Creates the minimum useful CLI configuration.
328    #[must_use]
329    pub fn new(
330        name: impl Into<String>,
331        short: impl Into<String>,
332        app_id: impl Into<String>,
333    ) -> Self {
334        Self {
335            name: name.into(),
336            short: short.into(),
337            app_id: app_id.into(),
338            ..Self::default()
339        }
340    }
341
342    /// Sets root long help text.
343    #[must_use]
344    pub fn with_long(mut self, long: impl Into<String>) -> Self {
345        self.long = Some(long.into());
346        self
347    }
348
349    /// Sets build metadata used by `--version`.
350    #[must_use]
351    pub fn with_build(mut self, build: BuildInfo) -> Self {
352        self.build = build;
353        self
354    }
355
356    /// Sets the fallback auth provider for commands that do not name one.
357    #[must_use]
358    pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
359        self.default_auth_provider = Some(provider.into());
360        self
361    }
362
363    /// Registers a first-class environment system.
364    ///
365    /// When set, [`Cli::new`] registers a global `--env` flag, seeds the active
366    /// environment into middleware (explicit `--env` > persisted active >
367    /// configured default), and exposes the resolved environment to handlers via
368    /// [`CommandContext::environment`](crate::command::CommandContext::environment).
369    ///
370    /// The [`Environments`](crate::environments::Environments) is stored as-is, so
371    /// the consumer is responsible for configuring it before wrapping it in an
372    /// `Arc`:
373    ///
374    /// - Call
375    ///   [`Environments::with_app_id`](crate::environments::Environments::with_app_id)
376    ///   with the **same** `app_id` passed to [`CliConfig::new`], so the config
377    ///   file and active-environment persistence resolve to the application's
378    ///   config directory. (An empty `app_id` makes
379    ///   [`Environments::config_file_path`](crate::environments::Environments::config_file_path)
380    ///   return `None`, silently disabling the `environments.toml` file layer.)
381    /// - Call
382    ///   [`Environments::with_config_file(true)`](crate::environments::Environments::with_config_file)
383    ///   if the application loads a user-editable `environments.toml`.
384    /// - **Share the same `Arc`** with any `PkceAuthProvider::with_environments`
385    ///   (available with the `pkce-auth` feature):
386    ///   the provider's OAuth file layer and active-environment persistence must
387    ///   resolve against the identical, `app_id`-stamped instance the engine sees,
388    ///   or a file-defined environment (or a file override of a compiled
389    ///   environment's `client_id`) will be visible to `env info` yet invisible to
390    ///   the actual OAuth login.
391    #[must_use]
392    pub fn with_environments(
393        mut self,
394        environments: Arc<crate::environments::Environments>,
395    ) -> Self {
396        self.environments = Some(environments);
397        self
398    }
399
400    /// Overrides the argv [`Cli::new`] prescans for `--env` before pruning the
401    /// command tree, instead of the real process argv.
402    ///
403    /// Only meaningful alongside [`with_environments`](Self::with_environments)
404    /// — otherwise `Cli::new` never registers `--env` or does the prescan at
405    /// all, so this is silently unused. Element `0` is treated as the program
406    /// name and skipped, the same convention [`Cli::run`]/[`Cli::execute_from`]
407    /// use for their own `args` parameter.
408    ///
409    /// This matters beyond tests: tree pruning is decided once, at `Cli::new`
410    /// time, from either this override or real process argv — never from the
411    /// `args` a later [`Cli::run`]/[`Cli::execute_from`] call receives. Any
412    /// caller that builds the `Cli` once and later runs it with a synthetic
413    /// argv (e.g. a wrapper binary invoking it programmatically, or a fixed
414    /// argument list unrelated to `std::env::args_os()`) should pass the same
415    /// `--env` here too, or an environment named only in the later call's
416    /// argv won't have been consulted for pruning, and a flagged command that
417    /// environment would reveal (or hide) can disagree with what actually
418    /// dispatches. A test that configures `with_environments` should call
419    /// this (even with an empty iterator) to keep construction hermetic;
420    /// without it, `Cli::new` reads whatever real argv the test binary itself
421    /// was invoked with.
422    #[must_use]
423    pub fn with_startup_args<I, S>(mut self, args: I) -> Self
424    where
425        I: IntoIterator<Item = S>,
426        S: Into<std::ffi::OsString>,
427    {
428        self.startup_args = Some(args.into_iter().map(Into::into).collect());
429        self
430    }
431
432    /// Sets the minimum feature stage required for a flagged command, group,
433    /// or module to remain mounted.
434    ///
435    /// See [`min_stage`](Self::min_stage) for the default and [`FlagPolicy`]
436    /// for how it combines with [`feature_overrides`](Self::feature_overrides)
437    /// during command-tree pruning.
438    #[must_use]
439    pub fn with_min_stage(mut self, stage: Stage) -> Self {
440        self.min_stage = stage;
441        self
442    }
443
444    /// Enables auto-interactive mode: when a TTY is detected, the CLI
445    /// defaults to interactive prompting for missing required arguments.
446    ///
447    /// Off by default for backwards compatibility. Enable once commands have
448    /// been tested under interactive prompting. `--interactive` still works as
449    /// an explicit override regardless of this setting.
450    #[must_use]
451    pub fn with_auto_interactive(mut self, enabled: bool) -> Self {
452        self.auto_interactive = enabled;
453        self
454    }
455
456    /// Adds (or replaces) a per-key feature-flag stage override.
457    ///
458    /// See [`feature_overrides`](Self::feature_overrides) for how the
459    /// override participates in the [`FlagPolicy::visible`] comparison.
460    #[must_use]
461    pub fn with_feature_override(mut self, key: impl Into<String>, stage: Stage) -> Self {
462        self.feature_overrides.insert(key.into(), stage);
463        self
464    }
465
466    /// Builds the merged [`FlagPolicy`] used for command-tree pruning from
467    /// this config's `min_stage` and `feature_overrides`.
468    fn flag_policy(&self) -> FlagPolicy {
469        FlagPolicy {
470            min_stage: self.min_stage,
471            overrides: self.feature_overrides.clone(),
472        }
473    }
474
475    /// Overrides the outbound User-Agent string for all HTTP traffic.
476    ///
477    /// When unset, the engine derives `name/version` from this config (see
478    /// [`CliConfig::user_agent_string`]). Set this when the upstream APIs expect
479    /// a specific product token. The resolved value is applied process-wide on
480    /// execution via [`crate::transport::set_default_user_agent`], so it reaches
481    /// both command [`HttpClient`](crate::transport::HttpClient)s and the
482    /// engine's own OAuth token requests.
483    #[must_use]
484    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
485        self.user_agent = Some(user_agent.into());
486        self
487    }
488
489    /// Adds HTTP header names to redact in `--debug transport` output, on top of
490    /// the built-in sensitive set.
491    ///
492    /// Use this for CLI-specific secret-bearing headers that are not standard
493    /// auth headers — for example a custom API-key header that an
494    /// [`AuthInjector`](crate::transport::AuthInjector) sets. Matching is
495    /// case-insensitive and additive: the built-in set is always redacted.
496    /// Calls accumulate. Names are trimmed and empty entries are dropped, so a
497    /// mistyped value with stray whitespace cannot silently disable redaction.
498    #[must_use]
499    pub fn with_redacted_debug_headers(
500        mut self,
501        names: impl IntoIterator<Item = impl Into<String>>,
502    ) -> Self {
503        self.redacted_debug_headers
504            .extend(names.into_iter().filter_map(|name| {
505                let name = name.into().trim().to_owned();
506                (!name.is_empty()).then_some(name)
507            }));
508        self
509    }
510
511    /// Returns the outbound User-Agent string the CLI presents on HTTP requests.
512    ///
513    /// Resolution order:
514    /// 1. an explicit [`with_user_agent`](Self::with_user_agent) override;
515    /// 2. otherwise `name/version` (for example `gdx/1.2.3`);
516    /// 3. otherwise just `name` when no build version is set.
517    #[must_use]
518    pub fn user_agent_string(&self) -> String {
519        if let Some(user_agent) = &self.user_agent {
520            return user_agent.clone();
521        }
522        if self.build.version.is_empty() {
523            self.name.clone()
524        } else {
525            format!("{}/{}", self.name, self.build.version)
526        }
527    }
528
529    /// Adds one domain module.
530    ///
531    /// # Reserved group names
532    ///
533    /// The top-level group names `help`, `guide`, `tree`, and `completion` are
534    /// reserved by the engine.  A module whose root group uses one of these
535    /// names will be rejected at registration time (logged as a warning) so
536    /// the engine's own built-in always takes precedence in the command tree.
537    #[must_use]
538    pub fn with_module(mut self, module: Module) -> Self {
539        self.modules.push(module);
540        self
541    }
542
543    /// Adds several domain modules.
544    ///
545    /// See [`with_module`](Self::with_module) for the list of reserved group names.
546    #[must_use]
547    pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
548        self.modules.extend(modules);
549        self
550    }
551
552    /// Adds a top-level runtime command outside a module.
553    #[must_use]
554    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
555        self.commands.push(command);
556        self
557    }
558
559    /// Adds commands mounted as siblings of the built-in `auth` group's
560    /// `login`/`status`/`logout`.
561    ///
562    /// Use this to extend `auth` with consumer-specific subcommands (e.g.
563    /// `auth scopes`) without losing or duplicating the built-ins — unlike
564    /// pre-registering an `auth` [`Module`], which either drops the built-ins
565    /// entirely or has them silently overwrite any extra command added this
566    /// way, these are folded in additively after building the built-in group.
567    #[must_use]
568    pub fn with_auth_extra_commands(
569        mut self,
570        commands: impl IntoIterator<Item = RuntimeCommandSpec>,
571    ) -> Self {
572        self.auth_extra_commands.extend(commands);
573        self
574    }
575
576    /// Adds one global guide.
577    #[must_use]
578    pub fn with_guide(mut self, guide: GuideEntry) -> Self {
579        self.guides.push(guide);
580        self
581    }
582
583    /// Adds several global guides.
584    #[must_use]
585    pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
586        self.guides.extend(guides);
587        self
588    }
589
590    /// Adds one global human view.
591    #[must_use]
592    pub fn with_view(mut self, view: HumanViewDef) -> Self {
593        self.views.push(view);
594        self
595    }
596
597    /// Registers one auth provider.
598    #[must_use]
599    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
600        self.auth_providers.push(provider);
601        self
602    }
603
604    /// Sets the authorization gatekeeper.
605    #[must_use]
606    pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
607        self.authz = Some(authz);
608        self
609    }
610
611    /// Sets the audit recorder.
612    #[must_use]
613    pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
614        self.auditor = Some(auditor);
615        self
616    }
617
618    /// Sets the activity event sink.
619    #[must_use]
620    pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
621        self.activity = Some(activity);
622        self
623    }
624
625    /// Sets the late dependency initializer.
626    #[must_use]
627    pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
628        self.init_deps = Some(init_deps);
629        self
630    }
631
632    /// Sets the application-specific global flag registration hook.
633    #[must_use]
634    pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
635        self.register_flags = Some(register_flags);
636        self
637    }
638
639    /// Sets the application-specific parsed flag application hook.
640    #[must_use]
641    pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
642        self.apply_flags = Some(apply_flags);
643        self
644    }
645
646    /// Sets the pre-run hook.
647    #[must_use]
648    pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
649        self.pre_run = Some(pre_run);
650        self
651    }
652
653    /// Sets the command metadata resolver hook.
654    #[must_use]
655    pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
656        self.meta_resolver = Some(meta_resolver);
657        self
658    }
659
660    /// Sets the shutdown hook.
661    #[must_use]
662    pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
663        self.on_shutdown = Some(on_shutdown);
664        self
665    }
666
667    /// Sets the provider for additional root-scope search documents.
668    #[must_use]
669    pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
670        self.extra_search_docs = Some(extra_search_docs);
671        self
672    }
673
674    /// Sets the provider for the bare-root suggested next actions.
675    #[must_use]
676    pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
677        self.root_next_actions = Some(root_next_actions);
678        self
679    }
680
681    /// Sets the name of the admin help category. The engine files the built-in
682    /// `auth` command there; apps should use the same name for their own admin
683    /// modules (e.g. godaddy's `env`). Optional: defaults to `"Admin"`.
684    #[must_use]
685    pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
686        self.admin_category = Some(category.into());
687        self
688    }
689
690    /// Mounts the built-in `config` command group (`config get`/`set`/`path`/
691    /// `list`) for reading and writing the per-application config file.
692    ///
693    /// Off by default so it never collides with a consumer's own `config` noun;
694    /// the group is filed under the admin help category when enabled.
695    #[must_use]
696    pub fn with_config_commands(mut self) -> Self {
697        self.config_commands = true;
698        self
699    }
700
701    /// Registers an alternative `argv[0]` name that acts as a shortcut to a
702    /// command path on this same CLI.
703    ///
704    /// When the binary is invoked under `name` (via symlink, hardlink, copy, or
705    /// the hidden `argv0` command), the engine behaves as if the user had typed
706    /// `command_path` followed by the real argument tail, routed through the
707    /// normal command tree. For example:
708    ///
709    /// ```
710    /// use cli_engine::CliConfig;
711    ///
712    /// // Invoking the binary as `pl --team platform` runs `project list --team platform`.
713    /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
714    ///     .with_argv0_alias("pl", ["project", "list"]);
715    /// ```
716    ///
717    /// `name` must be a simple token: non-empty and composed only of ASCII
718    /// letters, digits, `-`, or `_` (no dots, spaces, path separators, or shell
719    /// metacharacters), and it must differ from the CLI's own name. These are
720    /// debug-asserted. The restriction keeps the name usable as a link/shim
721    /// filename and an `argv[0]` basename (which is matched with its extension
722    /// stripped, so a dot would break matching).
723    #[must_use]
724    pub fn with_argv0_alias(
725        mut self,
726        name: impl Into<String>,
727        command_path: impl IntoIterator<Item = impl Into<String>>,
728    ) -> Self {
729        let name = name.into();
730        debug_assert!(
731            is_valid_argv0_name(&name),
732            "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
733        );
734        debug_assert!(
735            name != self.name,
736            "argv0 route name {name:?} must differ from the CLI's own name {:?}",
737            self.name
738        );
739        let tokens = command_path.into_iter().map(Into::into).collect();
740        self.argv0_routes.insert(name, Argv0Route::Alias(tokens));
741        self
742    }
743
744    /// Registers an alternative `argv[0]` name that runs an entirely separate CLI
745    /// application.
746    ///
747    /// When the binary is invoked under `name`, the engine builds a fresh
748    /// [`CliConfig`] from `build` and runs that application instead — its own root
749    /// name, commands, flags, and auth. The closure runs lazily, only when the
750    /// route is dispatched, so unused personalities cost nothing. The personality
751    /// presents the name from its own [`CliConfig`] in help and usage output.
752    ///
753    /// ```
754    /// use cli_engine::CliConfig;
755    ///
756    /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
757    ///     .with_argv0_personality("legacy-tool", || {
758    ///         CliConfig::new("legacy-tool", "Legacy compatibility shim", "legacy-tool")
759    ///     });
760    /// ```
761    ///
762    /// `name` follows the same contract as [`CliConfig::with_argv0_alias`]: a
763    /// simple `[A-Za-z0-9_-]` token, differing from the CLI's own name
764    /// (debug-asserted).
765    #[must_use]
766    pub fn with_argv0_personality(
767        mut self,
768        name: impl Into<String>,
769        build: impl Fn() -> CliConfig + Send + Sync + 'static,
770    ) -> Self {
771        let name = name.into();
772        debug_assert!(
773            is_valid_argv0_name(&name),
774            "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
775        );
776        debug_assert!(
777            name != self.name,
778            "argv0 route name {name:?} must differ from the CLI's own name {:?}",
779            self.name
780        );
781        self.argv0_routes
782            .insert(name, Argv0Route::Personality(Arc::new(build)));
783        self
784    }
785}
786
787impl std::fmt::Debug for CliConfig {
788    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        formatter
790            .debug_struct("CliConfig")
791            .field("name", &self.name)
792            .field("short", &self.short)
793            .field("long", &self.long)
794            .field("build", &self.build)
795            .field("app_id", &self.app_id)
796            .field("default_auth_provider", &self.default_auth_provider)
797            .field("modules", &self.modules)
798            .field("commands", &self.commands)
799            .field("guides", &self.guides)
800            .field("views", &self.views)
801            .field("auth_providers_len", &self.auth_providers.len())
802            .field("has_authz", &self.authz.is_some())
803            .field("has_auditor", &self.auditor.is_some())
804            .field("has_activity", &self.activity.is_some())
805            .field("has_init_deps", &self.init_deps.is_some())
806            .field("has_register_flags", &self.register_flags.is_some())
807            .field("has_apply_flags", &self.apply_flags.is_some())
808            .field("has_pre_run", &self.pre_run.is_some())
809            .field("has_meta_resolver", &self.meta_resolver.is_some())
810            .field("has_on_shutdown", &self.on_shutdown.is_some())
811            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
812            .field("has_root_next_actions", &self.root_next_actions.is_some())
813            .field("admin_category", &self.admin_category)
814            .field(
815                "argv0_routes",
816                &self.argv0_routes.keys().collect::<Vec<_>>(),
817            )
818            .field("min_stage", &self.min_stage)
819            .field("feature_overrides", &self.feature_overrides)
820            .finish()
821    }
822}
823
824/// Captured result of running a CLI in tests or embedding contexts.
825#[derive(Clone, Debug, PartialEq)]
826pub struct CliRunOutput {
827    /// Process-style exit code.
828    pub exit_code: i32,
829    /// Rendered stdout or stderr payload.
830    pub rendered: String,
831}
832
833impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
834    fn from(o: crate::middleware::MiddlewareOutput) -> Self {
835        Self {
836            exit_code: o.exit_code,
837            rendered: o.rendered,
838        }
839    }
840}
841
842/// Configured CLI application.
843///
844/// A `Cli` owns the `clap` command tree, middleware, registered runtime
845/// commands, guides, schemas, and built-ins. Consumer binaries normally create
846/// one `Cli` and call [`Cli::execute`].
847#[derive(Clone)]
848pub struct Cli {
849    config: CliConfig,
850    middleware: Middleware,
851    root: Command,
852    commands: BTreeMap<String, RuntimeCommandSpec>,
853    module_entries: Vec<ModuleHelpEntry>,
854    guide_entries: Vec<GuideEntry>,
855    init_deps: Option<InitDeps>,
856    apply_flags: Option<ApplyFlags>,
857    pre_run: Option<PreRun>,
858    meta_resolver: Option<ResolveMeta>,
859    on_shutdown: Option<OnShutdown>,
860    extra_search_docs: Option<ExtraSearchDocs>,
861    root_next_actions: Option<RootNextActions>,
862    init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
863}
864
865#[derive(Clone, Debug, Eq, PartialEq)]
866struct InitFailure {
867    message: String,
868    code: String,
869    system: String,
870    request_id: String,
871    fix: Option<String>,
872    exit_code: i32,
873}
874
875impl InitFailure {
876    fn capture(err: &CliCoreError) -> Self {
877        let envelope = crate::output::build_error_envelope(err, "");
878        let (code, system, request_id) = envelope.error.map_or_else(
879            || ("ERROR".to_owned(), String::new(), String::new()),
880            |error| (error.code, error.system, error.request_id),
881        );
882        Self {
883            message: err.to_string(),
884            code,
885            system,
886            request_id,
887            fix: envelope.fix,
888            exit_code: exit_code_for_error(err),
889        }
890    }
891
892    fn into_error(self) -> CliCoreError {
893        let message = CliCoreError::SystemMessage {
894            message: self.message,
895            system: self.system,
896            code: self.code,
897            request_id: self.request_id,
898        };
899        CliCoreError::with_exit_code(
900            self.exit_code,
901            CliCoreError::with_fix(self.fix.unwrap_or_default(), message),
902        )
903    }
904}
905
906impl std::fmt::Debug for Cli {
907    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908        formatter
909            .debug_struct("Cli")
910            .field("config", &self.config)
911            .field("middleware", &self.middleware)
912            .field("root", &self.root)
913            .field("commands", &self.commands)
914            .field("module_entries", &self.module_entries)
915            .field("guide_entries", &self.guide_entries)
916            .field("has_init_deps", &self.init_deps.is_some())
917            .field("has_apply_flags", &self.apply_flags.is_some())
918            .field("has_pre_run", &self.pre_run.is_some())
919            .field("has_meta_resolver", &self.meta_resolver.is_some())
920            .field("has_on_shutdown", &self.on_shutdown.is_some())
921            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
922            .field("has_root_next_actions", &self.root_next_actions.is_some())
923            .finish()
924    }
925}
926
927impl Cli {
928    /// Builds a CLI application from declarative configuration.
929    #[must_use]
930    pub fn new(config: CliConfig) -> Self {
931        let auth_providers = config.auth_providers.clone();
932        let guides = config.guides.clone();
933        let views = config.views.clone();
934        let modules = config.modules.clone();
935        let commands = config.commands.clone();
936        let init_deps = config.init_deps.clone();
937        let apply_flags = config.apply_flags.clone();
938        let pre_run = config.pre_run.clone();
939        let meta_resolver = config.meta_resolver.clone();
940        let on_shutdown = config.on_shutdown.clone();
941        let extra_search_docs = config.extra_search_docs.clone();
942        let root_next_actions = config.root_next_actions.clone();
943        let mut root = Command::new(config.name.clone())
944            .about(config.short.clone())
945            .disable_help_subcommand(true)
946            .version(config.build.version_string());
947        if let Some(long) = &config.long
948            && !long.is_empty()
949        {
950            root = root.long_about(long.clone());
951        }
952        root = register_global_flags(root)
953            .subcommand(help_command())
954            .subcommand(guide_command())
955            .subcommand(Command::new("tree").about("Display full command tree"))
956            .subcommand(completion_command())
957            .subcommand(search_command());
958        if let Some(register_flags) = &config.register_flags {
959            root = register_flags(root);
960        }
961        // `--reason` is only meaningful when something actually consumes it —
962        // an authorizer, auditor, or activity emitter. Apps with none of those
963        // registered never see the flag at all, rather than a flag whose value
964        // is captured and silently discarded. This checks the eager `CliConfig`
965        // fields only: an authorizer/auditor/activity emitter installed later via
966        // `init_deps` runs per-request, after flag registration, so it can't be
967        // observed here. Apps that want `--reason` must set `authz`/`auditor`/
968        // `activity` directly on `CliConfig`, not exclusively through `init_deps`.
969        if config.authz.is_some() || config.auditor.is_some() || config.activity.is_some() {
970            root = register_reason_flag(root);
971        }
972        if config.environments.is_some() {
973            root = root.arg(
974                Arg::new("env")
975                    .long("env")
976                    .global(true)
977                    .value_name("ENV")
978                    .display_order(crate::flags::global_flag_order::ENV)
979                    .help("Override the active environment (see: env list)"),
980            );
981        }
982        let intro = config
983            .long
984            .as_deref()
985            .filter(|long| !long.is_empty())
986            .unwrap_or(config.short.as_str());
987        root = root
988            .long_about(build_root_long(intro, &[], false))
989            .help_template(ROOT_HELP_TEMPLATE);
990
991        let mut middleware = Middleware::new();
992        middleware.app_id = config.app_id.clone();
993        // One-time, macOS-only: move any pre-existing $HOME/.config/<app_id>
994        // contents to $HOME/Library/Application Support/<app_id> before the
995        // config file below is loaded from its (possibly new) location.
996        crate::fs::migrate_macos_config_dir(&config.app_id);
997        // Load the per-application config file once at startup; cloned into each
998        // per-run middleware so handlers and module registration share it.
999        middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id));
1000        middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
1001        middleware.authz = config.authz.clone();
1002        middleware.auditor = config.auditor.clone();
1003        middleware.activity = config.activity.clone();
1004        middleware
1005            .schema_registry
1006            .merge(&global_schema_registry_snapshot());
1007        middleware
1008            .human_views
1009            .merge(&global_human_view_registry_snapshot());
1010        if let Some(environments) = &config.environments {
1011            // Seed the sticky/default active environment now, but let a
1012            // startup `--env` win over it if one is present: `prescan_env_flag`
1013            // scans `startup_args` (or, when unset, the real process argv) the
1014            // same way `apply_env_flag` will parse it for real per invocation
1015            // — this is what lets a same-invocation `--env <name>` affect the
1016            // `flag_policy` computed below (and therefore which flagged
1017            // commands get pruned), not just `middleware.env`. The real,
1018            // per-invocation value used for dispatch still comes from
1019            // `apply_env_flag`'s clap parse in `run_with_depth`; this prescan
1020            // only decides tree shape earlier than clap otherwise could,
1021            // since that decision can't be revisited once the tree is built.
1022            let startup_args = config
1023                .startup_args
1024                .clone()
1025                .unwrap_or_else(|| std::env::args_os().collect());
1026            let startup_env_flag = prescan_env_flag(
1027                startup_args
1028                    .iter()
1029                    .skip(1) // argv[0] is the program name, same convention `run`/`execute_from` use
1030                    .map(|arg| arg.to_string_lossy().into_owned()),
1031            );
1032            // The same `Arc` the consumer shared with any `PkceAuthProvider` is
1033            // reused, so the file layer and active-env persistence resolve
1034            // consistently.
1035            middleware.env =
1036                environments.effective_active(startup_env_flag.as_deref(), &middleware.config);
1037            middleware.environments = Some(Arc::clone(environments));
1038        }
1039        let mut flag_policy = config.flag_policy();
1040        if let Some(min_stage) = global_min_stage_override(&config.app_id) {
1041            flag_policy.min_stage = min_stage;
1042        }
1043        if let Some(environments) = &middleware.environments
1044            && let Ok(source) = environments.source(&middleware.env)
1045        {
1046            let chain = crate::env_config::SourceChain::new().push(&source);
1047            match crate::env_config::resolve_field::<Stage>(
1048                &chain,
1049                "min_stage",
1050                "min_stage",
1051                None,
1052                false,
1053                crate::env_config::default_from_toml::<Stage>,
1054                |_raw: &str| -> std::result::Result<Stage, String> { Err(String::new()) },
1055            ) {
1056                Ok(Some(min_stage)) => flag_policy.min_stage = min_stage,
1057                Ok(None) => {}
1058                Err(err) => {
1059                    tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment min_stage");
1060                }
1061            }
1062            match crate::env_config::resolve_field::<BTreeMap<String, Stage>>(
1063                &chain,
1064                "feature_overrides",
1065                "feature_overrides",
1066                None,
1067                false,
1068                crate::env_config::default_from_toml::<BTreeMap<String, Stage>>,
1069                |_raw: &str| -> std::result::Result<BTreeMap<String, Stage>, String> {
1070                    Err(String::new())
1071                },
1072            ) {
1073                Ok(Some(overrides)) => flag_policy.overrides.extend(overrides),
1074                Ok(None) => {}
1075                Err(err) => {
1076                    tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment feature_overrides");
1077                }
1078            }
1079        }
1080        middleware.flag_policy = flag_policy;
1081
1082        let mut cli = Self {
1083            config,
1084            middleware,
1085            root,
1086            commands: BTreeMap::new(),
1087            module_entries: Vec::new(),
1088            guide_entries: Vec::new(),
1089            init_deps,
1090            apply_flags,
1091            pre_run,
1092            meta_resolver,
1093            on_shutdown,
1094            extra_search_docs,
1095            root_next_actions,
1096            init_state: Arc::new(Mutex::new(None)),
1097        };
1098        for provider in auth_providers {
1099            cli.register_auth_provider(provider);
1100        }
1101        if cli.middleware.default_auth_provider.is_empty()
1102            && let Some(provider) = cli.middleware.auth.registered_names().first()
1103        {
1104            cli.middleware.default_auth_provider = provider.clone();
1105        }
1106        if !cli.middleware.default_auth_provider.is_empty() {
1107            cli.ensure_auth_command();
1108        }
1109        for view in views {
1110            cli.middleware.human_views.register(view);
1111        }
1112        cli.add_guides(guides);
1113        for module in modules {
1114            cli.add_module(module);
1115        }
1116        for command in commands {
1117            cli.add_command(command);
1118        }
1119        if cli.config.config_commands {
1120            cli.ensure_config_command();
1121        }
1122        if cli.config.environments.is_some() {
1123            cli.ensure_env_command();
1124        }
1125        cli.ensure_flags_command();
1126        cli
1127    }
1128
1129    /// Lists the auto-registered `auth` command under the admin help category so
1130    /// it is never uncategorized once clap's auto subcommand list is suppressed.
1131    /// Defaults to [`DEFAULT_ADMIN_CATEGORY`]; `admin_category` overrides it to
1132    /// align with a consumer's own taxonomy.
1133    fn register_auth_help_entry(&mut self) {
1134        let category = self
1135            .config
1136            .admin_category
1137            .clone()
1138            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
1139        let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
1140        let short = self
1141            .root
1142            .find_subcommand("auth")
1143            .filter(|auth| !auth.is_hide_set())
1144            .map(|auth| {
1145                auth.get_about()
1146                    .map(ToString::to_string)
1147                    .unwrap_or_default()
1148            });
1149        if !already_listed && let Some(short) = short {
1150            self.module_entries.push(ModuleHelpEntry {
1151                category,
1152                name: "auth".to_owned(),
1153                short,
1154            });
1155        }
1156        self.refresh_root_long();
1157    }
1158
1159    /// Returns the shared middleware template.
1160    #[must_use]
1161    pub fn middleware(&self) -> &Middleware {
1162        &self.middleware
1163    }
1164
1165    /// Returns mutable middleware for advanced application setup.
1166    pub fn middleware_mut(&mut self) -> &mut Middleware {
1167        &mut self.middleware
1168    }
1169
1170    /// Executes the CLI with process arguments and process stdout/stderr.
1171    pub async fn execute(&self) -> ExitCode {
1172        let mut stdout = std::io::stdout().lock();
1173        let mut stderr = std::io::stderr().lock();
1174        match self
1175            .execute_from(std::env::args_os(), &mut stdout, &mut stderr)
1176            .await
1177        {
1178            Ok(code) => code,
1179            Err(err) => {
1180                drop(writeln!(stderr, "{err}"));
1181                ExitCode::from(1)
1182            }
1183        }
1184    }
1185
1186    /// Executes the CLI with caller-provided args and output writers.
1187    ///
1188    /// If `args` carries a synthetic `--env` unrelated to real process argv
1189    /// (or to whatever [`CliConfig::with_startup_args`] this `Cli` was built
1190    /// with), command-tree pruning — decided once, at construction time —
1191    /// won't reflect it; see `with_startup_args`'s doc for why.
1192    pub async fn execute_from<I, S, O, E>(
1193        &self,
1194        args: I,
1195        stdout: &mut O,
1196        stderr: &mut E,
1197    ) -> std::io::Result<ExitCode>
1198    where
1199        I: IntoIterator<Item = S>,
1200        S: Into<std::ffi::OsString> + Clone,
1201        O: Write,
1202        E: Write,
1203    {
1204        self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
1205            .await
1206    }
1207
1208    /// Executes the CLI until either command completion or a shutdown signal future resolves.
1209    pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
1210        &self,
1211        args: I,
1212        stdout: &mut O,
1213        stderr: &mut E,
1214        shutdown: Shutdown,
1215    ) -> std::io::Result<ExitCode>
1216    where
1217        I: IntoIterator<Item = S>,
1218        S: Into<std::ffi::OsString> + Clone,
1219        O: Write,
1220        E: Write,
1221        Shutdown: Future<Output = ()>,
1222    {
1223        self.install_default_user_agent();
1224        let output = run_until_signal(self.run(args), shutdown).await;
1225        if output.exit_code == 130
1226            && output.rendered == "command interrupted\n"
1227            && let Some(on_shutdown) = &self.on_shutdown
1228        {
1229            on_shutdown();
1230        }
1231        if output.exit_code == 0 {
1232            stdout.write_all(output.rendered.as_bytes())?;
1233        } else {
1234            stderr.write_all(output.rendered.as_bytes())?;
1235        }
1236        Ok(process_exit_code(output.exit_code))
1237    }
1238
1239    /// Publishes the configured outbound User-Agent process-wide so that
1240    /// command [`HttpClient`](crate::transport::HttpClient)s and the engine's
1241    /// own OAuth token requests share it.
1242    ///
1243    /// Called from the execution entrypoints rather than [`Cli::new`] so that
1244    /// merely constructing a `Cli` (as tests do in bulk) does not mutate global
1245    /// state. See [`CliConfig::user_agent_string`] for resolution order.
1246    fn install_default_user_agent(&self) {
1247        crate::transport::set_default_user_agent(self.config.user_agent_string());
1248    }
1249
1250    /// Registers an auth provider after construction.
1251    pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
1252        self.middleware.auth.register(provider);
1253        self.ensure_auth_command();
1254        self.refresh_root_long();
1255        self
1256    }
1257
1258    /// Returns the built `clap` root command.
1259    #[must_use]
1260    pub fn root_command(&self) -> &Command {
1261        &self.root
1262    }
1263
1264    /// Adds one runtime module group after construction.
1265    pub fn add_module_group(
1266        &mut self,
1267        category: impl Into<String>,
1268        group: RuntimeGroupSpec,
1269    ) -> &mut Self {
1270        self.add_module_group_inner(category, group, None)
1271    }
1272
1273    /// Shared implementation behind [`add_module_group`](Self::add_module_group)
1274    /// and [`add_module`](Self::add_module). `inherited` is the effective
1275    /// feature flag the group's enclosing module declared (if any), so a
1276    /// module-level flag cascades down to the group even though
1277    /// `add_module_group` itself has no concept of a module.
1278    fn add_module_group_inner(
1279        &mut self,
1280        category: impl Into<String>,
1281        group: RuntimeGroupSpec,
1282        inherited: Option<FeatureFlag>,
1283    ) -> &mut Self {
1284        // Prevent consumer modules from shadowing engine built-ins in the clap
1285        // command tree.  A reserved group name would override the engine's own
1286        // subcommand (last-writer-wins in clap) and corrupt the dispatch path.
1287        if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
1288            tracing::warn!(
1289                name = %group.group.name,
1290                "module group name is reserved by cli-engine built-ins; the group will not be registered"
1291            );
1292            return self;
1293        }
1294
1295        let mut prefix = Vec::new();
1296        let Some(group) = prune_feature_flag_tree(
1297            group,
1298            inherited.as_ref(),
1299            &self.middleware.flag_policy,
1300            &mut prefix,
1301            &mut self.middleware.flag_registry,
1302        ) else {
1303            return self;
1304        };
1305
1306        let category = category.into();
1307        if !group.group.hidden {
1308            self.module_entries.push(ModuleHelpEntry {
1309                category,
1310                name: group.group.name.clone(),
1311                short: group.group.short.clone(),
1312            });
1313        }
1314
1315        let mut prefix = Vec::new();
1316        register_runtime_group_metadata(
1317            &group,
1318            &mut prefix,
1319            &mut self.middleware.schema_registry,
1320            &mut self.middleware.human_views,
1321        );
1322        let mut prefix = Vec::new();
1323        group.register_commands(&mut prefix, &mut self.commands);
1324        let mut prefix = Vec::new();
1325        let clap_group = runtime_group_clap_command_with_schema_help(
1326            &group,
1327            &mut prefix,
1328            &self.middleware.schema_registry,
1329        );
1330        self.root = self.root.clone().subcommand(clap_group);
1331        self.refresh_root_long();
1332        self
1333    }
1334
1335    /// Adds one module after construction.
1336    pub fn add_module(&mut self, module: Module) -> &mut Self {
1337        for view in module.views.clone() {
1338            self.middleware.human_views.register(view);
1339        }
1340        self.add_guides(module.guides.clone());
1341        let mut context = ModuleContext::new(&mut self.middleware);
1342        let group = (module.register)(&mut context);
1343        let (guides, views) = context.into_parts();
1344        for view in views {
1345            self.middleware.human_views.register(view);
1346        }
1347        self.add_guides(guides);
1348        self.add_module_group_inner(module.category, group, module.feature_flag.clone())
1349    }
1350
1351    /// Adds one top-level runtime command after construction.
1352    pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
1353        let name = command.spec.name.clone();
1354        register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
1355        self.commands.insert(name, command.clone());
1356        self.root = self
1357            .root
1358            .clone()
1359            .subcommand(command_clap_command_with_schema_help(
1360                &command.spec,
1361                &command.spec.name,
1362                &self.middleware.schema_registry,
1363            ));
1364        self
1365    }
1366
1367    /// Controls whether the built-in `guide` command is advertised.
1368    pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
1369        if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1370            self.root = self.root.clone().subcommand(guide_command());
1371        }
1372        self.sync_guide_topic_values();
1373        self.refresh_root_long();
1374        self
1375    }
1376
1377    /// Adds guide entries after construction.
1378    pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
1379        let mut seen = self
1380            .guide_entries
1381            .iter()
1382            .map(|entry| entry.name.clone())
1383            .collect::<BTreeSet<_>>();
1384        for entry in entries {
1385            if seen.insert(entry.name.clone()) {
1386                self.guide_entries.push(entry);
1387            }
1388        }
1389        if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1390            self.root = self.root.clone().subcommand(guide_command());
1391        }
1392        self.sync_guide_topic_values();
1393        self.refresh_root_long();
1394        self
1395    }
1396
1397    /// Re-attaches the `guide` subcommand's `topic` arg possible values from
1398    /// the current [`Self::guide_entries`], so shell completion knows about
1399    /// guide names, which are not all registered up front.
1400    fn sync_guide_topic_values(&mut self) {
1401        if self.guide_entries.is_empty() {
1402            return;
1403        }
1404        let names = self
1405            .guide_entries
1406            .iter()
1407            .map(|entry| entry.name.clone())
1408            .collect::<Vec<_>>();
1409        if let Some(guide_cmd) = self.root.find_subcommand_mut("guide") {
1410            let taken = std::mem::replace(guide_cmd, Command::new("guide"));
1411            *guide_cmd = taken.mut_arg("topic", |arg| {
1412                arg.value_parser(PossibleValuesParser::new(names))
1413            });
1414        }
1415    }
1416
1417    /// Resolves busybox/git-style `argv[0]` dispatch before the normal pipeline.
1418    ///
1419    /// Returns [`Argv0Outcome::Proceed`] with the (possibly rewritten) argument
1420    /// vector to feed the normal command pipeline, or [`Argv0Outcome::Handled`]
1421    /// with a fully rendered result when a personality ran or an explicit `argv0`
1422    /// invocation was rejected. When no routes are registered this is inert and
1423    /// returns the arguments unchanged. `depth` counts chained hand-offs and
1424    /// bounds recursion via [`MAX_ARGV0_DEPTH`].
1425    async fn resolve_argv0(&self, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
1426        if self.config.argv0_routes.is_empty() {
1427            return Argv0Outcome::Proceed(text_args);
1428        }
1429
1430        if depth > MAX_ARGV0_DEPTH {
1431            return Argv0Outcome::Handled(
1432                self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"),
1433            );
1434        }
1435
1436        // The hidden `argv0` meta-command (`<bin> argv0 <name> [args...]`) forces
1437        // a route without an actual symlink. It is recognized positionally as the
1438        // first argument after the program name and is never registered with clap,
1439        // so it stays absent from `--help`, `tree`, and the `search` command.
1440        let explicit = text_args.get(1).map(String::as_str) == Some("argv0");
1441        let (name, rest) = if explicit {
1442            match text_args.get(2) {
1443                None => {
1444                    return Argv0Outcome::Handled(self.render_argv0_error(
1445                        &text_args,
1446                        "the argv0 command requires a name to dispatch as",
1447                    ));
1448                }
1449                // Normalize the explicit name the same way as a symlink basename
1450                // so a route registered as `whatever` matches whether the caller
1451                // passed `whatever`, `whatever.exe`, or a `.cmd` shim's `whatever.cmd`.
1452                Some(name) => (
1453                    program_basename(name),
1454                    text_args
1455                        .get(3..)
1456                        .map(<[String]>::to_vec)
1457                        .unwrap_or_default(),
1458                ),
1459            }
1460        } else {
1461            let name = text_args
1462                .first()
1463                .map(|arg| program_basename(arg))
1464                .unwrap_or_default();
1465            let rest = text_args
1466                .get(1..)
1467                .map(<[String]>::to_vec)
1468                .unwrap_or_default();
1469            (name, rest)
1470        };
1471
1472        match self.config.argv0_routes.get(&name) {
1473            Some(Argv0Route::Alias(tokens)) => {
1474                // Rewrite as `<canonical-name> <tokens...> <rest...>`. Element 0 is
1475                // the canonical name so the downstream program-name skip applies.
1476                let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len());
1477                rewritten.push(self.config.name.clone());
1478                rewritten.extend(tokens.iter().cloned());
1479                rewritten.extend(rest);
1480                Argv0Outcome::Proceed(rewritten)
1481            }
1482            Some(Argv0Route::Personality(build)) => {
1483                // Hand off to an independent CLI built lazily from the route. Its
1484                // own config name leads so its help/usage and program-name skip
1485                // render correctly. `Box::pin` breaks the recursive `async fn`;
1486                // `depth + 1` bounds a pathological chain of hand-offs.
1487                let config = build();
1488                let bin = config.name.clone();
1489                let alt = Self::new(config);
1490                let mut alt_args = Vec::with_capacity(1 + rest.len());
1491                alt_args.push(bin);
1492                alt_args.extend(rest);
1493                Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await)
1494            }
1495            None if explicit => Argv0Outcome::Handled(self.render_argv0_error(
1496                &text_args,
1497                format!(
1498                    "{name:?} is not a registered argv0 name; known names: {}",
1499                    self.known_argv0_names()
1500                ),
1501            )),
1502            None => {
1503                // Unregistered name (e.g. the binary renamed to something we do not
1504                // recognize): fall through to the default CLI. Normalizing element 0
1505                // to the canonical name lets a renamed binary parse as the default
1506                // application instead of treating its name as a command token.
1507                let mut rewritten = Vec::with_capacity(1 + rest.len());
1508                rewritten.push(self.config.name.clone());
1509                rewritten.extend(rest);
1510                Argv0Outcome::Proceed(rewritten)
1511            }
1512        }
1513    }
1514
1515    /// Computes the default output format for this run — the fallback used
1516    /// when no explicit `--output`/`--json`/`--human`/`--toon` is given.
1517    fn resolve_run_output_format(&self) -> String {
1518        use std::io::IsTerminal;
1519
1520        let env = std::env::var(output_env_var(&self.config.app_id)).ok();
1521        let engine_config = self.middleware.config.engine();
1522        resolve_default_output_format(
1523            env.as_deref(),
1524            engine_config.output.format.as_deref(),
1525            std::io::stdout().is_terminal(),
1526        )
1527    }
1528
1529    /// Comma-separated, sorted list of registered alternative `argv[0]` names,
1530    /// used in the error shown for an unknown explicit `argv0` invocation.
1531    fn known_argv0_names(&self) -> String {
1532        self.config
1533            .argv0_routes
1534            .keys()
1535            .cloned()
1536            .collect::<Vec<_>>()
1537            .join(", ")
1538    }
1539
1540    /// Renders an `argv0`-dispatch error through the engine's structured error
1541    /// envelope so it honors `--output` (parsed from the raw args, since dispatch
1542    /// runs before clap) and the shared exit-code mapping, matching every other
1543    /// CLI error rather than emitting bare text.
1544    fn render_argv0_error(&self, text_args: &[String], message: impl Into<String>) -> CliRunOutput {
1545        let mut middleware = self.middleware.clone();
1546        middleware.output_format =
1547            extract_output_format(text_args, &self.resolve_run_output_format());
1548        let err = CliCoreError::message(message);
1549        self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id))
1550    }
1551
1552    /// Returns the registered alternative `argv[0]` names, sorted.
1553    ///
1554    /// Useful for install or self-healing code that iterates the names and calls
1555    /// [`Cli::create_link`] for each.
1556    #[must_use]
1557    pub fn argv0_names(&self) -> Vec<&str> {
1558        self.config
1559            .argv0_routes
1560            .keys()
1561            .map(String::as_str)
1562            .collect()
1563    }
1564
1565    /// Creates an on-disk link in `dir` that lets the binary be invoked under the
1566    /// registered alternative `argv[0]` name `name`, using `method`.
1567    ///
1568    /// `target` is the executable the link points at; pass `None` to use the
1569    /// current executable ([`std::env::current_exe`]), which is the common choice
1570    /// for install and self-healing code. The file name follows the platform and
1571    /// method: a symlink or hard link is `<name>` on Unix and `<name>.exe` on
1572    /// Windows; a [`Argv0LinkMethod::Script`] shim is `<name>.cmd` on Windows and
1573    /// an executable `<name>` shell script on Unix.
1574    ///
1575    /// The call ensures the desired state idempotently: if the destination already
1576    /// matches what would be created (a symlink to `target`, a hard link with the
1577    /// same contents, or a shim with identical contents) it is left untouched and
1578    /// its path returned; if it exists but differs (wrong kind, stale target, or
1579    /// edited shim) it is replaced. This makes the call safe to re-run as install
1580    /// or self-healing code, restoring both deleted and corrupted links. The
1581    /// directory is created if necessary.
1582    ///
1583    /// # Errors
1584    ///
1585    /// Returns an error if `name` is not a registered route, if the current
1586    /// executable cannot be resolved (when `target` is `None`), or if the
1587    /// directory or link cannot be created or replaced (e.g. insufficient
1588    /// privilege for a Windows symlink, or a hard link across volumes).
1589    pub fn create_link(
1590        &self,
1591        name: &str,
1592        dir: impl AsRef<Path>,
1593        target: Option<&Path>,
1594        method: Argv0LinkMethod,
1595    ) -> std::io::Result<PathBuf> {
1596        if !self.config.argv0_routes.contains_key(name) {
1597            return Err(std::io::Error::new(
1598                std::io::ErrorKind::InvalidInput,
1599                format!("{name:?} is not a registered argv0 name"),
1600            ));
1601        }
1602
1603        let dir = dir.as_ref();
1604        std::fs::create_dir_all(dir)?;
1605        let link = dir.join(argv0_link_file_name(name, method));
1606
1607        // Resolve the target up front so an existing entry can be compared against it.
1608        let resolved_target;
1609        let target = match target {
1610            Some(target) => target,
1611            None => {
1612                resolved_target = std::env::current_exe()?;
1613                resolved_target.as_path()
1614            }
1615        };
1616
1617        // Ensure-desired-state. `symlink_metadata` does not follow links, so a
1618        // present-but-dangling link still counts as existing. A matching entry is
1619        // left untouched (idempotent); a differing one is removed and recreated.
1620        if std::fs::symlink_metadata(&link).is_ok() {
1621            if argv0_link_matches(&link, target, name, method)? {
1622                return Ok(link);
1623            }
1624            std::fs::remove_file(&link)?;
1625        }
1626
1627        match method {
1628            Argv0LinkMethod::SoftLink => create_symlink(target, &link)?,
1629            Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?,
1630            Argv0LinkMethod::Script => {
1631                std::fs::write(&link, argv0_script_contents(target, name))?;
1632                make_executable(&link)?;
1633            }
1634        }
1635        Ok(link)
1636    }
1637
1638    /// Runs the CLI with provided args and captures the rendered result.
1639    ///
1640    /// Same `--env`/tree-pruning caveat as [`Cli::execute_from`]: see
1641    /// [`CliConfig::with_startup_args`].
1642    pub async fn run<I, S>(&self, args: I) -> CliRunOutput
1643    where
1644        I: IntoIterator<Item = S>,
1645        S: Into<std::ffi::OsString> + Clone,
1646    {
1647        self.run_with_depth(args, 0).await
1648    }
1649
1650    /// Runs the CLI like [`Cli::run`], threading the `argv0` dispatch recursion
1651    /// `depth` so a chain of personality hand-offs is bounded by [`MAX_ARGV0_DEPTH`].
1652    async fn run_with_depth<I, S>(&self, args: I, depth: usize) -> CliRunOutput
1653    where
1654        I: IntoIterator<Item = S>,
1655        S: Into<std::ffi::OsString> + Clone,
1656    {
1657        let raw_args = args
1658            .into_iter()
1659            .map(Into::into)
1660            .collect::<Vec<std::ffi::OsString>>();
1661        let text_args = raw_args
1662            .iter()
1663            .map(|arg| arg.to_string_lossy().into_owned())
1664            .collect::<Vec<_>>();
1665        let text_args = match self.resolve_argv0(text_args, depth).await {
1666            Argv0Outcome::Handled(output) => return output,
1667            Argv0Outcome::Proceed(args) => args,
1668        };
1669        let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
1670        if has_root_version_flag(&text_args, &self.root, &self.config.name) {
1671            return self.finish_run(CliRunOutput {
1672                exit_code: 0,
1673                rendered: format!(
1674                    "{} version {}\n",
1675                    self.config.name,
1676                    self.config.build.version_string()
1677                ),
1678            });
1679        }
1680        if let Some(output) = self.try_run_schema_bypass(&text_args) {
1681            return output;
1682        }
1683        // Resolve the positional command path once and share it between the
1684        // group-help rewrite and the unknown-command check below.
1685        let bool_flags = derive_bool_flags(&self.root);
1686        let value_flags = derive_value_flags(&self.root);
1687        let positionals =
1688            positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
1689        let command_keyword_count =
1690            command_keyword_count(&text_args, &self.config.name, &bool_flags, &value_flags);
1691        if let Some(parts) =
1692            group_help_target_parts(&self.root, &positionals, command_keyword_count)
1693        {
1694            // Rewrite `<group> help [sub...]` into the canonical
1695            // `help <group> [sub...]` so it flows through the curated root
1696            // `help` command, which also runs global-flag parsing and the
1697            // `pre_run` hook (matching `help <group>` and bare-group help).
1698            // Only the positional command tokens are reordered; every flag and
1699            // its value is preserved in place so e.g. `--output json` survives.
1700            clap_args = rewrite_group_help_args(
1701                &clap_args,
1702                &self.config.name,
1703                &bool_flags,
1704                &value_flags,
1705                &parts,
1706            );
1707        } else if let Some(unknown) =
1708            detect_unknown_group_command(&self.root, &positionals[..command_keyword_count])
1709        {
1710            // Hint/re-dispatch only when the whole path resolves to one command.
1711            if let Some(corrections) =
1712                full_command_correction(&self.root, &positionals[..command_keyword_count])
1713            {
1714                let display = correction_display(
1715                    &self.config.name,
1716                    &positionals[..command_keyword_count],
1717                    &corrections,
1718                );
1719                let full_fix_message = format_did_you_mean(&unknown.base, &display);
1720                match crate::prompt::confirm_command_correction(
1721                    &clap_args,
1722                    &display,
1723                    self.config.auto_interactive,
1724                ) {
1725                    crate::prompt::CommandCorrection::Accepted => {
1726                        for (index, replacement) in &corrections {
1727                            clap_args = replace_positional_command_token(
1728                                &clap_args,
1729                                &self.config.name,
1730                                &bool_flags,
1731                                &value_flags,
1732                                *index,
1733                                replacement,
1734                            );
1735                        }
1736                        clap_args = rewrite_group_help_if_needed(
1737                            &self.root,
1738                            &clap_args,
1739                            &self.config.name,
1740                            &bool_flags,
1741                            &value_flags,
1742                        );
1743                    }
1744                    crate::prompt::CommandCorrection::Declined => {
1745                        return self.finish_run(CliRunOutput {
1746                            exit_code: 1,
1747                            rendered: full_fix_message,
1748                        });
1749                    }
1750                    crate::prompt::CommandCorrection::Cancelled => {
1751                        return self.finish_run(CliRunOutput {
1752                            exit_code: 130,
1753                            rendered: "Cancelled.".to_owned(),
1754                        });
1755                    }
1756                }
1757            } else {
1758                return self.finish_run(CliRunOutput {
1759                    exit_code: 1,
1760                    rendered: unknown.base,
1761                });
1762            }
1763        }
1764
1765        let matches = match self.root.clone().try_get_matches_from(&clap_args) {
1766            Ok(matches) => matches,
1767            Err(err) => {
1768                // Attempt interactive recovery for missing required arguments.
1769                if let Some(recovery) = crate::prompt::try_recover_missing_args(
1770                    &err,
1771                    &clap_args,
1772                    &self.root,
1773                    &self.config.name,
1774                    self.config.auto_interactive,
1775                ) {
1776                    match recovery {
1777                        crate::prompt::RecoveryResult::Recovered { args } => {
1778                            match self.root.clone().try_get_matches_from(args) {
1779                                Ok(m) => m,
1780                                Err(retry_err) => {
1781                                    return self.finish_run(CliRunOutput {
1782                                        exit_code: retry_err.exit_code(),
1783                                        rendered: retry_err.to_string(),
1784                                    });
1785                                }
1786                            }
1787                        }
1788                        crate::prompt::RecoveryResult::Cancelled { resume } => {
1789                            return self.finish_run(CliRunOutput {
1790                                exit_code: 130,
1791                                rendered: format!("Cancelled. Resume with:\n  {resume}\n"),
1792                            });
1793                        }
1794                    }
1795                } else {
1796                    return self.finish_run(CliRunOutput {
1797                        exit_code: err.exit_code(),
1798                        rendered: err.to_string(),
1799                    });
1800                }
1801            }
1802        };
1803
1804        let default_format = self.resolve_run_output_format();
1805        let flags =
1806            global_flags_from_matches(&matches, &default_format, self.config.auto_interactive);
1807        // Publish the --credential-store override so auth providers resolving
1808        // their storage backend see it at the top of the precedence chain.
1809        crate::config::set_credential_store_flag(flags.credential_store);
1810        let command_timeout = match parse_command_timeout(&flags.timeout) {
1811            Ok(timeout) => timeout,
1812            Err(err) => {
1813                return self.finish_run(render_cli_error(
1814                    &self.middleware,
1815                    &err,
1816                    &self.config.app_id,
1817                ));
1818            }
1819        };
1820        let mut middleware = self.middleware.clone();
1821        apply_global_flags(&mut middleware, &flags, command_timeout);
1822        install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1823        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1824            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1825        }
1826        // Validate and apply `--env` for built-in paths (help/tree/guide/group
1827        // help) so they reflect the selected environment and reject unknowns.
1828        if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1829            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1830        }
1831
1832        let command_path = command_path_from_matches(&self.config.name, &matches);
1833        if command_path == "help" {
1834            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
1835            {
1836                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1837            }
1838            return self.finish_run(self.render_help_command(&matches));
1839        }
1840        if command_path == "tree" {
1841            if let Err(err) = self.run_pre_run(
1842                &mut middleware,
1843                &command_path,
1844                &crate::middleware::ValueMap::new(),
1845            ) {
1846                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1847            }
1848            return self.finish_run(tree_render::render_tree(
1849                &self.root,
1850                &self.config.app_id,
1851                &middleware,
1852            ));
1853        }
1854        if command_path == "guide" {
1855            if let Err(err) =
1856                self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
1857            {
1858                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1859            }
1860            return self.finish_run(self.render_guide(&matches, &flags.output_format));
1861        }
1862        if command_path == "search" {
1863            let args = search_args(&matches);
1864            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1865                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1866            }
1867            let query = args
1868                .get("query")
1869                .and_then(|v| v.as_str())
1870                .unwrap_or_default();
1871            let scope_path = args
1872                .get("scope")
1873                .and_then(|v| v.as_str())
1874                .unwrap_or_default();
1875            let scope = self.resolve_search_scope(scope_path);
1876            return self.finish_run(self.render_search(query, &scope, &flags.output_format));
1877        }
1878        if command_path == "completion" {
1879            let args = completion_args(&matches);
1880            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1881                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1882            }
1883            let install = args
1884                .get("install")
1885                .and_then(|v| v.as_bool())
1886                .unwrap_or(false);
1887            let shell_opt = args
1888                .get("shell")
1889                .and_then(|v| v.as_str())
1890                .map(str::to_owned);
1891            if install {
1892                use crate::cli::completion::{detect_shell, parse_shell};
1893                let shell = match shell_opt {
1894                    Some(ref s) => match parse_shell(s) {
1895                        Ok(s) => s,
1896                        Err(e) => {
1897                            return self.finish_run(render_cli_error(
1898                                &middleware,
1899                                &e,
1900                                &self.config.app_id,
1901                            ));
1902                        }
1903                    },
1904                    None => match detect_shell() {
1905                        Ok(s) => s,
1906                        Err(e) => {
1907                            return self.finish_run(render_cli_error(
1908                                &middleware,
1909                                &e,
1910                                &self.config.app_id,
1911                            ));
1912                        }
1913                    },
1914                };
1915                return self.finish_run(
1916                    completion::install(&self.root, &self.config.name, shell)
1917                        .await
1918                        .unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
1919                );
1920            }
1921            return self.finish_run(self.render_completion_print(shell_opt, &middleware));
1922        }
1923        let Some(command) = self.commands.get(&command_path) else {
1924            if !command_path.is_empty()
1925                && let Some(group) = find_command_by_colon_path(&self.root, &command_path)
1926                && group.get_subcommands().next().is_some()
1927            {
1928                if let Err(err) = self.run_pre_run(
1929                    &mut middleware,
1930                    &command_path,
1931                    &crate::middleware::ValueMap::new(),
1932                ) {
1933                    return self.finish_run(render_cli_error(
1934                        &middleware,
1935                        &err,
1936                        &self.config.app_id,
1937                    ));
1938                }
1939                if middleware.interactive
1940                    && let Some(subcommand) = single_leaf_subcommand(group)
1941                {
1942                    let augmented = inject_subcommand_after_command_path(
1943                        &text_args,
1944                        &self.config.name,
1945                        &command_path,
1946                        &subcommand,
1947                        &bool_flags,
1948                        &value_flags,
1949                    );
1950                    return Box::pin(self.run_with_depth(augmented, depth + 1)).await;
1951                }
1952                return self.finish_run(self.render_bare_group_discovery(
1953                    group,
1954                    &command_path,
1955                    &middleware,
1956                ));
1957            }
1958            if command_path.is_empty()
1959                && let Some(root_next_actions) = &self.root_next_actions
1960            {
1961                // Bare-root discovery is static (help text / metadata + action
1962                // pointers) and must always be available as a cold-start entry
1963                // point, so we skip `pre_run` here — matching the no-hook
1964                // bare-root path below, which also renders help without it.
1965                let actions = root_next_actions();
1966                return self.finish_run(self.render_root(&middleware, actions));
1967            }
1968            return self.finish_run(CliRunOutput {
1969                exit_code: if command_path.is_empty() { 0 } else { 1 },
1970                rendered: if command_path.is_empty() {
1971                    self.root.clone().render_long_help().to_string()
1972                } else {
1973                    format!("unknown command {command_path:?}")
1974                },
1975            });
1976        };
1977
1978        let mut middleware = match self.initialized_middleware() {
1979            Ok(middleware) => middleware,
1980            Err(err) => {
1981                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1982            }
1983        };
1984        apply_global_flags(&mut middleware, &flags, command_timeout);
1985        install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1986        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1987            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1988        }
1989        // The global `--env` flag overrides the seeded active environment for
1990        // this invocation; an unknown name surfaces as an error envelope.
1991        if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1992            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1993        }
1994
1995        let leaf = leaf_matches(&matches);
1996        apply_pagination_flags(&mut middleware, &command.spec, leaf);
1997        let args = command_args_from_matches(leaf, &command.spec, false);
1998        let user_args = command_args_from_matches(leaf, &command.spec, true);
1999        let pagination_command = command.spec.pagination.is_some().then(|| {
2000            pagination_command_base(
2001                &self.config.name,
2002                &command_path,
2003                &command.spec,
2004                &user_args,
2005                &flags,
2006            )
2007        });
2008        if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
2009            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
2010        }
2011        let meta = self.resolve_meta(&command_path, command.spec.metadata());
2012        let default_fields = command.spec.default_fields.clone().unwrap_or_default();
2013        let system = command.spec.system.clone().unwrap_or_default();
2014        // The human view this command declared: an explicit shared id wins;
2015        // otherwise an inline `with_view` was registered under the command path
2016        // at build time, so reference it by that path. `None` renders generic
2017        // human output.
2018        let view_id = command
2019            .spec
2020            .view_id
2021            .clone()
2022            .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
2023
2024        if let Some(streaming_handler) = command.streaming_handler.clone() {
2025            let result = run_with_timeout(
2026                command_timeout,
2027                &flags.timeout,
2028                run_streaming_command(
2029                    &middleware,
2030                    MiddlewareRequest {
2031                        meta,
2032                        command_path: &command_path,
2033                        system: &system,
2034                        user_args,
2035                        args,
2036                        default_fields: &default_fields,
2037                        view_id: view_id.as_deref(),
2038                        auth: command.spec.auth,
2039                        raw_output: command.spec.raw_output,
2040                        pagination_command,
2041                    },
2042                    Arc::new(leaf.clone()),
2043                    streaming_handler,
2044                ),
2045            )
2046            .await;
2047            return self.finish_run(match result {
2048                Ok(output) => output,
2049                Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
2050            });
2051        }
2052
2053        let handler = command.handler.clone();
2054        let args_for_handler = args.clone();
2055        let user_args_for_handler = user_args.clone();
2056        let handler_path = command_path.clone();
2057        let middleware_for_handler = middleware.clone();
2058        let raw_matches_for_handler = Arc::new(leaf.clone());
2059        let result = run_with_timeout(
2060            command_timeout,
2061            &flags.timeout,
2062            middleware.run(
2063                MiddlewareRequest {
2064                    meta,
2065                    command_path: &command_path,
2066                    system: &system,
2067                    user_args,
2068                    args,
2069                    default_fields: &default_fields,
2070                    view_id: view_id.as_deref(),
2071                    auth: command.spec.auth,
2072                    raw_output: command.spec.raw_output,
2073                    pagination_command,
2074                },
2075                async move |credential| {
2076                    handler(CommandContext {
2077                        credential,
2078                        args: args_for_handler,
2079                        user_args: user_args_for_handler,
2080                        command_path: handler_path,
2081                        middleware: middleware_for_handler,
2082                        raw_matches: raw_matches_for_handler,
2083                    })
2084                    .await
2085                },
2086            ),
2087        )
2088        .await;
2089
2090        match result {
2091            Ok(output) => self.finish_run(output.into()),
2092            Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
2093        }
2094    }
2095
2096    fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
2097        if !has_true_schema_flag(args) {
2098            return None;
2099        }
2100        let bool_flags = derive_bool_flags(&self.root);
2101        let value_flags = derive_value_flags(&self.root);
2102        let command_path =
2103            self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
2104        // `--schema` is an inspection flag and must not require the command's own
2105        // arguments, so it short-circuits before clap validates them. Only fire
2106        // for a real leaf command, though: unknown paths and groups fall through
2107        // so clap and `detect_unknown_group_command` can report them as usual.
2108        let command = find_command_by_colon_path(&self.root, &command_path)?;
2109        if command.get_subcommands().next().is_some() {
2110            return None;
2111        }
2112        let output_format = extract_output_format(args, &self.resolve_run_output_format());
2113        // When no schema is registered, report that rather than running the
2114        // command — matching the middleware's no-schema response so the public
2115        // path and the lower layer agree even when required args are missing.
2116        match self.middleware.schema_registry.get_by_path(&command_path) {
2117            Some(schema) => Some(self.render_schema(schema, &output_format)),
2118            None => Some(self.render_schema(
2119                crate::output::no_schema_response(&command_path),
2120                &output_format,
2121            )),
2122        }
2123    }
2124
2125    fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
2126        let format: crate::output::OutputFormat = match output_format.parse() {
2127            Ok(format) => format,
2128            Err(err) => {
2129                return CliRunOutput {
2130                    exit_code: exit_code_for_error(&err),
2131                    rendered: err.to_string(),
2132                };
2133            }
2134        };
2135        let envelope =
2136            crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
2137        match crate::output::render(format, &envelope) {
2138            Ok(rendered) => CliRunOutput {
2139                exit_code: 0,
2140                rendered,
2141            },
2142            Err(err) => CliRunOutput {
2143                exit_code: exit_code_for_error(&err),
2144                rendered: err.to_string(),
2145            },
2146        }
2147    }
2148
2149    /// Renders a bare group invocation (no subcommand given).
2150    ///
2151    /// Human output keeps the existing clap help text; every other format,
2152    /// explicit `--output json`/`--toon`, or the non-TTY default an agent
2153    /// sees with no `--output` flag at all — gets an explicit JSON
2154    /// command-tree subset scoped to this group, built with the same
2155    /// [`crate::tree`] machinery as the top-level `tree` command.
2156    fn render_bare_group_discovery(
2157        &self,
2158        group: &Command,
2159        command_path: &str,
2160        middleware: &Middleware,
2161    ) -> CliRunOutput {
2162        let format: crate::output::OutputFormat = match middleware.output_format.parse() {
2163            Ok(format) => format,
2164            Err(err) => {
2165                return CliRunOutput {
2166                    exit_code: exit_code_for_error(&err),
2167                    rendered: err.to_string(),
2168                };
2169            }
2170        };
2171        if format == crate::output::OutputFormat::Human {
2172            return CliRunOutput {
2173                exit_code: 0,
2174                rendered: group.clone().render_long_help().to_string(),
2175            };
2176        }
2177        let path = format!("{} {}", self.config.name, command_path.replace(':', " "));
2178        let tree = crate::tree::build_tree_from_clap_with_path(group, path);
2179        tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format)
2180    }
2181
2182    fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
2183        let format: crate::output::OutputFormat = match output_format.parse() {
2184            Ok(format) => format,
2185            Err(err) => {
2186                return CliRunOutput {
2187                    exit_code: exit_code_for_error(&err),
2188                    rendered: err.to_string(),
2189                };
2190            }
2191        };
2192        let docs = self.search_documents(scope);
2193        let results = SearchIndex::new(docs).search(query, 10);
2194        let envelope =
2195            crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
2196        match crate::output::render(format, &envelope) {
2197            Ok(rendered) => CliRunOutput {
2198                exit_code: 0,
2199                rendered,
2200            },
2201            Err(err) => CliRunOutput {
2202                exit_code: exit_code_for_error(&err),
2203                rendered: err.to_string(),
2204            },
2205        }
2206    }
2207
2208    /// Renders the bare-root response. For human output, renders long help plus
2209    /// a "Next actions" section so a human invoking the CLI with no arguments
2210    /// gets readable guidance; for machine-readable output, emits a discovery
2211    /// envelope (light metadata + next actions). The output format has already
2212    /// resolved the TTY/env/flag policy, so this just branches on it.
2213    fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
2214        // Reject an invalid explicit `--output` here too, matching the normal
2215        // command path (`Middleware::render_envelope`). `OutputFormat::from_str`
2216        // is infallible and would otherwise silently coerce an unrecognized
2217        // value (e.g. `--output yaml`) to JSON instead of reporting the error.
2218        if !crate::output::is_valid_output_format(&middleware.output_format) {
2219            let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
2220            return CliRunOutput {
2221                exit_code: exit_code_for_error(&err),
2222                rendered: err.to_string(),
2223            };
2224        }
2225        let format = middleware
2226            .output_format
2227            .parse()
2228            .unwrap_or(crate::output::OutputFormat::Json);
2229        if format == crate::output::OutputFormat::Human {
2230            // Fold the suggested actions into the root long-about so they render
2231            // alongside the other curated sections (before Usage) instead of
2232            // dangling beneath clap's options dump.
2233            let base_long = self
2234                .root
2235                .get_long_about()
2236                .map(ToString::to_string)
2237                .unwrap_or_default();
2238            let long = format!("{base_long}{}", render_next_actions_human(&actions));
2239            let rendered = self
2240                .root
2241                .clone()
2242                .long_about(long)
2243                .render_long_help()
2244                .to_string();
2245            return CliRunOutput {
2246                exit_code: 0,
2247                rendered,
2248            };
2249        }
2250        let description = self
2251            .config
2252            .long
2253            .as_deref()
2254            .filter(|long| !long.is_empty())
2255            .unwrap_or(self.config.short.as_str());
2256        let data = serde_json::json!({
2257            "description": description,
2258            "version": self.config.build.version,
2259        });
2260        let envelope = crate::Envelope::success(data, self.config.app_id.clone())
2261            .with_next_actions(actions)
2262            .prepare_for_render(&middleware.verbose);
2263        match crate::output::render(format, &envelope) {
2264            Ok(rendered) => CliRunOutput {
2265                exit_code: 0,
2266                rendered,
2267            },
2268            Err(err) => CliRunOutput {
2269                exit_code: exit_code_for_error(&err),
2270                rendered: err.to_string(),
2271            },
2272        }
2273    }
2274
2275    fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
2276        let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
2277            .unwrap_or((&self.root, Vec::new()));
2278        let mut docs = Vec::new();
2279        let mut aliases = Vec::new();
2280        append_command_alias_terms(scoped, &mut aliases);
2281        collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
2282        if scope.is_empty() {
2283            for entry in &self.guide_entries {
2284                docs.push(SearchDocument {
2285                    id: format!("guide:{}", entry.name),
2286                    kind: "guide".to_owned(),
2287                    title: format!("guide {}", entry.name),
2288                    summary: entry.summary.clone(),
2289                    content: format!("{} {}", entry.summary, entry.content),
2290                });
2291            }
2292            if let Some(extra_search_docs) = &self.extra_search_docs {
2293                docs.extend(extra_search_docs());
2294            }
2295        }
2296        docs
2297    }
2298
2299    /// Resolves `--scope`'s colon-separated path (e.g. `domain` or
2300    /// `domain:list`) to the canonical scope string [`Self::search_documents`]
2301    /// expects, matching aliases the same way a real command path would (via
2302    /// [`canonical_path_from_parts`]'s `find_subcommand` walk). An empty or
2303    /// unresolvable scope falls back to an unscoped (root) search rather than
2304    /// erroring — `search` staying permissive here matches how a typo in a
2305    /// search *query* just yields fewer results instead of a hard failure.
2306    /// An unresolvable (non-empty) scope prints a best-effort stderr hint
2307    /// first, so a typo like `--scope doamin` doesn't silently widen the
2308    /// search with no explanation for the extra results.
2309    fn resolve_search_scope(&self, scope_path: &str) -> String {
2310        if scope_path.is_empty() {
2311            return String::new();
2312        }
2313        let parts: Vec<String> = scope_path.split(':').map(str::to_owned).collect();
2314        match canonical_path_from_parts(&self.root, &parts) {
2315            Some(scope) => scope,
2316            None => {
2317                warn_unresolvable_search_scope(scope_path);
2318                String::new()
2319            }
2320        }
2321    }
2322
2323    fn canonical_command_path(&self, command_path: &str) -> String {
2324        find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
2325            || command_path.to_owned(),
2326            |(_, canonical)| canonical.join(":"),
2327        )
2328    }
2329
2330    fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput {
2331        use std::io::IsTerminal;
2332
2333        // Reject an invalid explicit `--output` here too, matching the normal
2334        // command path and `render_root`; otherwise an unrecognized value (e.g.
2335        // `--output yaml`) would silently fall through and emit raw content.
2336        if !crate::output::is_valid_output_format(output_format) {
2337            let err = CliCoreError::InvalidOutputFormat(output_format.to_owned());
2338            return CliRunOutput {
2339                exit_code: exit_code_for_error(&err),
2340                rendered: err.to_string(),
2341            };
2342        }
2343
2344        let leaf = leaf_matches(matches);
2345        let topic = leaf.get_one::<String>("topic").map(String::as_str);
2346        match guide_content(&self.guide_entries, topic) {
2347            Ok(rendered) => {
2348                // Only reflow an actual guide topic body, and only for human output.
2349                // The topic list is plain text (not markdown) and json/toon keep the
2350                // raw markdown so their output stays deterministic.
2351                let rendered = if topic.is_some() && output_format == "human" {
2352                    let is_tty = std::io::stdout().is_terminal();
2353                    render_guide_human(&rendered, crate::output::terminal_width(), is_tty)
2354                } else {
2355                    rendered
2356                };
2357                CliRunOutput {
2358                    exit_code: 0,
2359                    rendered,
2360                }
2361            }
2362            Err(err) => CliRunOutput {
2363                exit_code: 1,
2364                rendered: err,
2365            },
2366        }
2367    }
2368
2369    fn render_completion_print(
2370        &self,
2371        shell_opt: Option<String>,
2372        middleware: &Middleware,
2373    ) -> CliRunOutput {
2374        use crate::cli::completion::{detect_shell, generate_script, parse_shell};
2375        let shell = match shell_opt {
2376            Some(s) => match parse_shell(&s) {
2377                Ok(s) => s,
2378                Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2379            },
2380            None => match detect_shell() {
2381                Ok(s) => s,
2382                Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2383            },
2384        };
2385        match generate_script(&self.root, &self.config.name, shell) {
2386            Ok(script) => CliRunOutput {
2387                exit_code: 0,
2388                rendered: script,
2389            },
2390            Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
2391        }
2392    }
2393
2394    fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
2395        let leaf = leaf_matches(matches);
2396        let parts = leaf
2397            .get_many::<String>("command")
2398            .map(|values| values.map(String::as_str).collect::<Vec<_>>())
2399            .unwrap_or_default();
2400        self.render_help_for_parts(&parts)
2401    }
2402
2403    /// Renders the curated help text for a resolved command path.
2404    ///
2405    /// Empty `parts` render the root help. A path that resolves to a group or
2406    /// command renders that command's long help; an unresolved path returns the
2407    /// standard "unknown command" guidance with a non-zero exit code. Shared by
2408    /// the root `help <path>` command and the `<group> help` subcommand form.
2409    fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
2410        if parts.is_empty() {
2411            return CliRunOutput {
2412                exit_code: 0,
2413                rendered: self.root.clone().render_long_help().to_string(),
2414            };
2415        }
2416        let Some(command) = find_help_target(&self.root, parts) else {
2417            return CliRunOutput {
2418                exit_code: 1,
2419                rendered: format!(
2420                    "unknown command {:?} — run '{} help' for available commands",
2421                    parts.join(" "),
2422                    self.config.name
2423                ),
2424            };
2425        };
2426        CliRunOutput {
2427            exit_code: 0,
2428            rendered: command.clone().render_long_help().to_string(),
2429        }
2430    }
2431
2432    fn refresh_root_long(&mut self) {
2433        // Module-categorized entries, plus any visible top-level command that is
2434        // neither categorized nor an engine built-in, listed under a generic
2435        // "Commands" section. This keeps every command discoverable once clap's
2436        // auto subcommand list is suppressed by the root help template.
2437        let builtins = BUILTIN_COMMAND_NAMES;
2438        let categorized: BTreeSet<&str> = self
2439            .module_entries
2440            .iter()
2441            .map(|entry| entry.name.as_str())
2442            .collect();
2443        let mut generic: Vec<ModuleHelpEntry> = self
2444            .root
2445            .get_subcommands()
2446            .filter(|command| !command.is_hide_set())
2447            .filter(|command| !builtins.contains(&command.get_name()))
2448            .filter(|command| !categorized.contains(command.get_name()))
2449            .map(|command| ModuleHelpEntry {
2450                category: "Commands".to_owned(),
2451                name: command.get_name().to_owned(),
2452                short: command
2453                    .get_about()
2454                    .map(ToString::to_string)
2455                    .unwrap_or_default(),
2456            })
2457            .collect();
2458        generic.sort_by(|left, right| left.name.cmp(&right.name));
2459
2460        let mut entries = self.module_entries.clone();
2461        entries.extend(generic);
2462        let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
2463        let intro = self
2464            .config
2465            .long
2466            .as_deref()
2467            .filter(|long| !long.is_empty())
2468            .unwrap_or(self.config.short.as_str());
2469        self.root = self
2470            .root
2471            .clone()
2472            .long_about(build_root_long(intro, &entries, has_guide));
2473    }
2474
2475    fn ensure_auth_command(&mut self) {
2476        let default_provider = self.default_auth_provider();
2477        let registered_names = self.middleware.auth.registered_names();
2478        if default_provider.is_empty() && registered_names.is_empty() {
2479            return;
2480        }
2481        let replacing_builtin = self.commands.contains_key("auth:login");
2482        if has_subcommand(&self.root, "auth") && !replacing_builtin {
2483            return;
2484        }
2485        let mut group = auth_command_group(&default_provider, &registered_names);
2486        let mut seen_names: std::collections::HashSet<String> =
2487            group.commands.iter().map(|c| c.spec.name.clone()).collect();
2488        for extra in self.config.auth_extra_commands.clone() {
2489            if !seen_names.insert(extra.spec.name.clone()) {
2490                tracing::warn!(
2491                    command = %extra.spec.name,
2492                    "auth_extra_commands entry collides with a built-in auth subcommand or an \
2493                     earlier auth_extra_commands entry; ignoring"
2494                );
2495                continue;
2496            }
2497            group = group.with_command(extra);
2498        }
2499        let mut prefix = Vec::new();
2500        register_runtime_group_metadata(
2501            &group,
2502            &mut prefix,
2503            &mut self.middleware.schema_registry,
2504            &mut self.middleware.human_views,
2505        );
2506        let mut prefix = Vec::new();
2507        group.register_commands(&mut prefix, &mut self.commands);
2508        let mut prefix = Vec::new();
2509        let clap_group = runtime_group_clap_command_with_schema_help(
2510            &group,
2511            &mut prefix,
2512            &self.middleware.schema_registry,
2513        );
2514        self.root = if replacing_builtin {
2515            self.root.clone().mut_subcommand("auth", |_| clap_group)
2516        } else {
2517            self.root.clone().subcommand(clap_group)
2518        };
2519        // Categorize `auth` wherever it is ensured (construction or a later
2520        // `register_auth_provider`), so it never falls into the generic
2521        // "Commands" bucket. Idempotent via the `already_listed` guard.
2522        self.register_auth_help_entry();
2523    }
2524
2525    /// Mounts the built-in `config` command group and files it under the admin
2526    /// help category. Idempotent and yields to a consumer-defined `config`
2527    /// subcommand if one already exists.
2528    fn ensure_config_command(&mut self) {
2529        if has_subcommand(&self.root, "config") {
2530            return;
2531        }
2532        let group = crate::config_commands::config_command_group();
2533        let mut prefix = Vec::new();
2534        group.register_commands(&mut prefix, &mut self.commands);
2535        let mut prefix = Vec::new();
2536        let clap_group = runtime_group_clap_command_with_schema_help(
2537            &group,
2538            &mut prefix,
2539            &self.middleware.schema_registry,
2540        );
2541        self.root = self.root.clone().subcommand(clap_group);
2542        let category = self
2543            .config
2544            .admin_category
2545            .clone()
2546            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2547        if !self
2548            .module_entries
2549            .iter()
2550            .any(|entry| entry.name == "config")
2551        {
2552            self.module_entries.push(ModuleHelpEntry {
2553                category,
2554                name: "config".to_owned(),
2555                short: "Read and write the CLI config file".to_owned(),
2556            });
2557        }
2558        self.refresh_root_long();
2559    }
2560
2561    /// Mounts the built-in `env` command group and files it under the admin
2562    /// help category. Idempotent and yields to a consumer-defined `env`
2563    /// subcommand if one already exists.
2564    fn ensure_env_command(&mut self) {
2565        if has_subcommand(&self.root, "env") {
2566            return;
2567        }
2568        let group = crate::env_commands::env_command_group();
2569        let mut prefix = Vec::new();
2570        group.register_commands(&mut prefix, &mut self.commands);
2571        let mut prefix = Vec::new();
2572        let clap_group = runtime_group_clap_command_with_schema_help(
2573            &group,
2574            &mut prefix,
2575            &self.middleware.schema_registry,
2576        );
2577        self.root = self.root.clone().subcommand(clap_group);
2578        let category = self
2579            .config
2580            .admin_category
2581            .clone()
2582            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2583        if !self.module_entries.iter().any(|e| e.name == "env") {
2584            self.module_entries.push(ModuleHelpEntry {
2585                category,
2586                name: "env".to_owned(),
2587                short: "Manage the active environment".to_owned(),
2588            });
2589        }
2590        self.refresh_root_long();
2591    }
2592
2593    /// Mounts the built-in `flags` command group and files it under the admin
2594    /// help category. Idempotent and yields to a consumer-defined `flags`
2595    /// subcommand if one already exists. Unlike [`Self::ensure_env_command`],
2596    /// this is mounted unconditionally: feature-flag introspection does not
2597    /// depend on any opt-in system, so it is always available.
2598    fn ensure_flags_command(&mut self) {
2599        if has_subcommand(&self.root, "flags") {
2600            return;
2601        }
2602        let group = crate::flag_commands::flags_command_group();
2603        let mut prefix = Vec::new();
2604        group.register_commands(&mut prefix, &mut self.commands);
2605        let mut prefix = Vec::new();
2606        let clap_group = runtime_group_clap_command_with_schema_help(
2607            &group,
2608            &mut prefix,
2609            &self.middleware.schema_registry,
2610        );
2611        self.root = self.root.clone().subcommand(clap_group);
2612        let category = self
2613            .config
2614            .admin_category
2615            .clone()
2616            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2617        if !self.module_entries.iter().any(|e| e.name == "flags") {
2618            self.module_entries.push(ModuleHelpEntry {
2619                category,
2620                name: "flags".to_owned(),
2621                short: "Inspect declared feature flags".to_owned(),
2622            });
2623        }
2624        self.refresh_root_long();
2625    }
2626
2627    fn default_auth_provider(&self) -> String {
2628        if !self.middleware.default_auth_provider.is_empty() {
2629            return self.middleware.default_auth_provider.clone();
2630        }
2631        self.middleware
2632            .auth
2633            .registered_names()
2634            .into_iter()
2635            .next()
2636            .unwrap_or_default()
2637    }
2638
2639    fn initialized_middleware(&self) -> Result<Middleware> {
2640        let Some(init_deps) = &self.init_deps else {
2641            return Ok(self.middleware.clone());
2642        };
2643        let mut guard = self
2644            .init_state
2645            .lock()
2646            .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
2647        if let Some(result) = guard.as_ref() {
2648            return result.clone().map_err(InitFailure::into_error);
2649        }
2650        let mut middleware = self.middleware.clone();
2651        let result = init_deps(&mut middleware)
2652            .map(|()| middleware)
2653            .map_err(|err| InitFailure::capture(&err));
2654        *guard = Some(result.clone());
2655        result.map_err(InitFailure::into_error)
2656    }
2657
2658    fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2659        if let Some(apply_flags) = &self.apply_flags {
2660            apply_flags(matches, middleware)?;
2661        }
2662        Ok(())
2663    }
2664
2665    /// Applies the global `--env` override to a per-run middleware snapshot.
2666    ///
2667    /// The flag is only registered when environments are configured, so when it
2668    /// is present `middleware.environments` is set too. Validates the requested
2669    /// name against the registered environments and updates `middleware.env`,
2670    /// returning an error for an unknown environment.
2671    fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2672        // Guard on the environment system FIRST. The `--env` arg is only
2673        // registered when environments are configured (the same condition that
2674        // sets `middleware.environments`); calling `matches.get_one("env")` for
2675        // an arg that was never registered panics in clap, which would break
2676        // every CLI that does not use environments.
2677        let Some(environments) = middleware.environments.as_ref() else {
2678            return Ok(());
2679        };
2680        if let Some(env) = matches.get_one::<String>("env") {
2681            environments.source(env)?;
2682            middleware.env = env.clone();
2683        }
2684        Ok(())
2685    }
2686
2687    fn run_pre_run(
2688        &self,
2689        middleware: &mut Middleware,
2690        command_path: &str,
2691        args: &crate::middleware::ValueMap,
2692    ) -> Result<()> {
2693        if let Some(pre_run) = &self.pre_run {
2694            pre_run(middleware, command_path, args)?;
2695        }
2696        Ok(())
2697    }
2698
2699    fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
2700        if let Some(resolver) = &self.meta_resolver {
2701            resolver(command_path, meta)
2702        } else {
2703            meta
2704        }
2705    }
2706
2707    fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
2708        // Clear the per-thread credential-store flag so it does not leak into
2709        // subsequent sequential runs on the same thread.
2710        crate::config::clear_credential_store_flag();
2711        if let Some(on_shutdown) = &self.on_shutdown {
2712            on_shutdown();
2713        }
2714        output
2715    }
2716}
2717
2718fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
2719    middleware.output_format = flags.output_format.clone();
2720    middleware.verbose = flags.verbose.clone();
2721    middleware.dry_run = flags.dry_run;
2722    middleware.fields = flags.fields.clone();
2723    middleware.fields_explicit = flags.fields_explicit;
2724    middleware.filter = flags.filter.clone();
2725    middleware.expr = flags.expr.clone();
2726    middleware.reason = flags.reason.clone();
2727    middleware.schema = flags.schema;
2728    middleware.timeout = timeout;
2729    middleware.debug = flags.debug.clone();
2730    middleware.interactive = flags.interactive;
2731}
2732
2733/// Sets `middleware.limit`/`middleware.offset` from a paginating command's own
2734/// `--limit`/`--offset`
2735fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) {
2736    let Some(pagination) = spec.pagination else {
2737        return;
2738    };
2739    middleware.limit = leaf
2740        .get_one::<i64>("limit")
2741        .copied()
2742        .unwrap_or(pagination.default_limit);
2743    middleware.offset = leaf.get_one::<i64>("offset").copied().unwrap_or(0);
2744}
2745
2746/// Replays a paginating command's own explicit args, plus the global
2747/// `--filter`/`--expr`/`--fields` flags, as `--flag value` text, prefixed
2748/// with the CLI's binary name — the base a "view the next page"
2749/// [`crate::NextAction`] is built from once the response's
2750/// [`crate::PaginationMeta`] is known. Leading with the binary name keeps the
2751/// suggested command copy-pastable rather than a fragment starting at the
2752/// noun/verb path.
2753///
2754/// `--filter`/`--expr`/`--fields` sit in the same output pipeline as
2755/// pagination itself (filter -> paginate -> expr -> fields) and change what
2756/// data comes back, so dropping them would make the suggested next-page
2757/// command return different results than the command the user actually ran.
2758/// Other global flags (`--output`, `--verbose`, `--env`, ...) don't affect
2759/// *which* data is returned, so they're intentionally left out — the caller
2760/// is already running under them.
2761///
2762/// Best-effort, not a fully general clap-args reconstruction: it uses each
2763/// arg's real `get_long()`/`get_short()` name (never the value-map key,
2764/// which for derive-based args can differ from the flag — e.g. id
2765/// `page_size` vs flag `--page-size`), replays a multi-value arg as one
2766/// flag occurrence per value (round-trips correctly whether the arg is a
2767/// plain repeatable `ArgAction::Append` or also sets a `value_delimiter`),
2768/// and quotes/escapes values containing whitespace or shell metacharacters
2769/// (see `quote_pagination_value`). Deliberately omits `--limit`/`--offset` —
2770/// those are added by the caller once it knows the
2771/// next page's offset.
2772fn pagination_command_base(
2773    binary_name: &str,
2774    command_path: &str,
2775    spec: &CommandSpec,
2776    user_args: &crate::middleware::ValueMap,
2777    flags: &GlobalFlags,
2778) -> String {
2779    let mut parts = vec![
2780        quote_pagination_value(binary_name),
2781        command_path.replace(':', " "),
2782    ];
2783    for arg in &spec.args {
2784        let id = arg.get_id().as_str();
2785        if let Some(value) = user_args.get(id) {
2786            push_pagination_arg(&mut parts, arg, value);
2787        }
2788    }
2789    for (flag, value) in [
2790        ("--filter", &flags.filter),
2791        ("--expr", &flags.expr),
2792        ("--fields", &flags.fields),
2793    ] {
2794        if !value.is_empty() {
2795            parts.push(flag.to_owned());
2796            parts.push(quote_pagination_value(value));
2797        }
2798    }
2799    parts.join(" ")
2800}
2801
2802fn push_pagination_arg(parts: &mut Vec<String>, arg: &Arg, value: &serde_json::Value) {
2803    let flag = arg
2804        .get_long()
2805        .map(|long| format!("--{long}"))
2806        .or_else(|| arg.get_short().map(|short| format!("-{short}")));
2807    match value {
2808        serde_json::Value::Bool(enabled) => {
2809            if matches!(
2810                arg.get_action(),
2811                clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
2812            ) {
2813                // A switch-style flag's presence in `user_args` already means
2814                // the user typed exactly this flag — `SetTrue` implies `true`,
2815                // `SetFalse` implies `false` (e.g. a `--no-foo`-style arg) —
2816                // and neither accepts an explicit `=value` token, so replay
2817                // the bare flag rather than appending one.
2818                if let Some(flag) = flag {
2819                    parts.push(flag);
2820                }
2821            } else {
2822                // A custom bool-valued arg (`ArgAction::Set` with a bool
2823                // value parser) takes an explicit token, so replay it like
2824                // any other scalar.
2825                push_flagged_value(parts, flag, &enabled.to_string());
2826            }
2827        }
2828        serde_json::Value::Array(items) => {
2829            // Repeat the flag once per value rather than joining into one
2830            // comma-separated token: clap collects a repeatable flag
2831            // (`ArgAction::Append`, the common way a command declares a
2832            // multi-value arg) the same way whether or not it also sets
2833            // `value_delimiter(',')`, so `--scope a --scope b` round-trips
2834            // correctly either way. A single `--scope a,b` only works when
2835            // a delimiter was configured — for a plain `Append` arg it's
2836            // parsed as one literal value, changing the replay's meaning.
2837            for item in items {
2838                push_flagged_value(parts, flag.clone(), &pagination_arg_display(item));
2839            }
2840        }
2841        serde_json::Value::Null => {}
2842        other => push_flagged_value(parts, flag, &pagination_arg_display(other)),
2843    }
2844}
2845
2846fn push_flagged_value(parts: &mut Vec<String>, flag: Option<String>, value: &str) {
2847    if let Some(flag) = flag {
2848        parts.push(flag);
2849    }
2850    parts.push(quote_pagination_value(value));
2851}
2852
2853fn pagination_arg_display(value: &serde_json::Value) -> String {
2854    match value {
2855        serde_json::Value::String(text) => text.clone(),
2856        other => other.to_string(),
2857    }
2858}
2859
2860/// Quotes a value for the suggested next-page command, if it contains
2861/// anything beyond a small safe-unquoted allowlist. Whitespace and shell
2862/// metacharacters (`|`, `&`, `;`, `<`, `>`, ...) all fall outside that
2863/// allowlist and so trigger quoting; once quoted, `\`, `"`, `$`, and `` ` ``
2864/// are backslash-escaped (backslash first, so escaping the others doesn't
2865/// re-escape the backslashes it just inserted) so the value can't break out
2866/// of the double quotes or trigger POSIX-shell expansion (`$VAR`, `$(...)`,
2867/// backticks) if the suggestion is copy-pasted into a shell.
2868fn quote_pagination_value(value: &str) -> String {
2869    let safe_unquoted =
2870        |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@');
2871    if value.is_empty() || !value.chars().all(safe_unquoted) {
2872        let escaped = value
2873            .replace('\\', "\\\\")
2874            .replace('"', "\\\"")
2875            .replace('$', "\\$")
2876            .replace('`', "\\`");
2877        format!("\"{escaped}\"")
2878    } else {
2879        value.to_owned()
2880    }
2881}
2882
2883/// Builds the transport debug logger implied by a parsed `--debug` pattern,
2884/// without publishing it anywhere.
2885///
2886/// Pure so tests can assert on the decision (`--debug` pattern -> enabled or
2887/// not) without touching the process-wide default logger, which every
2888/// [`Cli::run`] call republishes — including the many unrelated tests that
2889/// exercise `cli.run(...)` with no `--debug` flag and would otherwise race
2890/// with an assertion on the shared global.
2891fn debug_transport_logger_for(
2892    debug: &str,
2893    extra_redacted: &[String],
2894) -> Arc<dyn crate::transport::TransportLogger> {
2895    if crate::debug_component_enabled(debug, "transport") {
2896        Arc::new(
2897            crate::transport::StderrTransportLogger::new()
2898                .with_redacted_headers(extra_redacted.iter().cloned()),
2899        )
2900    } else {
2901        Arc::new(crate::transport::NoopTransportLogger)
2902    }
2903}
2904
2905/// Installs (or clears) the process-wide transport debug logger from the parsed
2906/// `--debug` pattern.
2907///
2908/// When `--debug` selects the `transport` component the engine publishes a
2909/// [`StderrTransportLogger`](crate::transport::StderrTransportLogger) — extended
2910/// with any [`CliConfig::with_redacted_debug_headers`] entries — which every
2911/// [`HttpClient`](crate::transport::HttpClient) built afterward picks up
2912/// automatically, with no per-command wiring. The logger is reset to a noop when
2913/// `transport` is not selected so the explicit setting always reflects the
2914/// current invocation rather than a stale process-global from an earlier one.
2915fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2916    crate::transport::set_default_transport_logger(debug_transport_logger_for(
2917        debug,
2918        extra_redacted,
2919    ));
2920}
2921
2922async fn run_with_timeout<F, T>(
2923    timeout: Option<Duration>,
2924    timeout_label: &str,
2925    future: F,
2926) -> Result<T>
2927where
2928    F: Future<Output = Result<T>>,
2929{
2930    let Some(timeout) = timeout else {
2931        return future.await;
2932    };
2933    match tokio::time::timeout(timeout, future).await {
2934        Ok(result) => result,
2935        Err(_) => Err(CliCoreError::message(format!(
2936            "command timed out after {timeout_label}"
2937        ))),
2938    }
2939}
2940
2941async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2942where
2943    Run: Future<Output = CliRunOutput>,
2944    Shutdown: Future<Output = ()>,
2945{
2946    tokio::pin!(run);
2947    tokio::pin!(shutdown);
2948    tokio::select! {
2949        output = &mut run => output,
2950        () = &mut shutdown => CliRunOutput {
2951            exit_code: 130,
2952            rendered: "command interrupted\n".to_owned(),
2953        },
2954    }
2955}
2956
2957#[cfg(unix)]
2958async fn shutdown_signal() {
2959    let ctrl_c = tokio::signal::ctrl_c();
2960    match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2961        Ok(mut sigterm) => {
2962            tokio::select! {
2963                _ = ctrl_c => {},
2964                _ = sigterm.recv() => {},
2965            }
2966        }
2967        Err(_) => {
2968            drop(ctrl_c.await);
2969        }
2970    }
2971}
2972
2973#[cfg(not(unix))]
2974async fn shutdown_signal() {
2975    drop(tokio::signal::ctrl_c().await);
2976}
2977
2978fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2979    let raw = raw.trim();
2980    if raw.is_empty() {
2981        return Ok(Some(Duration::from_secs(60)));
2982    }
2983    let Some(seconds) = parse_duration_seconds(raw) else {
2984        return Err(CliCoreError::message(format!(
2985            "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2986        )));
2987    };
2988    if seconds <= 0.0 {
2989        Ok(None)
2990    } else {
2991        Ok(Some(Duration::from_secs_f64(seconds)))
2992    }
2993}
2994
2995fn parse_duration_seconds(raw: &str) -> Option<f64> {
2996    for (suffix, seconds) in [
2997        ("ns", 0.000_000_001_f64),
2998        ("us", 0.000_001_f64),
2999        ("µs", 0.000_001_f64),
3000        ("ms", 0.001_f64),
3001        ("s", 1.0_f64),
3002        ("m", 60.0_f64),
3003        ("h", 3600.0_f64),
3004    ] {
3005        if let Some(number) = raw.strip_suffix(suffix) {
3006            let value = number.parse::<f64>().ok()?;
3007            if !value.is_finite() {
3008                return None;
3009            }
3010            return Some(value * seconds);
3011        }
3012    }
3013    None
3014}
3015
3016/// Reads the global `${APP_ID}_MIN_STAGE` override (see [`min_stage_env_var`]).
3017///
3018/// Best-effort, like [`crate::config::ConfigFile::load`]'s handling of a
3019/// malformed config file: returns `None` when the var is unset, and also
3020/// `None` (after logging a warning) when it is set but fails to parse as a
3021/// [`Stage`], so a typo'd value cannot take the CLI down.
3022fn global_min_stage_override(app_id: &str) -> Option<Stage> {
3023    let var = min_stage_env_var(app_id);
3024    let value = std::env::var(&var).ok()?;
3025    value.parse::<Stage>().map_or_else(
3026        |err| {
3027            tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override");
3028            None
3029        },
3030        Some,
3031    )
3032}
3033
3034/// Pure scan over an arg iterator for the last `--env <value>`/`--env=<value>`
3035/// occurrence — used only to seed [`Cli::new`]'s `flag_policy` (and therefore
3036/// which flagged commands get pruned) before the command tree is built, since
3037/// that decision can't be revisited once real argv is parsed. The real,
3038/// per-invocation `--env` value used for dispatch still comes from
3039/// `apply_env_flag`'s clap-based parse, unchanged; this scan never replaces
3040/// it, only decides tree shape earlier than clap otherwise could
3041/// (clap's own [`clap::Command::ignore_errors`] does not help here — it
3042/// still requires the rest of the argv to parse against a *known* subcommand
3043/// structure, and at prescan time no domain modules are registered yet, so a
3044/// real command path makes it bail on capturing global flags too).
3045///
3046/// Scans the *entire* argv and keeps the *last* non-empty `--env`/`--env=`
3047/// value, rather than stopping at the first match — a global `--env` and a
3048/// command-local one sharing the same arg id can both appear in one
3049/// invocation, and whichever clap resolves as the effective value
3050/// (empirically, the last one) is the one this scan must agree with. An
3051/// empty value (`--env=` with nothing after the `=`, or `--env` immediately
3052/// followed by another flag with nothing captured) is ignored rather than
3053/// becoming a literal empty-string candidate.
3054fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option<String> {
3055    let mut result = None;
3056    while let Some(arg) = args.next() {
3057        // clap's end-of-options sentinel: everything after a bare `--` is a
3058        // positional argument, never a flag, no matter what it looks like.
3059        // This scan must agree, or `app cmd -- --env dev` would be
3060        // misread as a real `--env` override.
3061        if arg == "--" {
3062            break;
3063        }
3064        let value = if let Some(v) = arg.strip_prefix("--env=") {
3065            Some(v.to_owned())
3066        } else if arg == "--env" {
3067            // A space-separated value that itself looks like another flag
3068            // (starts with `-`) is not a value at all — clap rejects this
3069            // outright ("a value is required for '--env <ENV>' but none was
3070            // supplied"), so this scan must not treat it as one either. An
3071            // explicit `--env=-foo` is unambiguous and still accepted, same
3072            // as clap's own disambiguation rule.
3073            args.next().filter(|v| !v.starts_with('-'))
3074        } else {
3075            None
3076        };
3077        if let Some(v) = value.filter(|v| !v.is_empty()) {
3078            result = Some(v);
3079        }
3080    }
3081    result
3082}
3083
3084fn render_cli_error(
3085    middleware: &Middleware,
3086    err: &(dyn std::error::Error + 'static),
3087    system: &str,
3088) -> CliRunOutput {
3089    let format = middleware
3090        .output_format
3091        .parse::<crate::output::OutputFormat>()
3092        .unwrap_or(crate::output::OutputFormat::Json);
3093    let envelope =
3094        crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
3095    match crate::output::render(format, &envelope) {
3096        Ok(rendered) => CliRunOutput {
3097            exit_code: exit_code_for_error(err),
3098            rendered,
3099        },
3100        Err(render_err) => CliRunOutput {
3101            exit_code: exit_code_for_error(err),
3102            rendered: render_err.to_string(),
3103        },
3104    }
3105}
3106
3107fn find_command_by_colon_path<'command>(
3108    root: &'command Command,
3109    path: &str,
3110) -> Option<&'command Command> {
3111    find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
3112}
3113
3114fn find_help_target<'command>(
3115    root: &'command Command,
3116    parts: &[&str],
3117) -> Option<&'command Command> {
3118    let mut current = root;
3119    let mut matched_any = false;
3120    for part in parts {
3121        let Some(next) = current.find_subcommand(part) else {
3122            break;
3123        };
3124        current = next;
3125        matched_any = true;
3126    }
3127    matched_any.then_some(current)
3128}
3129
3130fn find_command_and_canonical_path_by_colon_path<'command>(
3131    root: &'command Command,
3132    path: &str,
3133) -> Option<(&'command Command, Vec<String>)> {
3134    if path.is_empty() {
3135        return Some((root, Vec::new()));
3136    }
3137    let mut current = root;
3138    let mut canonical = Vec::new();
3139    for part in path.split(':') {
3140        current = current.find_subcommand(part)?;
3141        canonical.push(current.get_name().to_owned());
3142    }
3143    Some((current, canonical))
3144}
3145
3146fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
3147    if parts.is_empty() {
3148        return Some(String::new());
3149    }
3150    let mut current = root;
3151    let mut canonical = Vec::new();
3152    for part in parts {
3153        current = current.find_subcommand(part)?;
3154        canonical.push(current.get_name().to_owned());
3155    }
3156    Some(canonical.join(":"))
3157}
3158
3159/// Best-effort stderr hint for a `--scope` value that didn't resolve to a
3160/// known command path — `resolve_search_scope` still searches everything
3161/// (matching a bare `search` with no `--scope` at all), so this is the only
3162/// signal the user gets that their scope was ignored rather than applied.
3163/// Written directly to a locked stderr handle (not `eprintln!`), matching
3164/// the transport module's own `StderrTransportLogger` convention for this
3165/// kind of side-channel diagnostic: best-effort, so a write failure is
3166/// discarded rather than surfaced as a command error.
3167fn warn_unresolvable_search_scope(scope_path: &str) {
3168    let mut stderr = std::io::stderr().lock();
3169    stderr
3170        .write_all(
3171            format!(
3172                "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n"
3173            )
3174            .as_bytes(),
3175        )
3176        .ok();
3177}
3178
3179fn collect_command_search_documents(
3180    command: &Command,
3181    prefix: &mut Vec<String>,
3182    aliases: &mut Vec<String>,
3183    docs: &mut Vec<SearchDocument>,
3184) {
3185    if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
3186        return;
3187    }
3188    if command.get_subcommands().next().is_some() {
3189        for child in command.get_subcommands() {
3190            prefix.push(child.get_name().to_owned());
3191            let alias_len = aliases.len();
3192            append_command_alias_terms(child, aliases);
3193            collect_command_search_documents(child, prefix, aliases, docs);
3194            aliases.truncate(alias_len);
3195            prefix.pop();
3196        }
3197        return;
3198    }
3199    if prefix.is_empty() {
3200        prefix.push(command.get_name().to_owned());
3201        append_command_alias_terms(command, aliases);
3202    }
3203    let path = prefix.join(" ");
3204    let alias_text = aliases.join(" ");
3205    docs.push(SearchDocument {
3206        id: format!("cmd:{path}"),
3207        kind: "command".to_owned(),
3208        title: path,
3209        summary: command
3210            .get_about()
3211            .map(ToString::to_string)
3212            .unwrap_or_default(),
3213        content: format!(
3214            "{} {} {} {}",
3215            command
3216                .get_about()
3217                .map(ToString::to_string)
3218                .unwrap_or_default(),
3219            command
3220                .get_long_about()
3221                .map(ToString::to_string)
3222                .unwrap_or_default(),
3223            command_flag_text(command),
3224            alias_text
3225        ),
3226    });
3227    if prefix.len() == 1 && prefix[0] == command.get_name() {
3228        prefix.pop();
3229    }
3230}
3231
3232fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
3233    aliases.extend(command.get_all_aliases().map(str::to_owned));
3234    aliases.extend(
3235        command
3236            .get_all_short_flag_aliases()
3237            .map(|alias| alias.to_string()),
3238    );
3239    aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
3240}
3241
3242fn command_flag_text(command: &Command) -> String {
3243    command
3244        .get_arguments()
3245        .filter(|arg| !arg.is_hide_set())
3246        .filter_map(|arg| {
3247            let mut names = Vec::new();
3248            if let Some(short) = arg.get_short() {
3249                names.push(format!("-{short}"));
3250            }
3251            if let Some(long) = arg.get_long() {
3252                names.push(format!("--{long}"));
3253            }
3254            if let Some(short_aliases) = arg.get_all_short_aliases() {
3255                names.extend(
3256                    short_aliases
3257                        .into_iter()
3258                        .map(|short_alias| format!("-{short_alias}")),
3259                );
3260            }
3261            if let Some(aliases) = arg.get_all_aliases() {
3262                names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
3263            }
3264            (!names.is_empty()).then(|| names.join(" "))
3265        })
3266        .collect::<Vec<_>>()
3267        .join(" ")
3268}
3269
3270fn has_subcommand(command: &Command, name: &str) -> bool {
3271    command
3272        .get_subcommands()
3273        .any(|child| child.get_name() == name)
3274}
3275
3276fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
3277    let bool_flags = derive_bool_flags(root);
3278    let value_flags = derive_value_flags(root);
3279    let mut iter = args.iter().peekable();
3280    if iter
3281        .peek()
3282        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3283    {
3284        iter.next();
3285    }
3286
3287    while let Some(arg) = iter.next() {
3288        match arg.as_str() {
3289            "--version" | "-v" => return true,
3290            "--" => return false,
3291            value if value.contains('=') || bool_flags.contains(value) => continue,
3292            value
3293                if value_flags.contains(value)
3294                    || unknown_flag_consumes_value(value, iter.peek()) =>
3295            {
3296                iter.next();
3297            }
3298            value if value.starts_with('-') => {}
3299            _ => return false,
3300        }
3301    }
3302    false
3303}
3304
3305fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
3306    let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
3307    let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
3308    let mut normalized = Vec::with_capacity(args.len());
3309    let mut index = 0;
3310    let mut current = root;
3311    while index < args.len() {
3312        let arg = &args[index];
3313        if index == 0 && arg_matches_root_name(arg, root.get_name()) {
3314            normalized.push(arg.clone());
3315            index += 1;
3316            continue;
3317        }
3318
3319        if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
3320            normalized.push(format!("{arg}={default}"));
3321            index += 1;
3322            continue;
3323        }
3324
3325        if let Some(default) = optional_string_defaults.get(arg.as_str()) {
3326            match args.get(index + 1) {
3327                None => {
3328                    normalized.push(format!("{arg}={default}"));
3329                    index += 1;
3330                    continue;
3331                }
3332                Some(next)
3333                    if current.get_name() == root.get_name()
3334                        || next.starts_with('-')
3335                        || direct_subcommand(current, next).is_some() =>
3336                {
3337                    normalized.push(format!("{arg}={default}"));
3338                    index += 1;
3339                    continue;
3340                }
3341                Some(next) => {
3342                    normalized.push(arg.clone());
3343                    normalized.push(next.clone());
3344                    index += 2;
3345                    continue;
3346                }
3347            }
3348        }
3349
3350        normalized.push(arg.clone());
3351        if !arg.starts_with('-')
3352            && let Some(next_command) = direct_subcommand(current, arg)
3353        {
3354            current = next_command;
3355        }
3356        index += 1;
3357    }
3358    normalized
3359}
3360
3361fn direct_subcommand<'command>(
3362    command: &'command Command,
3363    token: &str,
3364) -> Option<&'command Command> {
3365    command.get_subcommands().find(|child| {
3366        child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
3367    })
3368}
3369
3370/// Appends a `— did you mean "…"?` suffix to an unknown-command error clause.
3371fn format_did_you_mean(base: &str, suggestion: &str) -> String {
3372    format!("{base} — did you mean {suggestion:?}?")
3373}
3374
3375/// First unknown group token (`unknown command "X" for "Y"`, no hint suffix).
3376struct UnknownGroupCommand {
3377    base: String,
3378}
3379
3380/// Reports the first unknown token under a group. `positionals` must be pre-`--`
3381/// command keywords (slice to `command_keyword_count` like the group-help path).
3382fn detect_unknown_group_command(
3383    root: &Command,
3384    positionals: &[String],
3385) -> Option<UnknownGroupCommand> {
3386    if positionals.is_empty() {
3387        return None;
3388    }
3389
3390    let mut current = root;
3391    let mut path = vec![root.get_name().to_owned()];
3392    for token in positionals {
3393        if let Some(next) = current.find_subcommand(token) {
3394            current = next;
3395            path.push(next.get_name().to_owned());
3396            continue;
3397        }
3398        if current.get_subcommands().next().is_some() {
3399            let base = format!("unknown command {token:?} for {:?}", path.join(" "));
3400            return Some(UnknownGroupCommand { base });
3401        }
3402        return None;
3403    }
3404    None
3405}
3406
3407/// Counts positional command tokens that precede any `--` separator.
3408fn command_keyword_count(
3409    args: &[String],
3410    root_name: &str,
3411    bool_flags: &BTreeSet<String>,
3412    value_flags: &BTreeSet<String>,
3413) -> usize {
3414    let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags);
3415    match args.iter().position(|arg| arg == "--") {
3416        Some(end) => {
3417            positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len()
3418        }
3419        None => positionals.len(),
3420    }
3421}
3422
3423/// Rewrites `<group> help [sub...]` into `help <group> [sub...]` when the form
3424/// is present; otherwise returns `clap_args` unchanged.
3425fn rewrite_group_help_if_needed(
3426    root: &Command,
3427    clap_args: &[String],
3428    root_name: &str,
3429    bool_flags: &BTreeSet<String>,
3430    value_flags: &BTreeSet<String>,
3431) -> Vec<String> {
3432    let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags);
3433    let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags);
3434    let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else {
3435        return clap_args.to_vec();
3436    };
3437    rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts)
3438}
3439
3440/// Rewrites the `target`-th positional command token to `replacement`, preserving
3441/// flags. Token classification mirrors [`positional_command_tokens`].
3442fn replace_positional_command_token(
3443    args: &[String],
3444    root_name: &str,
3445    bool_flags: &BTreeSet<String>,
3446    value_flags: &BTreeSet<String>,
3447    target: usize,
3448    replacement: &str,
3449) -> Vec<String> {
3450    let mut out = args.to_vec();
3451    let mut index = 0;
3452    if out
3453        .first()
3454        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3455    {
3456        index = 1;
3457    }
3458
3459    let mut positional = 0;
3460    while index < out.len() {
3461        let arg = &out[index];
3462        if arg == "--" {
3463            break;
3464        }
3465        if arg.contains('=') {
3466            index += 1;
3467            continue;
3468        }
3469        if bool_flags.contains(arg) {
3470            index += 1;
3471            continue;
3472        }
3473        if value_flags.contains(arg)
3474            || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref())
3475        {
3476            index += 2;
3477            continue;
3478        }
3479        if arg.starts_with('-') {
3480            index += 1;
3481            continue;
3482        }
3483        if positional == target {
3484            out[index] = replacement.to_owned();
3485            break;
3486        }
3487        positional += 1;
3488        index += 1;
3489    }
3490    out
3491}
3492
3493/// Finds the closest visible subcommand name or alias within edit-distance
3494/// `max(1, token_len / 3)`. Returns the canonical name; ties break alphabetically.
3495fn nearest_subcommand(command: &Command, token: &str) -> Option<String> {
3496    let token = token.to_ascii_lowercase();
3497    let max_distance = 1.max(token.chars().count() / 3);
3498
3499    command
3500        .get_subcommands()
3501        .filter(|child| !child.is_hide_set())
3502        .filter_map(|child| {
3503            let best = std::iter::once(child.get_name())
3504                .chain(child.get_all_aliases())
3505                .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase()))
3506                .min()?;
3507            (best <= max_distance).then(|| (best, child.get_name().to_owned()))
3508        })
3509        .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)))
3510        .map(|(_, name)| name)
3511}
3512
3513/// Corrects every unknown group token to its nearest subcommand. Returns `None`
3514/// when any token has no near match, or when there is nothing to correct.
3515/// Stops at a leaf operand, curated `<group> help`, or an unfixable token.
3516fn full_command_correction(root: &Command, positionals: &[String]) -> Option<Vec<(usize, String)>> {
3517    let mut current = root;
3518    let mut corrections = Vec::new();
3519    for (index, token) in positionals.iter().enumerate() {
3520        if let Some(next) = current.find_subcommand(token) {
3521            current = next;
3522            continue;
3523        }
3524        if current.get_subcommands().next().is_none() {
3525            break;
3526        }
3527        if token == "help" && current.find_subcommand("help").is_none() {
3528            break;
3529        }
3530        let suggestion = nearest_subcommand(current, token)?;
3531        let next = current.find_subcommand(&suggestion)?;
3532        corrections.push((index, suggestion));
3533        current = next;
3534    }
3535    (!corrections.is_empty()).then_some(corrections)
3536}
3537
3538/// Prompt/display text for a correction. Last-token-only fixes show the bare
3539/// token; anything else shows the full corrected command path.
3540fn correction_display(
3541    root_name: &str,
3542    positionals: &[String],
3543    corrections: &[(usize, String)],
3544) -> String {
3545    if let [(index, only)] = corrections
3546        && *index + 1 == positionals.len()
3547    {
3548        return only.clone();
3549    }
3550    let mut tokens = vec![root_name.to_owned()];
3551    for (index, token) in positionals.iter().enumerate() {
3552        let corrected = corrections
3553            .iter()
3554            .find(|(i, _)| *i == index)
3555            .map(|(_, replacement)| replacement.clone())
3556            .unwrap_or_else(|| token.clone());
3557        tokens.push(corrected);
3558    }
3559    tokens.join(" ")
3560}
3561
3562#[cfg(test)]
3563mod unknown_command_suggestion_tests {
3564    use super::*;
3565
3566    fn sample_group() -> Command {
3567        Command::new("gddy").subcommand(
3568            Command::new("domain")
3569                .alias("dns-domain")
3570                .subcommand(Command::new("list"))
3571                .subcommand(Command::new("available")),
3572        )
3573    }
3574
3575    #[test]
3576    fn osa_distance_treats_adjacent_transposition_as_one_edit() {
3577        // Guard against swapping to `strsim::levenshtein`, which counts swaps as two edits.
3578        assert_eq!(strsim::osa_distance("domain", "domain"), 0);
3579        assert_eq!(strsim::osa_distance("domian", "domain"), 1);
3580        assert_eq!(strsim::osa_distance("lst", "list"), 1);
3581        assert_eq!(strsim::osa_distance("lsit", "list"), 1);
3582        assert_eq!(strsim::osa_distance("cat", "set"), 2);
3583    }
3584
3585    #[test]
3586    fn nearest_subcommand_matches_close_typos() {
3587        let root = sample_group();
3588        let domain = root.find_subcommand("domain").expect("domain registered");
3589        assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list"));
3590        assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list"));
3591        assert_eq!(
3592            nearest_subcommand(domain, "avaliable").as_deref(),
3593            Some("available")
3594        );
3595    }
3596
3597    #[test]
3598    fn nearest_subcommand_rejects_unrelated_tokens() {
3599        let root = sample_group();
3600        let domain = root.find_subcommand("domain").expect("domain registered");
3601        assert_eq!(nearest_subcommand(domain, "missing"), None);
3602    }
3603
3604    #[test]
3605    fn nearest_subcommand_returns_canonical_name_for_alias_typos() {
3606        let root = sample_group();
3607        assert_eq!(
3608            nearest_subcommand(&root, "dns-domian").as_deref(),
3609            Some("domain")
3610        );
3611    }
3612
3613    #[test]
3614    fn nearest_subcommand_skips_hidden_commands() {
3615        let root = Command::new("gddy")
3616            .subcommand(Command::new("visible"))
3617            .subcommand(Command::new("hiddeen").hide(true));
3618        assert_eq!(nearest_subcommand(&root, "hidden"), None);
3619    }
3620
3621    #[test]
3622    fn nearest_subcommand_rejects_short_unrelated_tokens() {
3623        let root = Command::new("gddy").subcommand(
3624            Command::new("config")
3625                .subcommand(Command::new("get"))
3626                .subcommand(Command::new("set"))
3627                .subcommand(Command::new("add")),
3628        );
3629        let config = root.find_subcommand("config").expect("config registered");
3630        assert_eq!(nearest_subcommand(config, "cat"), None);
3631        assert_eq!(nearest_subcommand(config, "x"), None);
3632        assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set"));
3633    }
3634
3635    #[test]
3636    fn unknown_group_command_formats_did_you_mean_suffix() {
3637        let root = sample_group();
3638        let unknown = detect_unknown_group_command(&root, &["domian".to_owned()])
3639            .expect("domian is an unknown top-level command");
3640        assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\"");
3641        assert_eq!(
3642            format_did_you_mean(&unknown.base, "domain"),
3643            "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?"
3644        );
3645    }
3646
3647    #[test]
3648    fn detect_unknown_group_command_reports_nested_typos() {
3649        let root = sample_group();
3650        let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()])
3651            .expect("lst is an unknown subcommand of domain");
3652        assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\"");
3653        assert_eq!(
3654            format_did_you_mean(&unknown.base, "list"),
3655            "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?"
3656        );
3657    }
3658
3659    #[test]
3660    fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() {
3661        let root = sample_group();
3662        let unknown = detect_unknown_group_command(&root, &["missing".to_owned()])
3663            .expect("missing is an unknown top-level command");
3664        assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\"");
3665    }
3666
3667    #[test]
3668    fn full_command_correction_fixes_a_single_group_typo() {
3669        let root = sample_group();
3670        let corrections = full_command_correction(&root, &["domian".to_owned()])
3671            .expect("domian is correctable to domain");
3672        assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3673    }
3674
3675    #[test]
3676    fn full_command_correction_fixes_every_typo_in_a_nested_path() {
3677        let root = sample_group();
3678        let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()])
3679            .expect("both tokens are correctable");
3680        assert_eq!(
3681            corrections,
3682            vec![(0, "domain".to_owned()), (1, "list".to_owned())]
3683        );
3684    }
3685
3686    #[test]
3687    fn full_command_correction_bails_when_a_token_has_no_near_match() {
3688        let root = sample_group();
3689        assert_eq!(
3690            full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]),
3691            None
3692        );
3693    }
3694
3695    #[test]
3696    fn full_command_correction_is_none_when_there_is_nothing_to_correct() {
3697        let root = sample_group();
3698        assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None);
3699        assert_eq!(full_command_correction(&root, &[]), None);
3700    }
3701
3702    #[test]
3703    fn full_command_correction_corrects_the_group_before_curated_help() {
3704        let root = sample_group();
3705        let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()])
3706            .expect("domian is correctable even ahead of a help token");
3707        assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3708    }
3709
3710    #[test]
3711    fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() {
3712        let root = sample_group();
3713        let corrections = full_command_correction(
3714            &root,
3715            &[
3716                "domain".to_owned(),
3717                "avaliable".to_owned(),
3718                "example.com".to_owned(),
3719            ],
3720        )
3721        .expect("avaliable is correctable to available");
3722        assert_eq!(corrections, vec![(1, "available".to_owned())]);
3723    }
3724
3725    #[test]
3726    fn correction_display_shows_the_bare_token_for_a_single_fix() {
3727        let corrections = vec![(1, "list".to_owned())];
3728        assert_eq!(
3729            correction_display(
3730                "gddy",
3731                &["domain".to_owned(), "lst".to_owned()],
3732                &corrections
3733            ),
3734            "list"
3735        );
3736    }
3737
3738    #[test]
3739    fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() {
3740        let corrections = vec![(0, "domain".to_owned())];
3741        assert_eq!(
3742            correction_display(
3743                "gddy",
3744                &["domian".to_owned(), "list".to_owned()],
3745                &corrections
3746            ),
3747            "gddy domain list"
3748        );
3749    }
3750
3751    #[test]
3752    fn correction_display_shows_the_full_command_for_multiple_fixes() {
3753        let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())];
3754        assert_eq!(
3755            correction_display(
3756                "gddy",
3757                &["domian".to_owned(), "lst".to_owned()],
3758                &corrections
3759            ),
3760            "gddy domain list"
3761        );
3762    }
3763
3764    #[test]
3765    fn replace_positional_command_token_rewrites_only_the_target() {
3766        let bool_flags: BTreeSet<String> = ["--verbose".to_owned()].into_iter().collect();
3767        let value_flags: BTreeSet<String> = ["--output".to_owned()].into_iter().collect();
3768        let args = vec![
3769            "gddy".to_owned(),
3770            "--output".to_owned(),
3771            "json".to_owned(),
3772            "domain".to_owned(),
3773            "lst".to_owned(),
3774        ];
3775        let corrected =
3776            replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list");
3777        assert_eq!(
3778            corrected,
3779            vec!["gddy", "--output", "json", "domain", "list"]
3780        );
3781    }
3782
3783    #[test]
3784    fn rewrite_group_help_if_needed_runs_after_typo_correction() {
3785        let root = sample_group();
3786        let bool_flags = derive_bool_flags(&root);
3787        let value_flags = derive_value_flags(&root);
3788        let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()];
3789        let corrected =
3790            replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain");
3791        assert_eq!(corrected, vec!["gddy", "domain", "help"]);
3792        let rewritten =
3793            rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags);
3794        assert_eq!(rewritten, vec!["gddy", "help", "domain"]);
3795    }
3796}
3797
3798/// Detects the `<group> help [sub...]` form and returns the command path whose
3799/// help should be rendered.
3800///
3801/// The engine ships a curated root `help` command, so it disables clap's
3802/// auto-generated help subcommand on the root. That setting propagates to every
3803/// subcommand and cannot be re-enabled per child, so `<group> help` would
3804/// otherwise hit clap's "unrecognized subcommand" error even though the group's
3805/// help listing advertises a `help` entry. We recognize the form here so the
3806/// caller can route it through the curated help renderer, matching clap's
3807/// documented equivalence between `cmd group help sub` and `cmd help group sub`.
3808///
3809/// Only groups (commands that have subcommands) are matched: a group is pure
3810/// subcommand dispatch, so a `help` token in that position is unambiguously a
3811/// help request. Leaf commands may accept a literal `help` positional argument,
3812/// so they are left for clap to parse (`<leaf> --help` still works). A group
3813/// that registers its own real `help` subcommand is likewise deferred to clap,
3814/// which dispatches the user-defined command (only auto-generated help is
3815/// suppressed).
3816///
3817/// `command_keyword_count` is the number of leading positionals that are
3818/// genuine command keywords (those before any `--`). A `help` at or beyond that
3819/// index is a literal operand after `--`, not a help request, so it is ignored.
3820fn group_help_target_parts(
3821    root: &Command,
3822    positionals: &[String],
3823    command_keyword_count: usize,
3824) -> Option<Vec<String>> {
3825    let help_index = positionals.iter().position(|token| token == "help")?;
3826    // A leading `help` is the curated root help command; let it flow through.
3827    if help_index == 0 {
3828        return None;
3829    }
3830    // A `help` after a `--` separator is a literal operand; leave it for clap.
3831    if help_index >= command_keyword_count {
3832        return None;
3833    }
3834    let prefix = &positionals[..help_index];
3835    let mut current = root;
3836    for token in prefix {
3837        current = current.find_subcommand(token)?;
3838    }
3839    // The token before `help` must resolve to a group; leaves are left to clap.
3840    current.get_subcommands().next()?;
3841    // Defer to clap when the group defines a real `help` subcommand of its own.
3842    if current.find_subcommand("help").is_some() {
3843        return None;
3844    }
3845    // `<group> help <sub...>` shows help for `<group> <sub...>`.
3846    let suffix = &positionals[help_index + 1..];
3847    Some(prefix.iter().chain(suffix).cloned().collect())
3848}
3849
3850/// Rewrites a `<group> help [sub...]` invocation into the canonical
3851/// `help <group> [sub...]` argument vector.
3852///
3853/// Only the positional command tokens are reordered (from `[group..., help,
3854/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value`
3855/// forms, value-consuming flags, unknown flags that consume a value, and
3856/// anything after `--` — is preserved in its original place. Reordering keeps
3857/// the positional count unchanged, so the rewritten stream is filled slot for
3858/// slot. `parts` is the resolved command path (group + subcommand) from
3859/// [`group_help_target_parts`].
3860fn rewrite_group_help_args(
3861    clap_args: &[String],
3862    root_name: &str,
3863    bool_flags: &BTreeSet<String>,
3864    value_flags: &BTreeSet<String>,
3865    parts: &[String],
3866) -> Vec<String> {
3867    // New positional order: the curated `help` command, then the command path.
3868    let mut next_positional = std::iter::once("help".to_owned())
3869        .chain(parts.iter().cloned())
3870        .peekable();
3871    let mut out = Vec::with_capacity(clap_args.len());
3872    let mut iter = clap_args.iter().peekable();
3873    if iter
3874        .peek()
3875        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3876        && let Some(program) = iter.next()
3877    {
3878        out.push(program.clone());
3879    }
3880
3881    let mut take_positional =
3882        |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
3883
3884    while let Some(arg) = iter.next() {
3885        if arg == "--" {
3886            out.push(arg.clone());
3887            // Everything after `--` is positional.
3888            for rest in iter.by_ref() {
3889                out.push(take_positional(rest));
3890            }
3891            break;
3892        }
3893        if arg.contains('=') || bool_flags.contains(arg) {
3894            out.push(arg.clone());
3895            continue;
3896        }
3897        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3898            out.push(arg.clone());
3899            if let Some(value) = iter.next() {
3900                out.push(value.clone());
3901            }
3902            continue;
3903        }
3904        if arg.starts_with('-') {
3905            out.push(arg.clone());
3906            continue;
3907        }
3908        out.push(take_positional(arg));
3909    }
3910    // Defensive: emit any positionals not yet placed (counts normally match).
3911    out.extend(next_positional);
3912    out
3913}
3914
3915fn positional_command_tokens(
3916    args: &[String],
3917    root_name: &str,
3918    bool_flags: &BTreeSet<String>,
3919    value_flags: &BTreeSet<String>,
3920) -> Vec<String> {
3921    let mut tokens = Vec::new();
3922    let mut iter = args.iter().peekable();
3923    if iter
3924        .peek()
3925        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3926    {
3927        iter.next();
3928    }
3929
3930    while let Some(arg) = iter.next() {
3931        if arg == "--" {
3932            tokens.extend(iter.cloned());
3933            break;
3934        }
3935        if arg.contains('=') {
3936            continue;
3937        }
3938        if bool_flags.contains(arg) {
3939            continue;
3940        }
3941        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3942            iter.next();
3943            continue;
3944        }
3945        if arg.starts_with('-') {
3946            continue;
3947        }
3948        tokens.push(arg.clone());
3949    }
3950    tokens
3951}
3952
3953/// Returns the sole visible leaf subcommand of a bare group, if unambiguous.
3954///
3955/// Clap may still attach a `help` subcommand on nested groups even when the
3956/// root disables the auto help subcommand, so that name is excluded.
3957fn single_leaf_subcommand(group: &Command) -> Option<String> {
3958    let candidates: Vec<_> = group
3959        .get_subcommands()
3960        .filter(|child| !child.is_hide_set())
3961        .filter(|child| child.get_name() != "help")
3962        .filter(|child| child.get_subcommands().next().is_none())
3963        .collect();
3964    if candidates.len() == 1 {
3965        Some(candidates[0].get_name().to_string())
3966    } else {
3967        None
3968    }
3969}
3970
3971/// Inserts `subcommand` immediately after the colon-separated `command_path`
3972/// tokens in `args`, before any trailing flags or positional values.
3973fn inject_subcommand_after_command_path(
3974    args: &[String],
3975    root_name: &str,
3976    command_path: &str,
3977    subcommand: &str,
3978    bool_flags: &BTreeSet<String>,
3979    value_flags: &BTreeSet<String>,
3980) -> Vec<String> {
3981    let path_parts: Vec<&str> = command_path.split(':').collect();
3982    let mut result = Vec::with_capacity(args.len() + 1);
3983    let mut iter = args.iter().peekable();
3984
3985    if iter
3986        .peek()
3987        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3988    {
3989        result.push(iter.next().expect("peeked").clone());
3990    }
3991
3992    let mut matched = 0_usize;
3993    while let Some(arg) = iter.next() {
3994        if arg == "--" {
3995            result.push(arg.clone());
3996            result.extend(iter.cloned());
3997            break;
3998        }
3999        if arg.contains('=') {
4000            result.push(arg.clone());
4001            continue;
4002        }
4003        if bool_flags.contains(arg) {
4004            result.push(arg.clone());
4005            continue;
4006        }
4007        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
4008            result.push(arg.clone());
4009            if let Some(value) = iter.next() {
4010                result.push(value.clone());
4011            }
4012            continue;
4013        }
4014        if arg.starts_with('-') {
4015            result.push(arg.clone());
4016            continue;
4017        }
4018
4019        result.push(arg.clone());
4020        if matched < path_parts.len() && arg == path_parts[matched] {
4021            matched += 1;
4022            if matched == path_parts.len() {
4023                result.push(subcommand.to_string());
4024            }
4025        }
4026    }
4027    result
4028}
4029
4030fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
4031    arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
4032}
4033
4034fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
4035    arg == root_name
4036        || Path::new(arg)
4037            .file_stem()
4038            .and_then(|n| n.to_str())
4039            .is_some_and(|n| n == root_name)
4040}
4041
4042/// Outcome of [`Cli::resolve_argv0`]: either rewritten arguments to feed the
4043/// normal pipeline, or a fully rendered result to return immediately.
4044enum Argv0Outcome {
4045    /// Continue the normal run pipeline with these arguments.
4046    Proceed(Vec<String>),
4047    /// Return this already-rendered result without further processing.
4048    Handled(CliRunOutput),
4049}
4050
4051/// Extracts the bare program name from an `argv[0]` value, dropping any directory
4052/// path and file extension (e.g. `/usr/bin/pl` or `pl.exe` both yield `pl`).
4053/// Falls back to the raw value when no file stem can be derived.
4054fn program_basename(arg: &str) -> String {
4055    Path::new(arg)
4056        .file_stem()
4057        .and_then(|stem| stem.to_str())
4058        .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
4059}
4060
4061/// Returns `true` when `name` is a valid alternative `argv[0]` route name: a
4062/// non-empty token of ASCII letters, digits, `-`, or `_`. This keeps the name
4063/// safe as a link/shim filename and as an `argv[0]` basename (which is matched
4064/// with its extension stripped, so an embedded dot would break matching).
4065fn is_valid_argv0_name(name: &str) -> bool {
4066    !name.is_empty()
4067        && name.chars().all(|character| {
4068            character.is_ascii_alphanumeric() || character == '-' || character == '_'
4069        })
4070}
4071
4072/// Returns `true` when the entry at `link` already matches what [`Cli::create_link`]
4073/// would produce for `method`/`target`/`name`, so it can be left untouched. A
4074/// mismatch (wrong kind, stale symlink target, or differing contents) returns
4075/// `false` so the caller replaces it.
4076fn argv0_link_matches(
4077    link: &Path,
4078    target: &Path,
4079    name: &str,
4080    method: Argv0LinkMethod,
4081) -> std::io::Result<bool> {
4082    let metadata = std::fs::symlink_metadata(link)?;
4083    match method {
4084        Argv0LinkMethod::SoftLink => {
4085            Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
4086        }
4087        Argv0LinkMethod::HardLink => {
4088            if metadata.file_type().is_symlink() {
4089                return Ok(false);
4090            }
4091            // A correct hard link is indistinguishable from the target by content;
4092            // comparing bytes also accepts an identical copy, which is harmless.
4093            Ok(std::fs::read(link)? == std::fs::read(target)?)
4094        }
4095        Argv0LinkMethod::Script => {
4096            if metadata.file_type().is_symlink() {
4097                return Ok(false);
4098            }
4099            Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
4100        }
4101    }
4102}
4103
4104/// File name for an alternative `argv[0]` link, per method and host platform.
4105fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
4106    let extension = match method {
4107        Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
4108        // Unix scripts are extension-less executables; links carry `.exe` on Windows.
4109        Argv0LinkMethod::Script => "",
4110        _ if cfg!(windows) => ".exe",
4111        _ => "",
4112    };
4113    format!("{name}{extension}")
4114}
4115
4116/// Contents of an alternative `argv[0]` shim script that forwards to `target`
4117/// via the explicit `argv0` command. A `.cmd` batch file on Windows, an
4118/// executable POSIX shell script elsewhere.
4119fn argv0_script_contents(target: &Path, name: &str) -> String {
4120    let target = target.display();
4121    if cfg!(windows) {
4122        format!("@\"{target}\" argv0 {name} %*\r\n")
4123    } else {
4124        format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
4125    }
4126}
4127
4128#[cfg(unix)]
4129fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4130    std::os::unix::fs::symlink(target, link)
4131}
4132
4133#[cfg(windows)]
4134fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4135    std::os::windows::fs::symlink_file(target, link)
4136}
4137
4138#[cfg(not(any(unix, windows)))]
4139fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
4140    Err(std::io::Error::new(
4141        std::io::ErrorKind::Unsupported,
4142        "symlink creation is not supported on this platform",
4143    ))
4144}
4145
4146/// Marks a freshly written shim script executable on Unix; a no-op elsewhere.
4147#[cfg(unix)]
4148fn make_executable(path: &Path) -> std::io::Result<()> {
4149    use std::os::unix::fs::PermissionsExt;
4150    let mut permissions = std::fs::metadata(path)?.permissions();
4151    permissions.set_mode(0o755);
4152    std::fs::set_permissions(path, permissions)
4153}
4154
4155#[cfg(not(unix))]
4156fn make_executable(_path: &Path) -> std::io::Result<()> {
4157    Ok(())
4158}
4159
4160/// Walks a runtime group tree, resolving each node's effective feature flag by
4161/// cascading from `inherited` — a node's own [`GroupSpec::feature_flag`] or
4162/// [`CommandSpec::feature_flag`] wins if set, otherwise it inherits the
4163/// nearest ancestor's effective flag, otherwise (nothing in the ancestor
4164/// chain declared a flag) it implicitly resolves to [`Stage::Ga`] with no key.
4165/// Every node that resolves to a *named* flag (own or inherited) is recorded
4166/// into `registry` under its colon-separated path, together with whether
4167/// `policy` judged it visible. Nodes that resolve to the implicit no-flag
4168/// default are not recorded (there is nothing to introspect) and are always
4169/// visible.
4170///
4171/// Returns `None` when this group itself should be dropped from the tree —
4172/// either because its effective flag is not visible under `policy`, or
4173/// because every one of its commands and subgroups was pruned away, leaving
4174/// an empty group with nothing to mount. An emptied-out group is dropped
4175/// unconditionally, even if its own flag was visible: a `clap` subcommand
4176/// group with zero children is useless either way, so this simplifies the
4177/// pruning logic rather than threading through a "was this group itself
4178/// visible but empty" distinction that no caller needs.
4179///
4180/// Note that an invisible ancestor short-circuits before its children are
4181/// even visited: a more permissive flag on a descendant cannot resurrect a
4182/// subtree whose enclosing group already failed the visibility check.
4183fn prune_feature_flag_tree(
4184    mut group: RuntimeGroupSpec,
4185    inherited: Option<&FeatureFlag>,
4186    policy: &FlagPolicy,
4187    prefix: &mut Vec<String>,
4188    registry: &mut FlagRegistry,
4189) -> Option<RuntimeGroupSpec> {
4190    prefix.push(group.group.name.clone());
4191
4192    let effective = group
4193        .group
4194        .feature_flag
4195        .clone()
4196        .or_else(|| inherited.cloned());
4197    if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) {
4198        prefix.pop();
4199        return None;
4200    }
4201
4202    let mut kept_groups = Vec::with_capacity(group.groups.len());
4203    for child in std::mem::take(&mut group.groups) {
4204        if let Some(pruned) =
4205            prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry)
4206        {
4207            kept_groups.push(pruned);
4208        }
4209    }
4210    group.groups = kept_groups;
4211
4212    let mut kept_commands = Vec::with_capacity(group.commands.len());
4213    for command in std::mem::take(&mut group.commands) {
4214        prefix.push(command.spec.name.clone());
4215        let command_effective = command
4216            .spec
4217            .feature_flag
4218            .clone()
4219            .or_else(|| effective.clone());
4220        let visible =
4221            record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry);
4222        prefix.pop();
4223        if visible {
4224            kept_commands.push(command);
4225        }
4226    }
4227    group.commands = kept_commands;
4228
4229    prefix.pop();
4230
4231    if group.commands.is_empty() && group.groups.is_empty() {
4232        None
4233    } else {
4234        Some(group)
4235    }
4236}
4237
4238/// Records `effective` at the current `prefix` path into `registry` (only
4239/// when it names a flag key — the implicit Ga default is not recorded) and
4240/// returns whether the node is visible under `policy`.
4241fn record_and_check_visibility(
4242    effective: Option<&FeatureFlag>,
4243    policy: &FlagPolicy,
4244    prefix: &[String],
4245    registry: &mut FlagRegistry,
4246) -> bool {
4247    let Some(flag) = effective else {
4248        return true;
4249    };
4250    let visible = policy.visible(Some(flag.key.as_str()), flag.stage);
4251    registry.record(FlagEntry {
4252        path: prefix.join(":"),
4253        key: flag.key.clone(),
4254        stage: flag.stage,
4255        visible,
4256    });
4257    visible
4258}
4259
4260fn register_runtime_group_metadata(
4261    group: &RuntimeGroupSpec,
4262    prefix: &mut Vec<String>,
4263    schemas: &mut SchemaRegistry,
4264    views: &mut HumanViewRegistry,
4265) {
4266    prefix.push(group.group.name.clone());
4267    for child_group in &group.groups {
4268        register_runtime_group_metadata(child_group, prefix, schemas, views);
4269    }
4270    for child in &group.commands {
4271        prefix.push(child.spec.name.clone());
4272        let command_path = prefix.join(":");
4273        register_command_schema(&child.spec, &command_path, schemas);
4274        // An inline `with_view` is registered under the command's own path; the
4275        // dispatch references it by that path. A `with_view_id` takes precedence
4276        // (dispatch uses it instead), so skip the inline registration when one is
4277        // set — registering it would leave an unused entry. Shared views are
4278        // registered separately by the module/CLI.
4279        if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
4280            views.register(HumanViewDef::new(
4281                command_path,
4282                child.spec.view_columns.clone(),
4283            ));
4284        }
4285        prefix.pop();
4286    }
4287    prefix.pop();
4288}
4289
4290fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
4291    if let Some(schema) = &spec.output_schema {
4292        schemas.register_info(command_path.to_owned(), schema.clone());
4293    }
4294}
4295
4296fn runtime_group_clap_command_with_schema_help(
4297    group: &RuntimeGroupSpec,
4298    prefix: &mut Vec<String>,
4299    schemas: &SchemaRegistry,
4300) -> Command {
4301    let mut command = group_clap_command_without_children(&group.group);
4302    prefix.push(group.group.name.clone());
4303    for child_group in &group.groups {
4304        command = command.subcommand(runtime_group_clap_command_with_schema_help(
4305            child_group,
4306            prefix,
4307            schemas,
4308        ));
4309    }
4310    for child in &group.commands {
4311        prefix.push(child.spec.name.clone());
4312        let command_path = prefix.join(":");
4313        command = command.subcommand(command_clap_command_with_schema_help(
4314            &child.spec,
4315            &command_path,
4316            schemas,
4317        ));
4318        prefix.pop();
4319    }
4320    prefix.pop();
4321    command
4322}
4323
4324fn group_clap_command_without_children(group: &GroupSpec) -> Command {
4325    let mut command = Command::new(group.name.clone())
4326        .about(group.short.clone())
4327        .help_template(GROUP_HELP_TEMPLATE);
4328    if let Some(long) = &group.long
4329        && !long.is_empty()
4330    {
4331        command = command.long_about(long.clone());
4332    }
4333    for alias in &group.aliases {
4334        command = command.alias(alias.clone());
4335    }
4336    if group.hidden {
4337        command = command.hide(true);
4338    }
4339    command
4340}
4341
4342fn command_clap_command_with_schema_help(
4343    spec: &CommandSpec,
4344    command_path: &str,
4345    schemas: &SchemaRegistry,
4346) -> Command {
4347    debug_assert!(
4348        !(spec.raw_output && spec.pagination.is_some()),
4349        "command {:?} sets both raw_output and with_pagination; a single verbatim string \
4350         has no pages, so the two are mutually exclusive",
4351        spec.name
4352    );
4353    let mut command = spec.clap_command();
4354    command = apply_dry_run_visibility(command, spec);
4355    command = apply_pagination_args(command, spec);
4356    let schema = schemas.get_by_path(command_path);
4357    let default_fields = default_field_names(spec);
4358    command = apply_fields_arg(
4359        command,
4360        spec,
4361        schema.as_ref().map(|schema| schema.fields.as_slice()),
4362        &default_fields,
4363    );
4364    command = apply_output_format_visibility(command, spec);
4365    let filter_expr_fields = schema
4366        .as_ref()
4367        .map_or(&[][..], |schema| schema.fields.as_slice());
4368    apply_filter_and_expr_examples(command, spec, filter_expr_fields)
4369}
4370
4371/// Hides this command's inherited `--output` flag when it opted into
4372/// [`CommandSpec::raw_output`].
4373fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command {
4374    if !spec.raw_output {
4375        return command;
4376    }
4377    use std::io::IsTerminal;
4378    command.arg(
4379        Arg::new("output")
4380            .long("output")
4381            .short('o')
4382            .value_name("FORMAT")
4383            .default_value(if std::io::stdout().is_terminal() {
4384                "human"
4385            } else {
4386                "json"
4387            })
4388            .conflicts_with_all(["json", "toon", "human"])
4389            .display_order(crate::flags::global_flag_order::OUTPUT)
4390            .hide(true)
4391            .help("Ignored — this command always prints raw text"),
4392    )
4393}
4394
4395/// Hides this command's inherited `--dry-run` flag when the command isn't
4396/// mutating (per [`CommandSpec::metadata`]'s `dry_run_prompt` — mirrored
4397/// here rather than reused, since that method returns the broader
4398/// [`CommandMeta`], not this one bool). `--dry-run` only ever does anything
4399/// for a command that opted in via `.mutates(true)`/`.with_tier(...)` (see
4400/// `Middleware::render_envelope`'s `meta.dry_run_prompt` gate), so showing
4401/// it on every other command is noise. The override still parses `--dry-run`
4402/// identically (same value parser, same defaults) in case a caller passes
4403/// it anyway — hidden only changes what `--help` shows, never behavior.
4404fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command {
4405    let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating);
4406    if mutates {
4407        return command;
4408    }
4409    command.arg(
4410        Arg::new("dry-run")
4411            .long("dry-run")
4412            .num_args(0..=1)
4413            .require_equals(true)
4414            .default_missing_value("true")
4415            .default_value("false")
4416            .value_parser(crate::flags::compat_bool_value_parser())
4417            .display_order(crate::flags::global_flag_order::DRY_RUN)
4418            .hide(true)
4419            .help("Preview mutations without executing"),
4420    )
4421}
4422
4423/// Registers `--limit`/`--offset` on this command's own `Command` when its
4424/// spec opted in via [`CommandSpec::with_pagination`], and leaves the command
4425/// untouched otherwise so a non-paginating command never sees those flags —
4426/// in `--help` or on its command line. See [`flags::apply_pagination_args`].
4427fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command {
4428    let Some(pagination) = spec.pagination else {
4429        return command;
4430    };
4431    crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit)
4432}
4433
4434/// Splits a command's raw `default_fields` string into individual field
4435/// names, dropping the `all`/`*` sentinels that mean "every field" rather
4436/// than naming a real field.
4437fn default_field_names(spec: &CommandSpec) -> Vec<&str> {
4438    spec.default_fields
4439        .as_deref()
4440        .map(|fields| {
4441            fields
4442                .split(',')
4443                .map(str::trim)
4444                .filter(|field| !field.is_empty() && *field != "all" && *field != "*")
4445                .collect()
4446        })
4447        .unwrap_or_default()
4448}
4449
4450/// Overrides this command's `--fields` flag with everything specific to this
4451/// command: its own `default_fields` as a native clap default value (so
4452/// `--help` shows `[default: ...]` on the flag itself, the same way
4453/// `--dry-run` shows `[default: false]`), and, when a schema is registered,
4454/// the output-field summary table appended to the flag's own help text
4455/// instead of the command's description — a long field table there used to
4456/// push `Usage:` far down the page. Global args apply to every subcommand,
4457/// but a subcommand-local arg of the same name takes precedence, so this
4458/// only affects the one command being built here.
4459fn apply_fields_arg(
4460    command: Command,
4461    spec: &CommandSpec,
4462    schema_fields: Option<&[FieldInfo]>,
4463    default_fields: &[&str],
4464) -> Command {
4465    if spec.raw_output {
4466        return command.arg(
4467            Arg::new("fields")
4468                .long("fields")
4469                .value_name("FIELDS")
4470                .display_order(crate::flags::global_flag_order::FIELDS)
4471                .hide(true)
4472                .help("Ignored — this command always prints raw text"),
4473        );
4474    }
4475    let default_value = spec
4476        .default_fields
4477        .as_deref()
4478        .filter(|fields| !fields.is_empty());
4479    let table = schema_fields
4480        .filter(|fields| !fields.is_empty())
4481        .map(|fields| format_help_section(fields, default_fields));
4482    if default_value.is_none() && table.is_none() {
4483        return command;
4484    }
4485
4486    let mut help = String::from(
4487        "Comma-separated fields to include in output (use 'all' or '*' for everything)",
4488    );
4489    if let Some(table) = &table {
4490        help.push_str("\n\n");
4491        help.push_str(table.trim_end());
4492    }
4493
4494    let mut arg = Arg::new("fields")
4495        .long("fields")
4496        .value_name("FIELDS")
4497        // Must match `global_flag_order::FIELDS` — this re-registers the
4498        // same flag with contextual help, not a new one, and needs to keep
4499        // its place among the other global flags rather than falling back
4500        // to this subcommand's own low, command-specific counter value.
4501        .display_order(crate::flags::global_flag_order::FIELDS)
4502        .help(help);
4503    if let Some(default_value) = default_value {
4504        arg = arg.default_value(default_value.to_owned());
4505    }
4506    command.arg(arg)
4507}
4508
4509/// Overrides this command's `--filter` and `--expr` flags with help text
4510/// carrying usage examples built from its own output fields, so `--help`
4511/// shows them right under the flag instead of in a separate "Filter
4512/// examples:"/"Expr examples:" section disconnected from the flags they
4513/// demonstrate. Mirrors [`apply_fields_arg`]: a subcommand-local arg of the
4514/// same name shadows the framework's global one, and must carry the same
4515/// `global_flag_order` value as that global one for the same reason.
4516fn apply_filter_and_expr_examples(
4517    mut command: Command,
4518    spec: &CommandSpec,
4519    fields: &[FieldInfo],
4520) -> Command {
4521    if spec.raw_output {
4522        return command
4523            .arg(
4524                Arg::new("filter")
4525                    .long("filter")
4526                    .value_name("EXPR")
4527                    .display_order(crate::flags::global_flag_order::FILTER)
4528                    .hide(true)
4529                    .help("Ignored — this command always prints raw text"),
4530            )
4531            .arg(
4532                Arg::new("expr")
4533                    .long("expr")
4534                    .value_name("EXPR")
4535                    .display_order(crate::flags::global_flag_order::EXPR)
4536                    .hide(true)
4537                    .help("Ignored — this command always prints raw text"),
4538            );
4539    }
4540    if fields.is_empty() {
4541        return command;
4542    }
4543    let first_string = fields
4544        .iter()
4545        .find(|field| field.field_type == "string")
4546        .map(|field| field.name.as_str());
4547    let first_bool = fields
4548        .iter()
4549        .find(|field| field.field_type == "bool")
4550        .map(|field| field.name.as_str());
4551
4552    if first_string.is_some() || first_bool.is_some() {
4553        let mut help = String::from("Per-item JMESPath predicate for list data");
4554        if let Some(name) = first_string {
4555            help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\""));
4556        }
4557        if let Some(name) = first_bool {
4558            help.push_str(&format!("\ne.g. --filter '{name}'"));
4559        }
4560        command = command.arg(
4561            Arg::new("filter")
4562                .long("filter")
4563                .value_name("EXPR")
4564                .display_order(crate::flags::global_flag_order::FILTER)
4565                .help(help),
4566        );
4567    }
4568
4569    let mut expr_help = String::from("JMESPath query applied to the whole result");
4570    expr_help.push_str("\ne.g. --expr 'length(@)'");
4571    if let Some(name) = first_string {
4572        expr_help.push_str(&format!("\ne.g. --expr '[].{name}'"));
4573    }
4574    command.arg(
4575        Arg::new("expr")
4576            .long("expr")
4577            .value_name("EXPR")
4578            .display_order(crate::flags::global_flag_order::EXPR)
4579            .help(expr_help),
4580    )
4581}
4582
4583fn process_exit_code(code: i32) -> ExitCode {
4584    if code == 0 {
4585        return ExitCode::SUCCESS;
4586    }
4587    match u8::try_from(code) {
4588        Ok(code) if code != 0 => ExitCode::from(code),
4589        Ok(_) | Err(_) => ExitCode::from(1),
4590    }
4591}
4592
4593async fn run_streaming_command(
4594    middleware: &Middleware,
4595    request: MiddlewareRequest<'_>,
4596    raw_matches: Arc<ArgMatches>,
4597    streaming_handler: crate::command::StreamingCommandHandler,
4598) -> Result<CliRunOutput> {
4599    use tokio::{io::AsyncWriteExt, sync::mpsc};
4600
4601    let args_for_handler = request.args.clone();
4602    let user_args_for_handler = request.user_args.clone();
4603    let handler_path = request.command_path.to_owned();
4604    let middleware_for_handler = middleware.clone();
4605    let raw_matches_for_handler = raw_matches;
4606
4607    let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
4608    let sender = StreamSender(tx);
4609
4610    // Drain the channel concurrently so the handler's sends don't stall
4611    // while the writer flushes to stdout. If stdout is under backpressure
4612    // the bounded channel can still fill and the handler will await send.
4613    let writer = tokio::spawn(async move {
4614        let mut stdout = tokio::io::stdout();
4615        while let Some(event) = rx.recv().await {
4616            let Ok(line) = serde_json::to_string(&event) else {
4617                continue;
4618            };
4619            if stdout.write_all(line.as_bytes()).await.is_err()
4620                || stdout.write_all(b"\n").await.is_err()
4621                || stdout.flush().await.is_err()
4622            {
4623                break;
4624            }
4625        }
4626    });
4627
4628    let output = middleware
4629        .run(request, async move |credential| {
4630            streaming_handler(
4631                CommandContext {
4632                    credential,
4633                    args: args_for_handler,
4634                    user_args: user_args_for_handler,
4635                    command_path: handler_path,
4636                    middleware: middleware_for_handler,
4637                    raw_matches: raw_matches_for_handler,
4638                },
4639                sender,
4640            )
4641            .await?;
4642            Ok(crate::CommandResult::new(serde_json::Value::Null))
4643        })
4644        .await;
4645
4646    // Handler has completed; its sender is dropped, which closes the channel.
4647    // Wait for the writer task to flush all remaining events.
4648    let _write_result = writer.await;
4649
4650    match output {
4651        Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
4652            exit_code: 0,
4653            rendered: String::new(),
4654        }),
4655        Ok(out) => Ok(out.into()),
4656        Err(err) => Ok(CliRunOutput {
4657            exit_code: exit_code_for_error(&err),
4658            rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
4659        }),
4660    }
4661}
4662
4663#[cfg(test)]
4664mod user_agent_tests {
4665    use super::*;
4666
4667    #[test]
4668    fn user_agent_string_derives_name_and_version_by_default() {
4669        let config =
4670            CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
4671        assert_eq!(config.user_agent_string(), "gdx/1.2.3");
4672    }
4673
4674    #[test]
4675    fn user_agent_string_prefers_explicit_override() {
4676        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
4677            .with_build(BuildInfo::new("1.2.3"))
4678            .with_user_agent("gdx-cli/9.9 (custom)");
4679        assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
4680    }
4681
4682    #[test]
4683    fn user_agent_string_omits_version_when_absent() {
4684        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
4685        assert_eq!(config.user_agent_string(), "gdx");
4686    }
4687
4688    #[test]
4689    fn install_default_user_agent_publishes_config_value() {
4690        let _guard = crate::transport::client::UA_TEST_LOCK
4691            .lock()
4692            .unwrap_or_else(std::sync::PoisonError::into_inner);
4693        let _restore = crate::transport::client::RestoreDefaultUserAgent;
4694        crate::transport::set_default_user_agent("cli/dev");
4695        let cli = Cli::new(
4696            CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
4697        );
4698        cli.install_default_user_agent();
4699        assert_eq!(
4700            crate::transport::client::default_user_agent(),
4701            "uatest/4.5.6"
4702        );
4703    }
4704
4705    #[test]
4706    fn install_debug_transport_logger_tracks_the_debug_pattern() {
4707        // Asserts on `debug_transport_logger_for`'s decision directly rather
4708        // than publishing to and reading back the process-wide default
4709        // logger, which `Cli::run` republishes on every call — including the
4710        // many unrelated tests that call `cli.run(...)` with no `--debug`
4711        // flag and would otherwise race with this assertion.
4712
4713        // `transport` selected -> an active (enabled) logger is built.
4714        assert!(debug_transport_logger_for("transport", &[]).enabled());
4715
4716        // Wildcard with transport excluded -> a disabled (noop) logger.
4717        assert!(!debug_transport_logger_for("*,-transport", &[]).enabled());
4718
4719        // Empty pattern -> disabled (noop).
4720        assert!(!debug_transport_logger_for("", &[]).enabled());
4721    }
4722}
4723
4724#[cfg(test)]
4725mod env_config_tests {
4726    use super::*;
4727
4728    #[test]
4729    fn with_environments_stores_shared_arc_with_consumer_app_id() {
4730        // The consumer sets app_id on the Environments before sharing the Arc;
4731        // CliConfig stores it as-is, so the file path resolves only because the
4732        // consumer stamped the matching app_id (not because the engine did).
4733        let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
4734            crate::environments::Environments::new("prod")
4735                .with_app_id("gddy")
4736                .with_config_file(true),
4737        ));
4738        let envs = cfg.environments.as_ref().expect("environments set");
4739        assert!(envs.config_file_path().is_some());
4740    }
4741
4742    #[tokio::test]
4743    async fn env_flag_overrides_default_and_reaches_middleware_env() {
4744        use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
4745        use serde_json::json;
4746        let mut cli = Cli::new(
4747            CliConfig::new("envtest", "Env test", "envtest")
4748                .with_environments(Arc::new(
4749                    crate::environments::Environments::new("prod")
4750                        .with_environment("prod", crate::environments::EnvTable::new())
4751                        .with_environment("ote", crate::environments::EnvTable::new()),
4752                ))
4753                .with_startup_args(Vec::<&str>::new()),
4754        );
4755        cli.add_command(RuntimeCommandSpec::new_with_context(
4756            CommandSpec::new("whichenv", "echo env").no_auth(true),
4757            async |ctx| {
4758                Ok(CommandResult::new(
4759                    json!({ "env": ctx.environment()?.name().to_owned() }),
4760                ))
4761            },
4762        ));
4763        let out = cli
4764            .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
4765            .await;
4766        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
4767        assert!(out.rendered.contains("\"env\""));
4768        assert!(out.rendered.contains("ote"));
4769    }
4770
4771    #[tokio::test]
4772    async fn unknown_env_flag_produces_error_envelope() {
4773        let cli = Cli::new(
4774            CliConfig::new("envtest2", "Env test", "envtest2")
4775                .with_environments(Arc::new(
4776                    crate::environments::Environments::new("prod")
4777                        .with_environment("prod", crate::environments::EnvTable::new()),
4778                ))
4779                .with_startup_args(Vec::<&str>::new()),
4780        );
4781        let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
4782        assert_ne!(out.exit_code, 0);
4783        assert!(out.rendered.contains("nope"));
4784    }
4785}
4786
4787#[cfg(test)]
4788mod prescan_env_flag_tests {
4789    use super::*;
4790
4791    fn argv(args: &[&str]) -> impl Iterator<Item = String> {
4792        args.iter()
4793            .map(|s| s.to_string())
4794            .collect::<Vec<_>>()
4795            .into_iter()
4796    }
4797
4798    #[test]
4799    fn finds_space_separated_value() {
4800        assert_eq!(
4801            prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])),
4802            Some("dev".to_owned())
4803        );
4804    }
4805
4806    #[test]
4807    fn finds_equals_separated_value() {
4808        assert_eq!(
4809            prescan_env_flag(argv(&["--env=dev", "list"])),
4810            Some("dev".to_owned())
4811        );
4812    }
4813
4814    #[test]
4815    fn is_none_without_the_flag() {
4816        assert_eq!(prescan_env_flag(argv(&["env", "list"])), None);
4817    }
4818
4819    #[test]
4820    fn trailing_env_flag_with_no_value_is_none() {
4821        assert_eq!(prescan_env_flag(argv(&["--env"])), None);
4822    }
4823
4824    #[test]
4825    fn keeps_the_last_of_multiple_occurrences() {
4826        // A global `--env` and a command-local one sharing the same arg id
4827        // can both appear (e.g. `app --env bar sub --env foo ...`); clap
4828        // resolves the *last* one as effective, so this scan must too.
4829        assert_eq!(
4830            prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])),
4831            Some("foo".to_owned())
4832        );
4833    }
4834
4835    #[test]
4836    fn ignores_an_empty_equals_value() {
4837        assert_eq!(prescan_env_flag(argv(&["--env="])), None);
4838    }
4839
4840    #[test]
4841    fn empty_occurrence_does_not_clobber_an_earlier_real_value() {
4842        assert_eq!(
4843            prescan_env_flag(argv(&["--env", "dev", "--env="])),
4844            Some("dev".to_owned())
4845        );
4846    }
4847
4848    #[test]
4849    fn space_separated_value_starting_with_dash_is_not_a_value() {
4850        // clap rejects `--env --dry-run` outright ("a value is required for
4851        // '--env <ENV>' but none was supplied") rather than treating
4852        // `--dry-run` as the value; this scan must agree.
4853        assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None);
4854    }
4855
4856    #[test]
4857    fn equals_form_accepts_a_value_starting_with_dash() {
4858        // `--env=-foo` is unambiguous (unlike the space-separated form) and
4859        // still accepted, matching clap's own disambiguation rule.
4860        assert_eq!(
4861            prescan_env_flag(argv(&["--env=-foo"])),
4862            Some("-foo".to_owned())
4863        );
4864    }
4865
4866    #[test]
4867    fn stops_at_the_end_of_options_sentinel() {
4868        // Everything after a bare `--` is positional to clap, never a flag —
4869        // `app cmd -- --env dev` must not be read as a real `--env` override.
4870        assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None);
4871    }
4872
4873    #[test]
4874    fn a_real_flag_before_the_sentinel_is_still_found() {
4875        assert_eq!(
4876            prescan_env_flag(argv(&["--env", "dev", "--", "positional"])),
4877            Some("dev".to_owned())
4878        );
4879    }
4880}
4881
4882#[cfg(test)]
4883mod feature_flag_pruning_tests {
4884    use super::*;
4885    use crate::CommandResult;
4886
4887    fn trivial_command(name: &str) -> RuntimeCommandSpec {
4888        RuntimeCommandSpec::new(
4889            CommandSpec::new(name, "short").no_auth(true),
4890            async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
4891        )
4892    }
4893
4894    fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec {
4895        let mut command = trivial_command(name);
4896        command.spec = command.spec.with_feature_flag(key, stage);
4897        command
4898    }
4899
4900    fn empty_policy() -> FlagPolicy {
4901        FlagPolicy::default()
4902    }
4903
4904    #[test]
4905    fn no_flags_anywhere_keeps_everything() {
4906        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4907            .with_command(trivial_command("a"))
4908            .with_command(trivial_command("b"))
4909            .with_group(
4910                RuntimeGroupSpec::new(GroupSpec::new("child", "short"))
4911                    .with_command(trivial_command("c")),
4912            );
4913
4914        let mut prefix = Vec::new();
4915        let mut registry = FlagRegistry::new();
4916        let pruned =
4917            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4918
4919        let pruned = pruned.expect("unflagged tree should never be dropped");
4920        assert_eq!(pruned.commands.len(), 2);
4921        assert_eq!(pruned.groups.len(), 1);
4922        assert_eq!(pruned.groups[0].commands.len(), 1);
4923        assert!(registry.entries().is_empty());
4924    }
4925
4926    #[test]
4927    fn experimental_command_is_pruned_sibling_is_not() {
4928        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4929            .with_command(flagged_command("gated", "gated-flag", Stage::Experimental))
4930            .with_command(trivial_command("sibling"));
4931
4932        let mut prefix = Vec::new();
4933        let mut registry = FlagRegistry::new();
4934        let pruned =
4935            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry)
4936                .expect("group still has a visible command left");
4937
4938        assert_eq!(pruned.commands.len(), 1);
4939        assert_eq!(pruned.commands[0].spec.name, "sibling");
4940
4941        let entries = registry.entries();
4942        assert_eq!(entries.len(), 1);
4943        assert_eq!(entries[0].path, "root:gated");
4944        assert_eq!(entries[0].key, "gated-flag");
4945        assert!(!entries[0].visible);
4946    }
4947
4948    #[test]
4949    fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() {
4950        let build_tree = || {
4951            RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4952                .with_command(trivial_command("keep-me"))
4953                .with_group(
4954                    RuntimeGroupSpec::new(
4955                        GroupSpec::new("flagged-group", "short")
4956                            .with_feature_flag("group-flag", Stage::Beta),
4957                    )
4958                    .with_command(trivial_command("cmd-default"))
4959                    .with_command(flagged_command(
4960                        "cmd-ga",
4961                        "cmd-ga-flag",
4962                        Stage::Ga,
4963                    )),
4964                )
4965        };
4966
4967        // Default policy (min_stage: Ga) drops the whole Beta subtree, including
4968        // both its undeclared and explicitly-Ga-declared children, because the
4969        // ancestor group itself already fails visibility before children are
4970        // even visited.
4971        let mut prefix = Vec::new();
4972        let mut registry = FlagRegistry::new();
4973        let pruned = prune_feature_flag_tree(
4974            build_tree(),
4975            None,
4976            &empty_policy(),
4977            &mut prefix,
4978            &mut registry,
4979        )
4980        .expect("root keeps its unflagged sibling command");
4981        assert!(pruned.groups.is_empty());
4982        assert_eq!(pruned.commands.len(), 1);
4983        assert_eq!(pruned.commands[0].spec.name, "keep-me");
4984        // Only the group itself was recorded; its children were never visited.
4985        assert_eq!(registry.entries().len(), 1);
4986        assert_eq!(registry.entries()[0].path, "root:flagged-group");
4987        assert!(!registry.entries()[0].visible);
4988
4989        // A Beta-permissive policy keeps the group and both of its children.
4990        let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4991        let mut prefix = Vec::new();
4992        let mut registry = FlagRegistry::new();
4993        let pruned =
4994            prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry)
4995                .expect("root is kept");
4996        assert_eq!(pruned.groups.len(), 1);
4997        assert_eq!(pruned.groups[0].commands.len(), 2);
4998        assert!(registry.entries().iter().all(|entry| entry.visible));
4999    }
5000
5001    #[test]
5002    fn ancestor_invisibility_short_circuits_before_children_are_visited() {
5003        // The child declares its own, more permissive Ga flag under a distinct
5004        // key. Per the documented pruning semantics, an invisible ancestor drops
5005        // its whole subtree unconditionally: the child's own flag is never even
5006        // considered, because `prune_feature_flag_tree` returns `None` for the
5007        // ancestor as soon as its own effective flag fails visibility, before
5008        // recursing into commands or subgroups at all.
5009        let group = RuntimeGroupSpec::new(
5010            GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta),
5011        )
5012        .with_command(flagged_command("child", "child-flag", Stage::Ga));
5013
5014        let mut prefix = Vec::new();
5015        let mut registry = FlagRegistry::new();
5016        let pruned =
5017            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
5018
5019        assert!(
5020            pruned.is_none(),
5021            "invisible ancestor drops its whole subtree"
5022        );
5023        // The child was never visited, so nothing about it was recorded.
5024        assert_eq!(registry.entries().len(), 1);
5025        assert_eq!(registry.entries()[0].path, "ancestor");
5026        assert!(registry.by_key("child-flag").is_empty());
5027    }
5028
5029    #[test]
5030    fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() {
5031        // Simulates a module-level flag with no per-group/per-command
5032        // declaration anywhere below it: `inherited` here stands in for
5033        // `Module::feature_flag`, exactly as `add_module_group_inner` passes it.
5034        let module_flag = FeatureFlag::new("module-flag", Stage::Beta);
5035        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
5036            .with_command(trivial_command("unflagged-child"));
5037
5038        let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
5039        let mut prefix = Vec::new();
5040        let mut registry = FlagRegistry::new();
5041        let pruned = prune_feature_flag_tree(
5042            group,
5043            Some(&module_flag),
5044            &policy,
5045            &mut prefix,
5046            &mut registry,
5047        )
5048        .expect("Beta-permissive policy keeps a Beta-inherited tree");
5049        assert_eq!(pruned.commands.len(), 1);
5050
5051        // Both the group and the descendant command recorded the *same*
5052        // inherited key/stage, proving real cascading rather than an implicit
5053        // Ga default at either level.
5054        let entries = registry.entries();
5055        assert_eq!(entries.len(), 2);
5056        assert_eq!(entries[0].path, "root");
5057        assert_eq!(entries[0].key, "module-flag");
5058        assert_eq!(entries[0].stage, Stage::Beta);
5059        assert_eq!(entries[1].path, "root:unflagged-child");
5060        assert_eq!(entries[1].key, "module-flag");
5061        assert_eq!(entries[1].stage, Stage::Beta);
5062
5063        // Under the default (Ga) policy the same inherited Beta flag makes the
5064        // whole tree invisible together, since the group and its unflagged
5065        // child resolve to the identical effective flag.
5066        let mut prefix = Vec::new();
5067        let mut registry = FlagRegistry::new();
5068        let pruned = prune_feature_flag_tree(
5069            RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
5070                .with_command(trivial_command("unflagged-child")),
5071            Some(&module_flag),
5072            &empty_policy(),
5073            &mut prefix,
5074            &mut registry,
5075        );
5076        assert!(pruned.is_none());
5077    }
5078
5079    #[test]
5080    fn registry_records_only_named_flags_not_unflagged_nodes() {
5081        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group(
5082            RuntimeGroupSpec::new(
5083                GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta),
5084            )
5085            .with_command(trivial_command("c1"))
5086            .with_command(flagged_command("c2", "c2-flag", Stage::Ga)),
5087        );
5088
5089        // Permissive enough that nothing is pruned, so every node is visited.
5090        let policy = FlagPolicy::default().with_min_stage(Stage::Experimental);
5091        let mut prefix = Vec::new();
5092        let mut registry = FlagRegistry::new();
5093        let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry)
5094            .expect("permissive policy keeps everything");
5095        assert_eq!(pruned.groups[0].commands.len(), 2);
5096
5097        let entries = registry.entries();
5098        assert_eq!(entries.len(), 3, "root has no flag and is not recorded");
5099        assert_eq!(entries[0].path, "root:g");
5100        assert_eq!(entries[0].key, "g-flag");
5101        assert_eq!(entries[1].path, "root:g:c1");
5102        assert_eq!(entries[1].key, "g-flag");
5103        assert_eq!(entries[1].stage, Stage::Beta);
5104        assert_eq!(entries[2].path, "root:g:c2");
5105        assert_eq!(entries[2].key, "c2-flag");
5106        assert_eq!(entries[2].stage, Stage::Ga);
5107        assert!(entries.iter().all(|entry| entry.visible));
5108    }
5109
5110    #[test]
5111    fn module_feature_flag_cascades_into_its_group_via_add_module() {
5112        // Regression test for the bug this task fixes: `add_module` used to
5113        // discard `module.feature_flag` entirely, so a module-level flag could
5114        // never reach its group/commands. `Module::new` returns a group with an
5115        // unflagged command; the module itself declares Experimental, and the
5116        // default (Ga) policy must prune the whole group away.
5117        let module = Module::new("Test Category", |_ctx| {
5118            RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short"))
5119                .with_command(trivial_command("list"))
5120        })
5121        .with_feature_flag("module-flag", Stage::Experimental);
5122
5123        let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest"));
5124        cli.add_module(module);
5125
5126        assert!(
5127            !cli.commands.contains_key("gated-mod:list"),
5128            "module-level Experimental flag should have pruned the whole group under the default Ga policy"
5129        );
5130        assert!(
5131            !has_subcommand(&cli.root, "gated-mod"),
5132            "the pruned group must not be mounted in the clap tree either"
5133        );
5134    }
5135
5136    #[test]
5137    fn module_feature_flag_keeps_group_when_policy_allows_it() {
5138        let module = Module::new("Test Category", |_ctx| {
5139            RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short"))
5140                .with_command(trivial_command("list"))
5141        })
5142        .with_feature_flag("module-flag-2", Stage::Experimental);
5143
5144        let mut cli = Cli::new(
5145            CliConfig::new("modtest2", "Module test", "modtest2")
5146                .with_min_stage(Stage::Experimental),
5147        );
5148        cli.add_module(module);
5149
5150        assert!(cli.commands.contains_key("gated-mod-2:list"));
5151        assert!(has_subcommand(&cli.root, "gated-mod-2"));
5152    }
5153
5154    #[test]
5155    fn active_environment_min_stage_loosens_consumer_level_policy() {
5156        // The CliConfig itself leaves min_stage at its Ga default, which would
5157        // normally prune this Experimental-flagged group. The active ("prod")
5158        // environment's compiled min_stage override should reach
5159        // `middleware.flag_policy` before pruning runs and keep it instead.
5160        let module = Module::new("Test Category", |_ctx| {
5161            RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short"))
5162                .with_command(trivial_command("list"))
5163        })
5164        .with_feature_flag("module-flag-3", Stage::Experimental);
5165
5166        let mut cli = Cli::new(
5167            CliConfig::new("modtest3", "Module test", "modtest3")
5168                .with_environments(Arc::new(
5169                    crate::environments::Environments::new("prod").with_environment(
5170                        "prod",
5171                        crate::environments::EnvTable::new().with("min_stage", "experimental"),
5172                    ),
5173                ))
5174                .with_startup_args(Vec::<&str>::new()),
5175        );
5176        cli.add_module(module);
5177
5178        assert!(cli.commands.contains_key("gated-mod-3:list"));
5179        assert!(has_subcommand(&cli.root, "gated-mod-3"));
5180    }
5181
5182    /// The direct proof of the startup `--env` prescan (see `Cli::new`):
5183    /// unlike [`active_environment_min_stage_loosens_consumer_level_policy`]
5184    /// (which exercises the *default* active environment), here "prod" is
5185    /// the default and carries no override, while "dev" loosens `min_stage`.
5186    /// A `--env dev` supplied via `with_startup_args` — standing in for real
5187    /// process argv — must be consulted before `add_module` prunes the tree,
5188    /// in the *same* construction, not just update `middleware.env` for a
5189    /// later run.
5190    #[test]
5191    fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() {
5192        fn gated_module() -> Module {
5193            Module::new("Test Category", |_ctx| {
5194                RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short"))
5195                    .with_command(trivial_command("list"))
5196            })
5197            .with_feature_flag("module-flag-4", Stage::Experimental)
5198        }
5199        fn environments() -> Arc<crate::environments::Environments> {
5200            Arc::new(
5201                crate::environments::Environments::new("prod")
5202                    .with_environment("prod", crate::environments::EnvTable::new())
5203                    .with_environment(
5204                        "dev",
5205                        crate::environments::EnvTable::new().with("min_stage", "experimental"),
5206                    ),
5207            )
5208        }
5209
5210        let mut with_dev_flag = Cli::new(
5211            CliConfig::new("modtest4a", "Module test", "modtest4a")
5212                .with_environments(environments())
5213                .with_startup_args(["modtest4a", "--env", "dev"]),
5214        );
5215        with_dev_flag.add_module(gated_module());
5216        assert!(
5217            with_dev_flag.commands.contains_key("gated-mod-4:list"),
5218            "--env dev in startup_args should reveal the Experimental module"
5219        );
5220        assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4"));
5221
5222        // Negative counterpart: with no `--env` at all, the default ("prod",
5223        // no override) still governs — nothing changed for the common case.
5224        let mut without_flag = Cli::new(
5225            CliConfig::new("modtest4b", "Module test", "modtest4b")
5226                .with_environments(environments())
5227                .with_startup_args(Vec::<&str>::new()),
5228        );
5229        without_flag.add_module(gated_module());
5230        assert!(
5231            !without_flag.commands.contains_key("gated-mod-4:list"),
5232            "without --env, the default env's Ga policy should still prune the module"
5233        );
5234        assert!(!has_subcommand(&without_flag.root, "gated-mod-4"));
5235    }
5236
5237    static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(());
5238
5239    /// RAII guard that restores (or removes) an env var on drop, even if a
5240    /// test panics.
5241    struct GlobalMinStageEnvGuard {
5242        key: &'static str,
5243        prev: Option<std::ffi::OsString>,
5244    }
5245    impl GlobalMinStageEnvGuard {
5246        /// Sets `key` to `value`. Caller must hold [`GLOBAL_MIN_STAGE_ENV_LOCK`]
5247        /// for the guard's entire lifetime.
5248        #[allow(unsafe_code)]
5249        fn set(key: &'static str, value: &str) -> Self {
5250            let prev = std::env::var_os(key);
5251            // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard
5252            // restores/removes on any exit incl. panic.
5253            unsafe { std::env::set_var(key, value) };
5254            Self { key, prev }
5255        }
5256
5257        /// Removes `key` (if set). Caller must hold
5258        /// [`GLOBAL_MIN_STAGE_ENV_LOCK`] for the guard's entire lifetime.
5259        #[allow(unsafe_code)]
5260        fn unset(key: &'static str) -> Self {
5261            let prev = std::env::var_os(key);
5262            // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard restores
5263            // on any exit incl. panic.
5264            unsafe { std::env::remove_var(key) };
5265            Self { key, prev }
5266        }
5267    }
5268    impl Drop for GlobalMinStageEnvGuard {
5269        #[allow(unsafe_code)]
5270        fn drop(&mut self) {
5271            // SAFETY: test holds GLOBAL_MIN_STAGE_ENV_LOCK; restore/clean up
5272            // on any exit including panic.
5273            unsafe {
5274                match &self.prev {
5275                    Some(v) => std::env::set_var(self.key, v),
5276                    None => std::env::remove_var(self.key),
5277                }
5278            }
5279        }
5280    }
5281
5282    #[test]
5283    #[allow(unsafe_code)]
5284    fn global_min_stage_override_is_a_noop_when_unset() {
5285        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5286            .lock()
5287            .unwrap_or_else(std::sync::PoisonError::into_inner);
5288        const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE";
5289        // Explicitly unset (and restored on drop) rather than assumed absent,
5290        // so the test is hermetic even if a developer/CI happens to have this
5291        // var set.
5292        let _guard = GlobalMinStageEnvGuard::unset(VAR);
5293
5294        assert_eq!(global_min_stage_override("unset-min-stage-app"), None);
5295    }
5296
5297    #[test]
5298    #[allow(unsafe_code)]
5299    fn global_min_stage_override_parses_a_valid_value() {
5300        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5301            .lock()
5302            .unwrap_or_else(std::sync::PoisonError::into_inner);
5303        const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE";
5304        let _guard = GlobalMinStageEnvGuard::set(VAR, "beta");
5305
5306        assert_eq!(
5307            global_min_stage_override("valid-min-stage-app"),
5308            Some(Stage::Beta)
5309        );
5310    }
5311
5312    #[test]
5313    #[allow(unsafe_code)]
5314    fn global_min_stage_override_ignores_a_malformed_value() {
5315        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5316            .lock()
5317            .unwrap_or_else(std::sync::PoisonError::into_inner);
5318        const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE";
5319        let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly");
5320
5321        assert_eq!(global_min_stage_override("bad-min-stage-app"), None);
5322    }
5323}
5324
5325#[cfg(test)]
5326mod flags_command_tests {
5327    use super::*;
5328    use crate::CommandResult;
5329
5330    /// Builds a module with one flagged group containing one flagged (via
5331    /// inheritance) `list` command, so `flag_registry` has something to
5332    /// introspect once the module is mounted.
5333    fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
5334        Module::new("Test Category", move |_ctx| {
5335            RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command(
5336                RuntimeCommandSpec::new(
5337                    CommandSpec::new("list", "short").no_auth(true),
5338                    async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
5339                ),
5340            )
5341        })
5342        .with_feature_flag(key, stage)
5343    }
5344
5345    #[tokio::test]
5346    async fn flags_list_reports_flagged_entries() {
5347        let mut cli = Cli::new(
5348            CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta),
5349        );
5350        cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta));
5351
5352        let out = cli
5353            .run(["flagtest", "flags", "list", "--output", "json"])
5354            .await;
5355        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5356        let rendered: serde_json::Value =
5357            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5358        let entries = rendered["data"].as_array().expect("data should be array");
5359        let command_entry = entries
5360            .iter()
5361            .find(|entry| entry["path"] == "flagged-mod:list")
5362            .expect("flagged command entry should be present");
5363        assert_eq!(command_entry["key"], "list-flag");
5364        assert_eq!(command_entry["stage"], "beta");
5365        assert_eq!(command_entry["visible"], true);
5366    }
5367
5368    #[tokio::test]
5369    async fn flags_info_returns_policy_and_entries_for_known_key() {
5370        let mut cli = Cli::new(
5371            CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta),
5372        );
5373        cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta));
5374
5375        let out = cli
5376            .run([
5377                "flagtest2",
5378                "flags",
5379                "info",
5380                "info-flag",
5381                "--output",
5382                "json",
5383            ])
5384            .await;
5385        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5386        let rendered: serde_json::Value =
5387            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5388        let data = &rendered["data"];
5389        assert_eq!(data["key"], "info-flag");
5390        assert_eq!(data["policy"]["min_stage"], "beta");
5391        assert!(data["policy"]["override"].is_null());
5392        let entries = data["entries"].as_array().expect("entries should be array");
5393        assert!(!entries.is_empty());
5394        assert!(entries.iter().any(|entry| {
5395            entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage"
5396        }));
5397    }
5398
5399    #[tokio::test]
5400    async fn flags_info_reports_override_decided_by() {
5401        // The module declares Experimental, which the default Ga policy would
5402        // normally hide; the override forces Ga instead, so the entries stay
5403        // visible even though `entry.stage` still reports the node's own
5404        // (Experimental) declaration, not the override.
5405        let mut cli = Cli::new(
5406            CliConfig::new("flagtest3", "Flag test", "flagtest3")
5407                .with_feature_override("override-flag", Stage::Ga),
5408        );
5409        cli.add_module(flagged_module(
5410            "flagged-mod-3",
5411            "override-flag",
5412            Stage::Experimental,
5413        ));
5414
5415        let out = cli
5416            .run([
5417                "flagtest3",
5418                "flags",
5419                "info",
5420                "override-flag",
5421                "--output",
5422                "json",
5423            ])
5424            .await;
5425        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5426        let rendered: serde_json::Value =
5427            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5428        let data = &rendered["data"];
5429        assert_eq!(data["policy"]["min_stage"], "ga");
5430        assert_eq!(data["policy"]["override"], "ga");
5431        let entries = data["entries"].as_array().expect("entries should be array");
5432        assert!(!entries.is_empty());
5433        assert!(
5434            entries
5435                .iter()
5436                .all(|entry| entry["decided_by"] == "override")
5437        );
5438        assert!(entries.iter().all(|entry| entry["visible"] == true));
5439        assert!(entries.iter().all(|entry| entry["stage"] == "experimental"));
5440    }
5441
5442    #[tokio::test]
5443    async fn flags_info_unknown_key_errors() {
5444        let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4"));
5445
5446        let out = cli
5447            .run(["flagtest4", "flags", "info", "no-such-flag"])
5448            .await;
5449        assert_ne!(out.exit_code, 0);
5450        assert!(out.rendered.contains("no such flag"));
5451    }
5452}