Skip to main content

cli_engine/
command.rs

1use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc};
2
3use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
4use schemars::JsonSchema;
5use serde_json::{Number, Value};
6use tokio::sync::mpsc;
7
8use crate::{
9    AuthRequirement, CommandMeta, Credential, CredentialResolver, FeatureFlag, Middleware,
10    OutputSchema, Result, SchemaInfo, Stage, Tier,
11    middleware::ValueMap,
12    output::{NextAction, TableColumn},
13};
14
15/// Sender half for streaming command output.
16///
17/// Streaming handlers call [`StreamSender::send`] for each progress event.
18/// The engine drains the channel and writes each event as an NDJSON line.
19#[derive(Clone, Debug)]
20pub struct StreamSender(pub(crate) mpsc::Sender<Value>);
21
22impl StreamSender {
23    /// Sends one event. Silently drops the event if the receiver is gone.
24    pub async fn send(&self, event: Value) {
25        drop(self.0.send(event).await);
26    }
27}
28
29/// Boxed future returned by runtime command handlers.
30pub type CommandFuture = Pin<Box<dyn Future<Output = Result<CommandResult>> + Send>>;
31/// Shared command handler used by [`RuntimeCommandSpec`].
32pub type CommandHandler = Arc<dyn Fn(CommandContext) -> CommandFuture + Send + Sync>;
33
34/// Boxed future returned by streaming command handlers.
35pub type StreamingCommandFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
36/// Shared streaming handler: receives context and an event sender; returns when the stream ends.
37pub type StreamingCommandHandler =
38    Arc<dyn Fn(CommandContext, StreamSender) -> StreamingCommandFuture + Send + Sync>;
39
40/// Data returned by a command handler.
41///
42/// Command handlers should return renderable data and keep output metadata on
43/// [`CommandSpec`]. The metadata field is reserved for future command-result
44/// extensions that are not known when the command is registered.
45///
46/// Construct with [`CommandResult::new`], then chain `with_*` methods —
47/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine
48/// can add fields without a breaking release.
49#[derive(Clone, Debug, PartialEq)]
50#[non_exhaustive]
51pub struct CommandResult {
52    /// JSON data rendered by the configured output formatter.
53    pub data: Value,
54    /// Optional command-result extension metadata.
55    pub metadata: CommandResultMetadata,
56}
57
58impl CommandResult {
59    /// Creates a command result from renderable JSON data.
60    #[must_use]
61    pub fn new(data: Value) -> Self {
62        Self {
63            data,
64            metadata: CommandResultMetadata::default(),
65        }
66    }
67
68    /// Attaches suggested follow-up actions to this result.
69    #[must_use]
70    pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
71        self.metadata.next_actions = actions;
72        self
73    }
74
75    /// Marks this result as a dry-run preview outcome.
76    ///
77    /// Call only when the handler actually skipped its mutating step because
78    /// [`CommandContext::dry_run`] was `true`. This requires the command to
79    /// have opted in via [`CommandSpec::handles_dry_run`] — otherwise
80    /// middleware never invokes the handler under `--dry-run` in the first
81    /// place. Middleware tags the audit/activity outcome as `dry-run` instead
82    /// of `ok` and marks the rendered envelope accordingly.
83    #[must_use]
84    pub fn with_dry_run(mut self) -> Self {
85        self.metadata.dry_run = true;
86        self
87    }
88}
89
90impl From<Value> for CommandResult {
91    fn from(data: Value) -> Self {
92        Self::new(data)
93    }
94}
95
96/// Optional metadata a command can attach to its result.
97#[non_exhaustive]
98#[derive(Clone, Debug, Default, Eq, PartialEq)]
99pub struct CommandResultMetadata {
100    /// Suggested follow-up actions for the caller.
101    pub next_actions: Vec<NextAction>,
102    /// Set by [`CommandResult::with_dry_run`] when a
103    /// [`handles_dry_run`](CommandSpec::handles_dry_run) handler skipped its
104    /// mutating step. Middleware tags the audit/activity outcome and envelope
105    /// as `dry-run` instead of `ok` when this is `true`.
106    pub dry_run: bool,
107}
108
109/// Runtime context passed to advanced command handlers.
110///
111/// Most commands can use [`RuntimeCommandSpec::new`] and receive just the
112/// credential and effective args. Use this context when a command needs the
113/// colon path, user-supplied args, or a snapshot of middleware state.
114///
115/// This struct is constructed by the framework during command dispatch.
116/// Consumer code receives it in handler closures and should not construct it
117/// directly.
118#[derive(Clone, Debug)]
119#[non_exhaustive]
120pub struct CommandContext {
121    /// Lazy credential resolver.
122    pub credential: CredentialResolver,
123    /// Effective arguments, including defaults and framework-injected values.
124    pub args: ValueMap,
125    /// Arguments explicitly supplied by the user.
126    pub user_args: ValueMap,
127    /// Colon-separated command path such as `project:list`.
128    pub command_path: String,
129    /// Middleware snapshot for this invocation.
130    pub middleware: Middleware,
131    /// Raw `clap` matches for typed argument deserialization via derive.
132    pub raw_matches: Arc<ArgMatches>,
133}
134
135impl CommandContext {
136    /// Returns the per-application config file as loaded at startup.
137    ///
138    /// Read a consumer-owned section with
139    /// [`ConfigFile::section`](crate::config::ConfigFile::section), for example
140    /// `ctx.config().section::<DeployConfig>("deploy")?`. Engine-reserved
141    /// settings are available via
142    /// [`ConfigFile::engine`](crate::config::ConfigFile::engine).
143    ///
144    /// **Snapshot semantics**: this is the config loaded once when
145    /// [`crate::cli::Cli::new`] was called. Changes made by `config set` during the same process
146    /// invocation (e.g. from a previous `Cli::run`) are not reflected here;
147    /// restart the CLI (a new `Cli::new`) to pick them up. For a one-shot CLI
148    /// process this is always the current on-disk state.
149    #[must_use]
150    pub fn config(&self) -> &crate::config::ConfigFile {
151        &self.middleware.config
152    }
153
154    /// Returns whether `--dry-run` was passed for this invocation.
155    ///
156    /// Only meaningful for commands that opted in via
157    /// [`CommandSpec::handles_dry_run`] — other mutating commands never reach
158    /// their handler under `--dry-run` at all, so there's nothing to branch
159    /// on. An opted-in handler should run its real validation unconditionally
160    /// and use this only to skip the actual mutating I/O, returning a preview
161    /// result tagged with [`CommandResult::with_dry_run`].
162    #[must_use]
163    pub fn dry_run(&self) -> bool {
164        self.middleware.dry_run
165    }
166
167    /// Returns the resolved interactivity mode for this invocation.
168    ///
169    /// Use this to decide whether to prompt for missing inputs, show progress
170    /// spinners, or offer interactive choices. When `false`, the command should
171    /// fail with a descriptive error if required inputs are missing.
172    #[must_use]
173    pub fn is_interactive(&self) -> bool {
174        self.middleware.interactive
175    }
176
177    /// Returns the resolved [`InteractivityMode`](crate::InteractivityMode).
178    ///
179    /// Equivalent to [`is_interactive`](Self::is_interactive) but returns the
180    /// enum for pattern matching.
181    #[must_use]
182    pub fn interactivity_mode(&self) -> crate::InteractivityMode {
183        self.middleware.interactive.into()
184    }
185
186    /// Resolves the active environment's merged TOML table for this
187    /// invocation, as an [`EnvSource`](crate::env_config::EnvSource).
188    ///
189    /// The active environment name is `self.middleware.env`, seeded at startup
190    /// from the persisted active environment or configured default and
191    /// overridden per invocation by the global `--env` flag. Resolution merges
192    /// the compiled-in table and the `environments.toml` file layer (file
193    /// wins). Use this for generic introspection (see the built-in `env info`
194    /// command); for a typed section with the app-scoped environment-variable
195    /// override tier applied, use
196    /// [`environment_config`](Self::environment_config) instead.
197    ///
198    /// # Blocking
199    ///
200    /// When the `environments.toml` file layer is enabled, this performs
201    /// synchronous filesystem I/O via
202    /// [`Environments::source`](crate::environments::Environments::source).
203    /// Call it once per invocation and reuse the result rather than calling it
204    /// repeatedly inside an async handler on a latency-sensitive path.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if no environment system was registered via
209    /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) or
210    /// if the active name does not resolve to a known environment.
211    pub fn environment(&self) -> Result<crate::env_config::EnvSource> {
212        let environments = self.middleware.environments.as_ref().ok_or_else(|| {
213            crate::error::CliCoreError::message("no environment system configured")
214        })?;
215        environments.source(&self.middleware.env)
216    }
217
218    /// Resolves the active environment into a typed
219    /// [`EnvConfig`](crate::env_config::EnvConfig) section, with the
220    /// app-scoped environment-variable override tier applied (see
221    /// [`Environments::resolve`](crate::environments::Environments::resolve)).
222    ///
223    /// # Blocking
224    ///
225    /// See [`environment`](Self::environment).
226    ///
227    /// # Errors
228    ///
229    /// Returns an error under the same conditions as
230    /// [`environment`](Self::environment), or when a field's present value
231    /// fails to convert to its type, or a required field has no value in any
232    /// source and no default.
233    pub fn environment_config<T: crate::env_config::EnvConfig>(
234        &self,
235    ) -> std::result::Result<T, crate::env_config::EnvConfigError> {
236        let environments = self.middleware.environments.as_ref().ok_or_else(|| {
237            crate::error::CliCoreError::message("no environment system configured")
238        })?;
239        environments.resolve(&self.middleware.env)
240    }
241
242    /// Deserializes the raw argument matches into a typed args struct.
243    ///
244    /// Use this with `#[derive(clap::Args)]` structs to get type-safe access
245    /// to command arguments instead of working with the `ValueMap` directly.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if the matches cannot be deserialized into `T`.
250    pub fn typed_args<T: clap::FromArgMatches>(&self) -> Result<T> {
251        T::from_arg_matches(self.raw_matches.as_ref())
252            .map_err(|e| crate::CliCoreError::Message(format!("argument parse error: {e}")))
253    }
254
255    /// Resolves the credential for this command, triggering the auth flow on
256    /// first use and memoizing the result.
257    ///
258    /// Convenience wrapper over [`self.credential.resolve()`](CredentialResolver::resolve).
259    ///
260    /// # Errors
261    ///
262    /// Returns an error when the command is marked `no_auth`, or when the auth
263    /// provider fails to produce a credential.
264    pub async fn credential(&self) -> Result<Credential> {
265        self.credential.resolve().await
266    }
267
268    /// Resolves the credential when one is available, returning `Ok(None)` for
269    /// no-auth commands.
270    ///
271    /// Convenience wrapper over [`self.credential.try_resolve()`](CredentialResolver::try_resolve).
272    ///
273    /// # Errors
274    ///
275    /// Propagates the auth provider error when resolution is attempted and fails.
276    pub async fn try_credential(&self) -> Result<Option<Credential>> {
277        self.credential.try_resolve().await
278    }
279
280    /// Resolves a credential that additionally covers `extra` scopes, on top of
281    /// the command's declared scopes.
282    ///
283    /// Use this when the required scopes are only known at runtime (for example
284    /// a generic API caller that derives scopes from the target endpoint). A
285    /// scope-aware auth provider re-authenticates when the cached token does not
286    /// already cover the requested set.
287    ///
288    /// Convenience wrapper over
289    /// [`self.credential.resolve_with_scopes()`](CredentialResolver::resolve_with_scopes).
290    ///
291    /// If the handler also issues HTTP requests through the transport bearer
292    /// injector, call this **before** the first request: the injector resolves
293    /// and caches a scope-unaware token, so stepping up afterwards would not
294    /// affect requests it already authorized. See
295    /// [`CredentialResolver::resolve_with_scopes`] for the full ordering note.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error when the command is marked `no_auth`, or when the auth
300    /// provider fails to produce a credential.
301    pub async fn credential_with_scopes(&self, extra: &[String]) -> Result<Credential> {
302        self.credential.resolve_with_scopes(extra).await
303    }
304}
305
306/// Declarative leaf command metadata and parser arguments.
307///
308/// `CommandSpec` intentionally keeps command metadata next to the command's
309/// handler. This is the primary copy/paste surface for teams adding commands.
310///
311/// Construct with [`CommandSpec::new`] or [`CommandSpec::from_args`], then
312/// configure with the `with_*` builder methods — never as a struct literal.
313/// `#[non_exhaustive]` enforces this so the engine can add fields (as it did
314/// for [`arg_groups`](CommandSpec::arg_groups)) without a breaking release.
315#[derive(Clone, Debug, Default)]
316#[non_exhaustive]
317pub struct CommandSpec {
318    /// Leaf command name.
319    pub name: String,
320    /// One-line command description.
321    pub short: String,
322    /// Optional long help text.
323    pub long: Option<String>,
324    /// Alternate command names accepted by the parser.
325    pub aliases: Vec<String>,
326    /// Whether the command runs but is hidden from help, tree, and search.
327    pub hidden: bool,
328    /// Backend/system id used in output metadata and generic error envelopes.
329    pub system: Option<String>,
330    /// Default comma-separated field projection.
331    pub default_fields: Option<String>,
332    /// Authentication requirement enforced by the engine for this command.
333    ///
334    /// Defaults to [`AuthRequirement::Required`] (fail-closed). Use
335    /// [`auth_optional`](CommandSpec::auth_optional) for commands that should run
336    /// logged out, or [`no_auth`](CommandSpec::no_auth) for commands that never
337    /// authenticate.
338    pub auth: AuthRequirement,
339    /// Auth provider name for this command.
340    pub auth_provider: Option<String>,
341    /// Risk tier used by authentication, authorization, and dry-run.
342    pub tier: Option<Tier>,
343    /// Explicit dry-run prompt marker for commands without a tier.
344    pub mutates: bool,
345    /// Opts this command into handler-driven `--dry-run`.
346    ///
347    /// Set with [`handles_dry_run`](CommandSpec::handles_dry_run). When
348    /// `true`, the engine skips its generic `--dry-run` short-circuit for
349    /// this command and invokes the handler as normal (still respecting the
350    /// command's [`AuthRequirement`]). The handler is responsible for
351    /// running its real validation unconditionally, checking
352    /// [`CommandContext::dry_run`] to skip only the mutating I/O, and tagging
353    /// its preview result with [`CommandResult::with_dry_run`].
354    ///
355    /// **Requires a context-aware handler.** Only handlers built with
356    /// [`RuntimeCommandSpec::new_with_context`],
357    /// [`new_streaming`](RuntimeCommandSpec::new_streaming),
358    /// [`new_typed_with_context`](RuntimeCommandSpec::new_typed_with_context),
359    /// or [`new_typed_streaming`](RuntimeCommandSpec::new_typed_streaming)
360    /// receive a [`CommandContext`] and can call [`CommandContext::dry_run`].
361    /// A handler built with [`RuntimeCommandSpec::new`]/[`new_typed`](RuntimeCommandSpec::new_typed)
362    /// only receives `(CredentialResolver, args)` — it has no way to observe
363    /// `--dry-run` at all, so opting it into `handles_dry_run` would silently
364    /// execute the handler's real side effects under `--dry-run` instead of
365    /// skipping them. `RuntimeCommandSpec::new`/`new_typed` debug-assert
366    /// against this misuse; release builds do not, so treat the assert as a
367    /// development-time safety net, not the actual guarantee — only pair this
368    /// field with one of the four context-aware constructors above.
369    pub handles_dry_run: bool,
370    /// Forces this command's successful output to print verbatim to stdout.
371    pub raw_output: bool,
372    /// Provider-specific auth metadata.
373    pub auth_metadata: BTreeMap<String, String>,
374    /// Command-specific `clap` arguments.
375    pub args: Vec<Arg>,
376    /// Argument relations (mutually-exclusive or "at least one of" groups).
377    ///
378    /// Set with [`with_arg_group`](CommandSpec::with_arg_group), or captured
379    /// automatically by [`from_args`](CommandSpec::from_args) from a
380    /// `#[derive(clap::Args)]` struct's `#[group(...)]` attribute.
381    pub arg_groups: Vec<ArgGroup>,
382    /// Optional output schema published through `--schema` and help.
383    pub output_schema: Option<SchemaInfo>,
384    /// Inline human-output table columns assigned directly to this command.
385    ///
386    /// Set with [`with_view`](CommandSpec::with_view). When present (and
387    /// [`view_id`](CommandSpec::view_id) is unset), the engine registers these
388    /// columns under the command's own path so human output renders them.
389    pub view_columns: Vec<TableColumn>,
390    /// Id of a shared human view this command should use.
391    ///
392    /// Set with [`with_view_id`](CommandSpec::with_view_id). Names a
393    /// [`HumanViewDef`](crate::HumanViewDef) registered with `with_view` on the
394    /// module or CLI, so several commands can share one table. Takes precedence
395    /// over inline [`view_columns`](CommandSpec::view_columns).
396    pub view_id: Option<String>,
397    /// This command's own feature-flag declaration, if any.
398    ///
399    /// `None` means the command has no explicit stage declaration of its own,
400    /// in which case it inherits its effective stage from its nearest ancestor
401    /// (nested group, then enclosing group, then module — nearest declaration
402    /// wins), implicitly resolving to [`Stage::Ga`] if nothing in the ancestor
403    /// chain declares a flag either; see [`Stage`]'s documentation for why
404    /// that is its default. Set with
405    /// [`with_feature_flag`](CommandSpec::with_feature_flag). This field only
406    /// records the command's own declaration; cascading resolution against the
407    /// ancestor chain happens when a [`Cli`](crate::Cli) mounts the enclosing
408    /// module or group.
409    pub feature_flag: Option<FeatureFlag>,
410    /// This command's opt-in pagination policy, if any.
411    ///
412    /// `None` (the default) means the command does not paginate: `--limit`/
413    /// `--offset` are not registered for it, so they neither show up in its
414    /// `--help` nor parse on its command line. Set with
415    /// [`with_pagination`](CommandSpec::with_pagination).
416    pub pagination: Option<PaginationConfig>,
417}
418
419/// Opt-in pagination policy for a single command, set with
420/// [`CommandSpec::with_pagination`].
421///
422/// Registering this is what makes `--limit`/`--offset` exist for a command at
423/// all — without it, the engine does not register those flags, so they are
424/// absent from `--help` and rejected as unknown arguments if passed. Construct
425/// it with `..Default::default()`, as in the example below, so a future
426/// engine release can add fields without breaking existing callers.
427///
428/// ```
429/// use cli_engine::PaginationConfig;
430///
431/// let pagination = PaginationConfig {
432///     default_limit: 20,
433///     max_limit: 100,
434///     ..Default::default()
435/// };
436/// assert_eq!(pagination.default_limit, 20);
437/// ```
438#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
439pub struct PaginationConfig {
440    /// Page size applied when the user passes neither `--limit` nor
441    /// `--offset`. `0` (the default) means unlimited — the same "no
442    /// pagination" sentinel used everywhere else in the output pipeline.
443    pub default_limit: i64,
444    /// Upper bound a user can request with an explicit `--limit`. `0` (the
445    /// default) means uncapped. Does not affect `default_limit` itself.
446    pub max_limit: i64,
447}
448
449impl CommandSpec {
450    /// Creates a command spec with the required name and one-line help.
451    #[must_use]
452    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
453        Self {
454            name: name.into(),
455            short: short.into(),
456            ..Self::default()
457        }
458    }
459
460    /// Creates a command spec from a `#[derive(clap::Args)]` struct.
461    ///
462    /// Extracts the argument definitions from the derive type and populates the
463    /// spec's args list. The command name and help text are still required since
464    /// `Args` types do not carry those. Also captures any `ArgGroup`s the derive
465    /// macro registers (via a struct-level `#[group(...)]` attribute) into
466    /// [`arg_groups`](CommandSpec::arg_groups).
467    ///
468    /// **Flatten caveat**: `clap_derive` empties a struct's own implicit group's
469    /// member list when the struct also has a `#[command(flatten)]` field, so a
470    /// `#[group(required = true)]` on such a struct silently enforces nothing.
471    /// This is debug-asserted against below; treat it as a development-time
472    /// safety net, not the actual guarantee.
473    #[must_use]
474    pub fn from_args<T: clap::Args>(name: impl Into<String>, short: impl Into<String>) -> Self {
475        let name = name.into();
476        let placeholder = Command::new("__placeholder");
477        let augmented = T::augment_args(placeholder);
478        let args: Vec<Arg> = augmented
479            .get_arguments()
480            // `cli-engine` registers its own global `--help` flag. Retain a
481            // command-specific `--version` flag: it may represent a resource
482            // version rather than the CLI binary version.
483            .filter(|arg| arg.get_id().as_str() != "help")
484            .cloned()
485            .collect();
486        let arg_groups: Vec<ArgGroup> = augmented.get_groups().cloned().collect();
487        debug_assert!(
488            arg_groups
489                .iter()
490                .all(|group| !group.is_required_set() || group.get_args().count() > 0),
491            "command {name:?} has a required ArgGroup with no member args — likely the \
492             clap_derive flatten+group interaction emptying the implicit group's \
493             member list; the constraint will not be enforced"
494        );
495        Self {
496            name,
497            short: short.into(),
498            args,
499            arg_groups,
500            ..Self::default()
501        }
502    }
503
504    /// Sets expanded command help.
505    #[must_use]
506    pub fn with_long(mut self, long: impl Into<String>) -> Self {
507        self.long = Some(long.into());
508        self
509    }
510
511    /// Adds one command alias.
512    #[must_use]
513    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
514        self.aliases.push(alias.into());
515        self
516    }
517
518    /// Hides or shows this command in discovery output.
519    #[must_use]
520    pub fn hidden(mut self, hidden: bool) -> Self {
521        self.hidden = hidden;
522        self
523    }
524
525    /// Sets the backend/system id for output metadata and error attribution.
526    #[must_use]
527    pub fn with_system(mut self, system: impl Into<String>) -> Self {
528        self.system = Some(system.into());
529        self
530    }
531
532    /// Sets the default field projection used when `--fields` is absent.
533    #[must_use]
534    pub fn with_default_fields(mut self, default_fields: impl Into<String>) -> Self {
535        self.default_fields = Some(default_fields.into());
536        self
537    }
538
539    /// Assigns an inline human-output table view to this command.
540    ///
541    /// The columns are registered under the command's own path, so human output
542    /// renders this table directly. Field selection still applies: `--fields`
543    /// (defaulting to [`default_fields`](CommandSpec::default_fields)) narrows
544    /// which of these columns show. Use
545    /// [`with_view_id`](CommandSpec::with_view_id) instead to point at a shared
546    /// view registered with `with_view` on the module or CLI.
547    #[must_use]
548    pub fn with_view(mut self, columns: impl Into<Vec<TableColumn>>) -> Self {
549        self.view_columns = columns.into();
550        self
551    }
552
553    /// Points this command at a shared human view by id.
554    ///
555    /// The id must match a [`HumanViewDef`](crate::HumanViewDef) registered with
556    /// `with_view` on the module or CLI, letting several commands share one
557    /// table. Takes precedence over inline [`with_view`](CommandSpec::with_view)
558    /// columns.
559    #[must_use]
560    pub fn with_view_id(mut self, id: impl Into<String>) -> Self {
561        self.view_id = Some(id.into());
562        self
563    }
564
565    /// Selects the auth provider for this command.
566    #[must_use]
567    pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {
568        self.auth_provider = Some(provider.into());
569        self
570    }
571
572    /// Marks the command as no-auth.
573    ///
574    /// `no_auth(true)` sets [`AuthRequirement::None`]: the command never resolves
575    /// a credential and default-env injection is suppressed. `no_auth(false)`
576    /// restores the default [`AuthRequirement::Required`].
577    #[must_use]
578    pub fn no_auth(mut self, no_auth: bool) -> Self {
579        self.auth = if no_auth {
580            AuthRequirement::None
581        } else {
582            AuthRequirement::Required
583        };
584        self
585    }
586
587    /// Sets the command's [`AuthRequirement`] explicitly.
588    #[must_use]
589    pub fn auth(mut self, requirement: AuthRequirement) -> Self {
590        self.auth = requirement;
591        self
592    }
593
594    /// Marks authentication as optional ([`AuthRequirement::Optional`]).
595    ///
596    /// The engine does not resolve a credential before the handler runs; the
597    /// handler triggers the auth flow only by calling
598    /// [`CredentialResolver::resolve`]/[`try_resolve`](CredentialResolver::try_resolve).
599    /// Use for commands that should still run when the user is logged out.
600    #[must_use]
601    pub fn auth_optional(mut self) -> Self {
602        self.auth = AuthRequirement::Optional;
603        self
604    }
605
606    /// Sets the command risk tier.
607    #[must_use]
608    pub fn with_tier(mut self, tier: Tier) -> Self {
609        self.tier = Some(tier);
610        self
611    }
612
613    /// Declares this command's own feature flag: the key used for policy
614    /// overrides and introspection, and the stage at which it becomes visible.
615    #[must_use]
616    pub fn with_feature_flag(mut self, key: impl Into<String>, stage: Stage) -> Self {
617        self.feature_flag = Some(FeatureFlag::new(key, stage));
618        self
619    }
620
621    /// Opts this command into paginated list output.
622    ///
623    /// Registers `--limit`/`--offset` for this command only — a command that
624    /// never calls this does not get those flags at all, in `--help` or on
625    /// the command line. When the user passes neither flag, `config.default_limit`
626    /// applies instead of the framework's "pagination disabled" default of
627    /// unlimited; an explicit `--limit` above `config.max_limit` (when set) is
628    /// rejected before the command runs. See [`PaginationConfig`].
629    #[must_use]
630    pub fn with_pagination(mut self, config: PaginationConfig) -> Self {
631        debug_assert!(
632            config.max_limit == 0 || config.default_limit <= config.max_limit,
633            "command {:?} has a default_limit ({}) greater than its max_limit ({})",
634            self.name,
635            config.default_limit,
636            config.max_limit
637        );
638        self.pagination = Some(config);
639        self
640    }
641
642    /// Adds provider-specific auth metadata.
643    #[must_use]
644    pub fn with_auth_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
645        self.auth_metadata.insert(key.into(), value.into());
646        self
647    }
648
649    /// Declares the OAuth scopes this command requires.
650    ///
651    /// Sugar over [`with_auth_metadata`](CommandSpec::with_auth_metadata) with the
652    /// `"scopes"` key (whitespace-joined). The scopes surface on
653    /// [`CommandMeta::scopes`](crate::CommandMeta) and reach the auth provider via
654    /// [`CredentialRequest`](crate::CredentialRequest); a provider that supports
655    /// scope step-up re-authenticates when the cached token lacks them.
656    #[must_use]
657    pub fn with_scopes(mut self, scopes: &[impl AsRef<str>]) -> Self {
658        let joined = scopes
659            .iter()
660            .map(AsRef::as_ref)
661            .collect::<Vec<_>>()
662            .join(" ");
663        // Mirror `CommandMeta::set_scopes`: an empty list clears the key rather
664        // than leaving an empty-but-present `auth_metadata["scopes"]`.
665        if joined.is_empty() {
666            self.auth_metadata.remove("scopes");
667        } else {
668            self.auth_metadata.insert("scopes".to_owned(), joined);
669        }
670        self
671    }
672
673    /// Adds a `clap` argument or option to this command.
674    #[must_use]
675    pub fn with_arg(mut self, arg: Arg) -> Self {
676        self.args.push(arg);
677        self
678    }
679
680    /// Adds a `clap` flag or option to this command.
681    #[must_use]
682    pub fn with_flag(self, flag: Arg) -> Self {
683        self.with_arg(flag)
684    }
685
686    /// Adds an argument relation (an `ArgGroup`) to this command, e.g. to
687    /// express "at least one of" or mutually-exclusive relationships between
688    /// arguments added with [`with_arg`](CommandSpec::with_arg)/[`with_flag`](CommandSpec::with_flag).
689    ///
690    /// The group's `ArgGroup::args([...])` ids must reference args already (or
691    /// later) added to this spec, matching `clap`'s own requirement that
692    /// referenced arg ids exist on the built `Command`. This replaces
693    /// hand-rolled `required_unless_present_any`/`conflicts_with` chains with a
694    /// single declarative relation.
695    #[must_use]
696    pub fn with_arg_group(mut self, group: ArgGroup) -> Self {
697        self.arg_groups.push(group);
698        self
699    }
700
701    /// Registers a compact framework schema from an [`OutputSchema`] type.
702    #[must_use]
703    pub fn with_output_schema<T: OutputSchema>(mut self) -> Self {
704        self.output_schema = Some(SchemaInfo {
705            command: String::new(),
706            fields: crate::output::fields_for::<T>(),
707            schema: None,
708        });
709        self
710    }
711
712    /// Registers JSON Schema generated from a Rust type with `schemars`.
713    #[must_use]
714    pub fn with_json_schema<T: JsonSchema>(mut self) -> Self {
715        self.output_schema = Some(crate::output::json_schema_info::<T>(""));
716        self
717    }
718
719    /// Marks whether the command should short-circuit under `--dry-run`.
720    #[must_use]
721    pub fn mutates(mut self, mutates: bool) -> Self {
722        self.mutates = mutates;
723        self
724    }
725
726    /// Opts this command into handler-driven `--dry-run` instead of the
727    /// engine's generic short-circuit.
728    ///
729    /// See [`handles_dry_run`](CommandSpec::handles_dry_run) (the field) for
730    /// the contract a handler must follow once it opts in — in particular,
731    /// **only use this with a context-aware handler**
732    /// ([`RuntimeCommandSpec::new_with_context`],
733    /// [`new_streaming`](RuntimeCommandSpec::new_streaming),
734    /// [`new_typed_with_context`](RuntimeCommandSpec::new_typed_with_context), or
735    /// [`new_typed_streaming`](RuntimeCommandSpec::new_typed_streaming)); a
736    /// `new`/`new_typed` handler can't observe `--dry-run` and would execute
737    /// its real side effects under it regardless of this flag.
738    #[must_use]
739    pub fn handles_dry_run(mut self, handles: bool) -> Self {
740        self.handles_dry_run = handles;
741        self
742    }
743
744    /// Forces this command's successful output to print verbatim to stdout.
745    #[must_use]
746    pub fn raw_output(mut self, raw_output: bool) -> Self {
747        self.raw_output = raw_output;
748        self
749    }
750
751    /// Builds middleware metadata from the spec.
752    #[must_use]
753    pub fn metadata(&self) -> CommandMeta {
754        let mut auth_metadata = self.auth_metadata.clone();
755        if let Some(provider) = &self.auth_provider
756            && !provider.is_empty()
757        {
758            auth_metadata.insert("provider".to_owned(), provider.clone());
759        }
760        if let Some(tier) = self.tier
761            && !auth_metadata.contains_key("tier")
762        {
763            auth_metadata.insert("tier".to_owned(), tier.to_string());
764        }
765        let scopes = auth_metadata
766            .get("scopes")
767            .map(|scopes| {
768                scopes
769                    .split_whitespace()
770                    .map(str::to_owned)
771                    .collect::<Vec<_>>()
772            })
773            .unwrap_or_default();
774
775        CommandMeta {
776            dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating),
777            handles_dry_run: self.handles_dry_run,
778            auth_metadata,
779            scopes,
780        }
781    }
782
783    /// Builds the `clap` command for parser registration.
784    #[must_use]
785    pub fn clap_command(&self) -> Command {
786        let mut command = Command::new(self.name.clone()).about(self.short.clone());
787        if let Some(long) = &self.long
788            && !long.is_empty()
789        {
790            command = command.long_about(long.clone());
791        }
792        for alias in &self.aliases {
793            command = command.alias(alias.clone());
794        }
795        if self.hidden {
796            command = command.hide(true);
797        }
798        // Explicit `display_order` (rather than relying on clap's own
799        // implicit per-`Command` counter) guarantees these render first, as
800        // a block, in declaration order — see `flags::global_flag_order`
801        // for why leaving it implicit lets a propagated global flag collide
802        // with a low counter value here and interleave with these instead.
803        for (index, arg) in self.args.iter().enumerate() {
804            command = command.arg(arg.clone().display_order(index));
805        }
806        for group in &self.arg_groups {
807            command = command.group(group.clone());
808        }
809        command
810    }
811}
812
813/// Declarative command group metadata.
814///
815/// Groups are noun-based containers. They do not run business logic directly;
816/// when invoked bare, the CLI renders group help.
817///
818/// Construct with [`GroupSpec::new`], then configure with the `with_*` builder
819/// methods — never as a struct literal. `#[non_exhaustive]` enforces this so
820/// the engine can add fields later without a breaking release.
821#[derive(Clone, Debug, Default)]
822#[non_exhaustive]
823pub struct GroupSpec {
824    /// Group command name.
825    pub name: String,
826    /// One-line group description.
827    pub short: String,
828    /// Optional long help text.
829    pub long: Option<String>,
830    /// Alternate group names accepted by the parser.
831    pub aliases: Vec<String>,
832    /// Whether the group runs but is hidden from discovery output.
833    pub hidden: bool,
834    /// Declarative child commands used for static tree construction.
835    pub commands: Vec<CommandSpec>,
836    /// Declarative nested groups used for static tree construction.
837    pub groups: Vec<GroupSpec>,
838    /// This group's own feature-flag declaration, if any.
839    ///
840    /// `None` means the group has no explicit stage declaration of its own, in
841    /// which case it inherits its effective stage from its nearest ancestor
842    /// (enclosing group, then module — nearest declaration wins), implicitly
843    /// resolving to [`Stage::Ga`] if nothing in the ancestor chain declares a
844    /// flag either; see [`Stage`]'s documentation for why that is its default.
845    /// Set with [`with_feature_flag`](GroupSpec::with_feature_flag). This field
846    /// only records the group's own declaration; cascading resolution against
847    /// the ancestor chain happens when a [`Cli`](crate::Cli) mounts the
848    /// enclosing module or parent group.
849    pub feature_flag: Option<FeatureFlag>,
850}
851
852impl GroupSpec {
853    /// Creates a command group with the required name and one-line help.
854    #[must_use]
855    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
856        Self {
857            name: name.into(),
858            short: short.into(),
859            ..Self::default()
860        }
861    }
862
863    /// Sets expanded group help.
864    #[must_use]
865    pub fn with_long(mut self, long: impl Into<String>) -> Self {
866        self.long = Some(long.into());
867        self
868    }
869
870    /// Adds one group alias.
871    #[must_use]
872    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
873        self.aliases.push(alias.into());
874        self
875    }
876
877    /// Hides or shows this group in discovery output.
878    #[must_use]
879    pub fn hidden(mut self, hidden: bool) -> Self {
880        self.hidden = hidden;
881        self
882    }
883
884    /// Adds one declarative child command.
885    #[must_use]
886    pub fn with_command(mut self, command: CommandSpec) -> Self {
887        self.commands.push(command);
888        self
889    }
890
891    /// Adds one declarative nested group.
892    #[must_use]
893    pub fn with_group(mut self, group: GroupSpec) -> Self {
894        self.groups.push(group);
895        self
896    }
897
898    /// Declares this group's own feature flag: the key used for policy overrides
899    /// and introspection, and the stage at which it becomes visible.
900    #[must_use]
901    pub fn with_feature_flag(mut self, key: impl Into<String>, stage: Stage) -> Self {
902        self.feature_flag = Some(FeatureFlag::new(key, stage));
903        self
904    }
905
906    /// Builds the `clap` command for parser registration.
907    #[must_use]
908    pub fn clap_command(&self) -> Command {
909        let mut command = Command::new(self.name.clone()).about(self.short.clone());
910        if let Some(long) = &self.long
911            && !long.is_empty()
912        {
913            command = command.long_about(long.clone());
914        }
915        for alias in &self.aliases {
916            command = command.alias(alias.clone());
917        }
918        if self.hidden {
919            command = command.hide(true);
920        }
921        for group in &self.groups {
922            command = command.subcommand(group.clap_command());
923        }
924        for child in &self.commands {
925            command = command.subcommand(child.clap_command());
926        }
927        command
928    }
929}
930
931/// Executable leaf command.
932///
933/// `RuntimeCommandSpec` pairs a [`CommandSpec`] with async business logic.
934/// This split keeps metadata inspectable for help/search/schema generation
935/// before the handler ever runs.
936///
937/// Use [`RuntimeCommandSpec::new_streaming`] for commands that emit incremental
938/// NDJSON progress events (e.g. long-running deployments with `--follow`).
939///
940/// Construct with one of the `new*` constructors — never as a struct literal.
941/// Literal construction would bypass the `handles_dry_run`/handler-shape
942/// misuse checks those constructors debug-assert. `#[non_exhaustive]` also
943/// means the engine can add fields without a breaking release.
944#[derive(Clone)]
945#[non_exhaustive]
946pub struct RuntimeCommandSpec {
947    /// Declarative command metadata.
948    pub spec: CommandSpec,
949    /// Async command implementation.
950    pub handler: CommandHandler,
951    /// Optional streaming handler. When set, the engine writes NDJSON events
952    /// to stdout as they arrive instead of collecting a single envelope.
953    pub streaming_handler: Option<StreamingCommandHandler>,
954}
955
956impl std::fmt::Debug for RuntimeCommandSpec {
957    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958        formatter
959            .debug_struct("RuntimeCommandSpec")
960            .field("spec", &self.spec)
961            .field("is_streaming", &self.streaming_handler.is_some())
962            .finish_non_exhaustive()
963    }
964}
965
966impl RuntimeCommandSpec {
967    /// Creates a runtime command with the common handler shape.
968    ///
969    /// The handler receives a lazy [`CredentialResolver`] and the effective args.
970    /// Call `resolver.resolve().await?` only when the command actually needs a
971    /// credential; commands that ignore it never trigger an auth flow. The
972    /// handler returns [`CommandResult`], where `data` must be JSON-serializable.
973    ///
974    /// This handler shape has no [`CommandContext`], so it can never call
975    /// [`CommandContext::dry_run`] — do not pair this with
976    /// [`CommandSpec::handles_dry_run`] (debug-asserted; see that field's docs).
977    #[must_use]
978    pub fn new<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
979    where
980        F: Fn(CredentialResolver, ValueMap) -> Fut + Send + Sync + 'static,
981        Fut: Future<Output = Result<Output>> + Send + 'static,
982        Output: Into<CommandResult> + Send + 'static,
983    {
984        debug_assert!(
985            !spec.handles_dry_run,
986            "command {:?} sets handles_dry_run but RuntimeCommandSpec::new's handler \
987             (CredentialResolver, args) has no CommandContext and can never check \
988             CommandContext::dry_run(), so it would silently run its real side effects \
989             under --dry-run; use RuntimeCommandSpec::new_with_context (or \
990             new_typed_with_context to keep typed args) instead",
991            spec.name
992        );
993        Self {
994            spec,
995            streaming_handler: None,
996            handler: Arc::new(move |context| {
997                let future = handler(context.credential, context.args);
998                Box::pin(async move { future.await.map(Into::into) })
999            }),
1000        }
1001    }
1002
1003    /// Creates a runtime command with the full invocation context.
1004    #[must_use]
1005    pub fn new_with_context<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
1006    where
1007        F: Fn(CommandContext) -> Fut + Send + Sync + 'static,
1008        Fut: Future<Output = Result<Output>> + Send + 'static,
1009        Output: Into<CommandResult> + Send + 'static,
1010    {
1011        Self {
1012            spec,
1013            streaming_handler: None,
1014            handler: Arc::new(move |context| {
1015                let future = handler(context);
1016                Box::pin(async move { future.await.map(Into::into) })
1017            }),
1018        }
1019    }
1020
1021    /// Creates a streaming command that emits NDJSON events to stdout.
1022    ///
1023    /// The handler receives context and a [`StreamSender`]. It should call
1024    /// `sender.send(event).await` for each progress event, then return `Ok(())`.
1025    /// The engine writes each event as a JSON line; stdout is flushed after each.
1026    #[must_use]
1027    pub fn new_streaming<F, Fut>(spec: CommandSpec, handler: F) -> Self
1028    where
1029        F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static,
1030        Fut: Future<Output = Result<()>> + Send + 'static,
1031    {
1032        debug_assert!(
1033            !spec.raw_output,
1034            "command {:?} sets raw_output but RuntimeCommandSpec::new_streaming writes \
1035             chunked NDJSON events, which does not fit a single-verbatim-string contract; \
1036             raw_output is only supported on non-streaming commands",
1037            spec.name
1038        );
1039        let streaming: StreamingCommandHandler = Arc::new(move |context, sender| {
1040            let future = handler(context, sender);
1041            Box::pin(future)
1042        });
1043        Self {
1044            spec,
1045            streaming_handler: Some(streaming),
1046            handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })),
1047        }
1048    }
1049
1050    /// Creates a runtime command with typed argument deserialization.
1051    ///
1052    /// The handler receives a lazy [`CredentialResolver`] and the deserialized
1053    /// args struct. Use with `CommandSpec::from_args::<T>()` to get end-to-end
1054    /// type safety from argument definition through handler consumption.
1055    ///
1056    /// If the handler also needs the command path, middleware, or user-supplied
1057    /// args, use [`RuntimeCommandSpec::new_typed_with_context`] (or
1058    /// [`RuntimeCommandSpec::new_with_context`] with
1059    /// [`CommandContext::typed_args`]) instead.
1060    ///
1061    /// This handler shape has no [`CommandContext`], so it can never call
1062    /// [`CommandContext::dry_run`] — do not pair this with
1063    /// [`CommandSpec::handles_dry_run`] (debug-asserted; see that field's docs).
1064    #[must_use]
1065    pub fn new_typed<T, F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
1066    where
1067        T: clap::FromArgMatches + Send + 'static,
1068        F: Fn(CredentialResolver, T) -> Fut + Send + Sync + 'static,
1069        Fut: Future<Output = Result<Output>> + Send + 'static,
1070        Output: Into<CommandResult> + Send + 'static,
1071    {
1072        debug_assert!(
1073            !spec.handles_dry_run,
1074            "command {:?} sets handles_dry_run but RuntimeCommandSpec::new_typed's handler \
1075             (CredentialResolver, args) has no CommandContext and can never check \
1076             CommandContext::dry_run(), so it would silently run its real side effects \
1077             under --dry-run; use RuntimeCommandSpec::new_with_context (or \
1078             new_typed_with_context to keep typed args) instead",
1079            spec.name
1080        );
1081        let handler = Arc::new(handler);
1082        Self {
1083            spec,
1084            handler: Arc::new(move |context| {
1085                let credential = context.credential.clone();
1086                let parsed = T::from_arg_matches(context.raw_matches.as_ref());
1087                let handler = handler.clone();
1088                Box::pin(async move {
1089                    let args = parsed.map_err(|e| {
1090                        crate::CliCoreError::Message(format!("argument parse error: {e}"))
1091                    })?;
1092                    handler(credential, args).await.map(Into::into)
1093                })
1094            }),
1095            streaming_handler: None,
1096        }
1097    }
1098
1099    /// Creates a runtime command with full context and typed argument
1100    /// deserialization.
1101    ///
1102    /// Combines [`new_with_context`](RuntimeCommandSpec::new_with_context)'s
1103    /// access to [`CommandContext`] (command path, middleware snapshot,
1104    /// user-supplied args, [`CommandContext::dry_run`]) with
1105    /// [`new_typed`](RuntimeCommandSpec::new_typed)'s automatic
1106    /// deserialization: the engine parses `T` from the raw matches before
1107    /// invoking the handler, so the handler never needs to call
1108    /// [`CommandContext::typed_args`] itself.
1109    ///
1110    /// Use this instead of `new_with_context` + `context.typed_args::<T>()`
1111    /// when a command needs full context and wants eager, guaranteed-parsed
1112    /// typed args rather than parsing on demand. Because the handler receives
1113    /// a [`CommandContext`], this is a valid pairing with
1114    /// [`CommandSpec::handles_dry_run`].
1115    ///
1116    /// # Errors
1117    ///
1118    /// The returned handler surfaces a `CliCoreError::Message` if `T` fails to
1119    /// deserialize from the parsed matches (this should not happen for args
1120    /// generated by `CommandSpec::from_args::<T>()`, since `clap` already
1121    /// validated them during parsing).
1122    #[must_use]
1123    pub fn new_typed_with_context<T, F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
1124    where
1125        T: clap::FromArgMatches + Send + 'static,
1126        F: Fn(CommandContext, T) -> Fut + Send + Sync + 'static,
1127        Fut: Future<Output = Result<Output>> + Send + 'static,
1128        Output: Into<CommandResult> + Send + 'static,
1129    {
1130        let handler = Arc::new(handler);
1131        Self {
1132            spec,
1133            handler: Arc::new(move |context| {
1134                let parsed = T::from_arg_matches(context.raw_matches.as_ref());
1135                let handler = handler.clone();
1136                Box::pin(async move {
1137                    let args = parsed.map_err(|e| {
1138                        crate::CliCoreError::Message(format!("argument parse error: {e}"))
1139                    })?;
1140                    handler(context, args).await.map(Into::into)
1141                })
1142            }),
1143            streaming_handler: None,
1144        }
1145    }
1146
1147    /// Creates a streaming command with full context and typed argument
1148    /// deserialization.
1149    ///
1150    /// Combines [`new_streaming`](RuntimeCommandSpec::new_streaming)'s NDJSON
1151    /// event emission with [`new_typed`](RuntimeCommandSpec::new_typed)'s
1152    /// automatic deserialization: the engine parses `T` from the raw matches
1153    /// before invoking the handler.
1154    ///
1155    /// # Errors
1156    ///
1157    /// The returned handler surfaces a `CliCoreError::Message` if `T` fails to
1158    /// deserialize from the parsed matches.
1159    #[must_use]
1160    pub fn new_typed_streaming<T, F, Fut>(spec: CommandSpec, handler: F) -> Self
1161    where
1162        T: clap::FromArgMatches + Send + 'static,
1163        F: Fn(CommandContext, T, StreamSender) -> Fut + Send + Sync + 'static,
1164        Fut: Future<Output = Result<()>> + Send + 'static,
1165    {
1166        debug_assert!(
1167            !spec.raw_output,
1168            "command {:?} sets raw_output but RuntimeCommandSpec::new_typed_streaming writes \
1169             chunked NDJSON events, which does not fit a single-verbatim-string contract; \
1170             raw_output is only supported on non-streaming commands",
1171            spec.name
1172        );
1173        let handler = Arc::new(handler);
1174        let streaming: StreamingCommandHandler = Arc::new(move |context, sender| {
1175            let parsed = T::from_arg_matches(context.raw_matches.as_ref());
1176            let handler = handler.clone();
1177            Box::pin(async move {
1178                let args = parsed.map_err(|e| {
1179                    crate::CliCoreError::Message(format!("argument parse error: {e}"))
1180                })?;
1181                handler(context, args, sender).await
1182            })
1183        });
1184        Self {
1185            spec,
1186            streaming_handler: Some(streaming),
1187            handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })),
1188        }
1189    }
1190}
1191
1192/// Executable command group with runtime children.
1193///
1194/// Construct with [`RuntimeGroupSpec::new`], then chain `with_*` methods —
1195/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine
1196/// can add fields without a breaking release.
1197#[derive(Clone, Debug, Default)]
1198#[non_exhaustive]
1199pub struct RuntimeGroupSpec {
1200    /// Declarative group metadata.
1201    pub group: GroupSpec,
1202    /// Executable leaf commands under this group.
1203    pub commands: Vec<RuntimeCommandSpec>,
1204    /// Executable nested groups under this group.
1205    pub groups: Vec<RuntimeGroupSpec>,
1206}
1207
1208impl RuntimeGroupSpec {
1209    /// Creates a runtime group from declarative group metadata.
1210    #[must_use]
1211    pub fn new(group: GroupSpec) -> Self {
1212        Self {
1213            group,
1214            ..Self::default()
1215        }
1216    }
1217
1218    /// Adds one executable leaf command.
1219    #[must_use]
1220    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
1221        self.commands.push(command);
1222        self
1223    }
1224
1225    /// Adds one executable nested group.
1226    #[must_use]
1227    pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self {
1228        self.groups.push(group);
1229        self
1230    }
1231
1232    /// Builds the `clap` command for parser registration.
1233    #[must_use]
1234    pub fn clap_command(&self) -> Command {
1235        let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone());
1236        if let Some(long) = &self.group.long
1237            && !long.is_empty()
1238        {
1239            command = command.long_about(long.clone());
1240        }
1241        for alias in &self.group.aliases {
1242            command = command.alias(alias.clone());
1243        }
1244        if self.group.hidden {
1245            command = command.hide(true);
1246        }
1247        for group in &self.groups {
1248            command = command.subcommand(group.clap_command());
1249        }
1250        for child in &self.commands {
1251            command = command.subcommand(child.spec.clap_command());
1252        }
1253        command
1254    }
1255
1256    pub(crate) fn register_commands(
1257        &self,
1258        prefix: &mut Vec<String>,
1259        out: &mut BTreeMap<String, RuntimeCommandSpec>,
1260    ) {
1261        prefix.push(self.group.name.clone());
1262        for group in &self.groups {
1263            group.register_commands(prefix, out);
1264        }
1265        for command in &self.commands {
1266            prefix.push(command.spec.name.clone());
1267            out.insert(prefix.join(":"), command.clone());
1268            prefix.pop();
1269        }
1270        prefix.pop();
1271    }
1272}
1273
1274/// Extracts the colon-separated command path from parsed `clap` matches.
1275#[must_use]
1276pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String {
1277    let mut parts = Vec::new();
1278    let mut current = matches;
1279    while let Some((name, submatches)) = current.subcommand() {
1280        if name != root_name {
1281            parts.push(name.to_owned());
1282        }
1283        current = submatches;
1284    }
1285    parts.join(":")
1286}
1287
1288/// Builds a colon-separated command path from path parts.
1289///
1290/// The optional annotation is used only for isolated single-command tests.
1291#[must_use]
1292pub fn command_path_from_parts(parts: &[impl AsRef<str>], path_annotation: Option<&str>) -> String {
1293    if parts.is_empty() {
1294        return String::new();
1295    }
1296    if parts.len() > 1 {
1297        return parts[1..]
1298            .iter()
1299            .map(AsRef::as_ref)
1300            .collect::<Vec<_>>()
1301            .join(":");
1302    }
1303    path_annotation
1304        .filter(|annotation| !annotation.is_empty())
1305        .map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned)
1306}
1307
1308/// Returns the deepest subcommand matches.
1309#[must_use]
1310pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches {
1311    let mut current = matches;
1312    while let Some((_, submatches)) = current.subcommand() {
1313        current = submatches;
1314    }
1315    current
1316}
1317
1318/// Converts parsed command arguments into the JSON-ish map consumed by middleware.
1319///
1320/// When `changed_only` is true, only arguments that came from the command line
1321/// are included. This is the user-args map used by authz and audit.
1322#[must_use]
1323pub fn command_args_from_matches(
1324    matches: &ArgMatches,
1325    spec: &CommandSpec,
1326    changed_only: bool,
1327) -> ValueMap {
1328    let mut args = ValueMap::new();
1329    for arg in &spec.args {
1330        let id = arg.get_id().to_string();
1331        let changed = matches
1332            .value_source(&id)
1333            .is_some_and(|source| source == clap::parser::ValueSource::CommandLine);
1334        if changed_only && !changed {
1335            continue;
1336        }
1337        if let Some(value) = arg_value_from_matches(matches, arg, &id) {
1338            args.insert(id, value);
1339        }
1340    }
1341    args
1342}
1343
1344fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option<Value> {
1345    matches.value_source(id)?;
1346
1347    if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
1348        && let Some(value) = matches.get_one::<bool>(id)
1349    {
1350        return Some(Value::Bool(*value));
1351    }
1352
1353    if let Some(value) = typed_arg_value_from_matches(matches, id) {
1354        return Some(value);
1355    }
1356
1357    if let Some(values) = matches.get_raw(id) {
1358        let rendered = values
1359            .map(|value| value.to_string_lossy().into_owned())
1360            .collect::<Vec<_>>();
1361        return match rendered.as_slice() {
1362            [] => None,
1363            [single] => Some(Value::String(single.clone())),
1364            _ => Some(Value::Array(
1365                rendered.into_iter().map(Value::String).collect(),
1366            )),
1367        };
1368    }
1369
1370    if let Some(value) = matches.get_one::<String>(id) {
1371        return Some(Value::String(value.clone()));
1372    }
1373    if let Some(value) = matches.get_one::<usize>(id) {
1374        return Some(serde_json::json!(value));
1375    }
1376    if let Some(value) = matches.get_one::<u64>(id) {
1377        return Some(serde_json::json!(value));
1378    }
1379    if let Some(value) = matches.get_one::<i64>(id) {
1380        return Some(serde_json::json!(value));
1381    }
1382    None
1383}
1384
1385fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option<Value> {
1386    typed_values::<bool>(matches, id, Value::Bool)
1387        .or_else(|| typed_values::<i8>(matches, id, |value| Value::Number(value.into())))
1388        .or_else(|| typed_values::<i16>(matches, id, |value| Value::Number(value.into())))
1389        .or_else(|| typed_values::<i64>(matches, id, |value| Value::Number(value.into())))
1390        .or_else(|| typed_values::<i32>(matches, id, |value| Value::Number(value.into())))
1391        .or_else(|| typed_values::<u8>(matches, id, |value| Value::Number(value.into())))
1392        .or_else(|| typed_values::<u16>(matches, id, |value| Value::Number(value.into())))
1393        .or_else(|| typed_values::<u64>(matches, id, |value| Value::Number(value.into())))
1394        .or_else(|| typed_values::<u32>(matches, id, |value| Value::Number(value.into())))
1395        .or_else(|| {
1396            typed_values::<usize>(matches, id, |value| {
1397                u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into()))
1398            })
1399        })
1400        .or_else(|| {
1401            typed_values::<f64>(matches, id, |value| {
1402                Number::from_f64(value).map_or(Value::Null, Value::Number)
1403            })
1404        })
1405        .or_else(|| {
1406            typed_values::<f32>(matches, id, |value| {
1407                Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number)
1408            })
1409        })
1410        .or_else(|| typed_values::<String>(matches, id, Value::String))
1411}
1412
1413fn typed_values<T>(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option<Value>
1414where
1415    T: Clone + Send + Sync + 'static,
1416{
1417    let Ok(Some(values)) = matches.try_get_many::<T>(id) else {
1418        return None;
1419    };
1420    let values = values.cloned().map(to_value).collect::<Vec<_>>();
1421    match values.as_slice() {
1422        [] => None,
1423        [single] => Some(single.clone()),
1424        _ => Some(Value::Array(values)),
1425    }
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430    use super::*;
1431
1432    #[test]
1433    fn command_spec_with_feature_flag_sets_key_and_stage() {
1434        let spec =
1435            CommandSpec::new("list", "List things").with_feature_flag("my-flag", Stage::Beta);
1436
1437        let flag = spec
1438            .feature_flag
1439            .as_ref()
1440            .expect("feature flag should be set");
1441        assert_eq!(flag.key, "my-flag");
1442        assert_eq!(flag.stage, Stage::Beta);
1443    }
1444
1445    #[test]
1446    fn command_spec_feature_flag_defaults_to_none() {
1447        let spec = CommandSpec::new("list", "List things");
1448
1449        assert!(spec.feature_flag.is_none());
1450    }
1451
1452    #[test]
1453    fn group_spec_with_feature_flag_sets_key_and_stage() {
1454        let group = GroupSpec::new("project", "Manage projects")
1455            .with_feature_flag("my-flag", Stage::Experimental);
1456
1457        let flag = group
1458            .feature_flag
1459            .as_ref()
1460            .expect("feature flag should be set");
1461        assert_eq!(flag.key, "my-flag");
1462        assert_eq!(flag.stage, Stage::Experimental);
1463    }
1464
1465    #[test]
1466    fn group_spec_feature_flag_defaults_to_none() {
1467        let group = GroupSpec::new("project", "Manage projects");
1468
1469        assert!(group.feature_flag.is_none());
1470    }
1471
1472    #[test]
1473    fn command_spec_with_arg_group_registers_group_on_clap_command() {
1474        let spec = CommandSpec::new("update", "Update a thing")
1475            .with_arg(Arg::new("a").long("a"))
1476            .with_arg(Arg::new("b").long("b"))
1477            .with_arg_group(ArgGroup::new("ab").args(["a", "b"]).required(true));
1478
1479        assert!(
1480            spec.clap_command()
1481                .try_get_matches_from(["update"])
1482                .is_err(),
1483            "neither `a` nor `b` present should fail the required group"
1484        );
1485        assert!(
1486            spec.clap_command()
1487                .try_get_matches_from(["update", "--a", "x"])
1488                .is_ok()
1489        );
1490    }
1491
1492    #[test]
1493    fn command_spec_from_args_preserves_derive_arg_group() {
1494        #[derive(clap::Args)]
1495        #[group(required = true, multiple = false)]
1496        struct ExclusiveArgs {
1497            #[arg(long)]
1498            one: bool,
1499            #[arg(long)]
1500            two: bool,
1501        }
1502
1503        let spec = CommandSpec::from_args::<ExclusiveArgs>("bump", "Bump one thing");
1504
1505        assert_eq!(spec.arg_groups.len(), 1);
1506        let group = &spec.arg_groups[0];
1507        assert!(group.is_required_set());
1508        assert_eq!(group.get_args().count(), 2);
1509    }
1510
1511    #[test]
1512    fn command_spec_from_args_preserves_version_argument() {
1513        #[derive(clap::Args)]
1514        struct ReleaseArgs {
1515            #[arg(long)]
1516            version: String,
1517        }
1518
1519        let spec = CommandSpec::from_args::<ReleaseArgs>("release", "Create a release");
1520
1521        assert!(
1522            spec.clap_command()
1523                .try_get_matches_from(["release", "--version", "1.0.0"])
1524                .is_ok(),
1525            "typed command arguments named `version` must remain available as `--version`"
1526        );
1527    }
1528}