Skip to main content

cli_engine/
command.rs

1use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc};
2
3use clap::{Arg, ArgAction, ArgMatches, Command};
4use schemars::JsonSchema;
5use serde_json::{Number, Value};
6use tokio::sync::mpsc;
7
8use crate::{
9    AuthRequirement, CommandMeta, Credential, CredentialResolver, Middleware, OutputSchema, Result,
10    SchemaInfo, Tier, middleware::ValueMap, output::NextAction,
11};
12
13/// Sender half for streaming command output.
14///
15/// Streaming handlers call [`StreamSender::send`] for each progress event.
16/// The engine drains the channel and writes each event as an NDJSON line.
17#[derive(Clone, Debug)]
18pub struct StreamSender(pub(crate) mpsc::Sender<Value>);
19
20impl StreamSender {
21    /// Sends one event. Silently drops the event if the receiver is gone.
22    pub async fn send(&self, event: Value) {
23        drop(self.0.send(event).await);
24    }
25}
26
27/// Boxed future returned by runtime command handlers.
28pub type CommandFuture = Pin<Box<dyn Future<Output = Result<CommandResult>> + Send>>;
29/// Shared command handler used by [`RuntimeCommandSpec`].
30pub type CommandHandler = Arc<dyn Fn(CommandContext) -> CommandFuture + Send + Sync>;
31
32/// Boxed future returned by streaming command handlers.
33pub type StreamingCommandFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
34/// Shared streaming handler: receives context and an event sender; returns when the stream ends.
35pub type StreamingCommandHandler =
36    Arc<dyn Fn(CommandContext, StreamSender) -> StreamingCommandFuture + Send + Sync>;
37
38/// Data returned by a command handler.
39///
40/// Command handlers should return renderable data and keep output metadata on
41/// [`CommandSpec`]. The metadata field is reserved for future command-result
42/// extensions that are not known when the command is registered.
43#[derive(Clone, Debug, PartialEq)]
44pub struct CommandResult {
45    /// JSON data rendered by the configured output formatter.
46    pub data: Value,
47    /// Optional command-result extension metadata.
48    pub metadata: CommandResultMetadata,
49}
50
51impl CommandResult {
52    /// Creates a command result from renderable JSON data.
53    #[must_use]
54    pub fn new(data: Value) -> Self {
55        Self {
56            data,
57            metadata: CommandResultMetadata::default(),
58        }
59    }
60
61    /// Attaches suggested follow-up actions to this result.
62    #[must_use]
63    pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
64        self.metadata.next_actions = actions;
65        self
66    }
67}
68
69impl From<Value> for CommandResult {
70    fn from(data: Value) -> Self {
71        Self::new(data)
72    }
73}
74
75/// Optional metadata a command can attach to its result.
76#[non_exhaustive]
77#[derive(Clone, Debug, Default, Eq, PartialEq)]
78pub struct CommandResultMetadata {
79    /// Suggested follow-up actions for the caller.
80    pub next_actions: Vec<NextAction>,
81}
82
83/// Runtime context passed to advanced command handlers.
84///
85/// Most commands can use [`RuntimeCommandSpec::new`] and receive just the
86/// credential and effective args. Use this context when a command needs the
87/// colon path, user-supplied args, or a snapshot of middleware state.
88///
89/// This struct is constructed by the framework during command dispatch.
90/// Consumer code receives it in handler closures and should not construct it
91/// directly.
92#[derive(Clone, Debug)]
93#[non_exhaustive]
94pub struct CommandContext {
95    /// Lazy credential resolver.
96    pub credential: CredentialResolver,
97    /// Effective arguments, including defaults and framework-injected values.
98    pub args: ValueMap,
99    /// Arguments explicitly supplied by the user.
100    pub user_args: ValueMap,
101    /// Colon-separated command path such as `project:list`.
102    pub command_path: String,
103    /// Middleware snapshot for this invocation.
104    pub middleware: Middleware,
105    /// Raw `clap` matches for typed argument deserialization via derive.
106    pub raw_matches: Arc<ArgMatches>,
107}
108
109impl CommandContext {
110    /// Returns the per-application config file as loaded at startup.
111    ///
112    /// Read a consumer-owned section with
113    /// [`ConfigFile::section`](crate::config::ConfigFile::section), for example
114    /// `ctx.config().section::<DeployConfig>("deploy")?`. Engine-reserved
115    /// settings are available via
116    /// [`ConfigFile::engine`](crate::config::ConfigFile::engine).
117    ///
118    /// **Snapshot semantics**: this is the config loaded once when
119    /// [`crate::cli::Cli::new`] was called. Changes made by `config set` during the same process
120    /// invocation (e.g. from a previous `Cli::run`) are not reflected here;
121    /// restart the CLI (a new `Cli::new`) to pick them up. For a one-shot CLI
122    /// process this is always the current on-disk state.
123    #[must_use]
124    pub fn config(&self) -> &crate::config::ConfigFile {
125        &self.middleware.config
126    }
127
128    /// Deserializes the raw argument matches into a typed args struct.
129    ///
130    /// Use this with `#[derive(clap::Args)]` structs to get type-safe access
131    /// to command arguments instead of working with the `ValueMap` directly.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the matches cannot be deserialized into `T`.
136    pub fn typed_args<T: clap::FromArgMatches>(&self) -> Result<T> {
137        T::from_arg_matches(self.raw_matches.as_ref())
138            .map_err(|e| crate::CliCoreError::Message(format!("argument parse error: {e}")))
139    }
140
141    /// Resolves the credential for this command, triggering the auth flow on
142    /// first use and memoizing the result.
143    ///
144    /// Convenience wrapper over [`self.credential.resolve()`](CredentialResolver::resolve).
145    ///
146    /// # Errors
147    ///
148    /// Returns an error when the command is marked `no_auth`, or when the auth
149    /// provider fails to produce a credential.
150    pub async fn credential(&self) -> Result<Credential> {
151        self.credential.resolve().await
152    }
153
154    /// Resolves the credential when one is available, returning `Ok(None)` for
155    /// no-auth commands.
156    ///
157    /// Convenience wrapper over [`self.credential.try_resolve()`](CredentialResolver::try_resolve).
158    ///
159    /// # Errors
160    ///
161    /// Propagates the auth provider error when resolution is attempted and fails.
162    pub async fn try_credential(&self) -> Result<Option<Credential>> {
163        self.credential.try_resolve().await
164    }
165
166    /// Resolves a credential that additionally covers `extra` scopes, on top of
167    /// the command's declared scopes.
168    ///
169    /// Use this when the required scopes are only known at runtime (for example
170    /// a generic API caller that derives scopes from the target endpoint). A
171    /// scope-aware auth provider re-authenticates when the cached token does not
172    /// already cover the requested set.
173    ///
174    /// Convenience wrapper over
175    /// [`self.credential.resolve_with_scopes()`](CredentialResolver::resolve_with_scopes).
176    ///
177    /// If the handler also issues HTTP requests through the transport bearer
178    /// injector, call this **before** the first request: the injector resolves
179    /// and caches a scope-unaware token, so stepping up afterwards would not
180    /// affect requests it already authorized. See
181    /// [`CredentialResolver::resolve_with_scopes`] for the full ordering note.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error when the command is marked `no_auth`, or when the auth
186    /// provider fails to produce a credential.
187    pub async fn credential_with_scopes(&self, extra: &[String]) -> Result<Credential> {
188        self.credential.resolve_with_scopes(extra).await
189    }
190}
191
192/// Declarative leaf command metadata and parser arguments.
193///
194/// `CommandSpec` intentionally keeps command metadata next to the command's
195/// handler. This is the primary copy/paste surface for teams adding commands.
196#[derive(Clone, Debug, Default)]
197pub struct CommandSpec {
198    /// Leaf command name.
199    pub name: String,
200    /// One-line command description.
201    pub short: String,
202    /// Optional long help text.
203    pub long: Option<String>,
204    /// Alternate command names accepted by the parser.
205    pub aliases: Vec<String>,
206    /// Whether the command runs but is hidden from help, tree, and search.
207    pub hidden: bool,
208    /// Backend/system id used in output metadata and generic error envelopes.
209    pub system: Option<String>,
210    /// Default comma-separated field projection.
211    pub default_fields: Option<String>,
212    /// Authentication requirement enforced by the engine for this command.
213    ///
214    /// Defaults to [`AuthRequirement::Required`] (fail-closed). Use
215    /// [`auth_optional`](CommandSpec::auth_optional) for commands that should run
216    /// logged out, or [`no_auth`](CommandSpec::no_auth) for commands that never
217    /// authenticate.
218    pub auth: AuthRequirement,
219    /// Auth provider name for this command.
220    pub auth_provider: Option<String>,
221    /// Risk tier used by authentication, authorization, and dry-run.
222    pub tier: Option<Tier>,
223    /// Explicit dry-run prompt marker for commands without a tier.
224    pub mutates: bool,
225    /// Provider-specific auth metadata.
226    pub auth_metadata: BTreeMap<String, String>,
227    /// Command-specific `clap` arguments.
228    pub args: Vec<Arg>,
229    /// Optional output schema published through `--schema` and help.
230    pub output_schema: Option<SchemaInfo>,
231}
232
233impl CommandSpec {
234    /// Creates a command spec with the required name and one-line help.
235    #[must_use]
236    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
237        Self {
238            name: name.into(),
239            short: short.into(),
240            ..Self::default()
241        }
242    }
243
244    /// Creates a command spec from a `#[derive(clap::Args)]` struct.
245    ///
246    /// Extracts the argument definitions from the derive type and populates the
247    /// spec's args list. The command name and help text are still required since
248    /// `Args` types do not carry those.
249    #[must_use]
250    pub fn from_args<T: clap::Args>(name: impl Into<String>, short: impl Into<String>) -> Self {
251        let placeholder = Command::new("__placeholder");
252        let augmented = T::augment_args(placeholder);
253        let args: Vec<Arg> = augmented
254            .get_arguments()
255            .filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
256            .cloned()
257            .collect();
258        Self {
259            name: name.into(),
260            short: short.into(),
261            args,
262            ..Self::default()
263        }
264    }
265
266    /// Sets expanded command help.
267    #[must_use]
268    pub fn with_long(mut self, long: impl Into<String>) -> Self {
269        self.long = Some(long.into());
270        self
271    }
272
273    /// Adds one command alias.
274    #[must_use]
275    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
276        self.aliases.push(alias.into());
277        self
278    }
279
280    /// Hides or shows this command in discovery output.
281    #[must_use]
282    pub fn hidden(mut self, hidden: bool) -> Self {
283        self.hidden = hidden;
284        self
285    }
286
287    /// Sets the backend/system id for output metadata and error attribution.
288    #[must_use]
289    pub fn with_system(mut self, system: impl Into<String>) -> Self {
290        self.system = Some(system.into());
291        self
292    }
293
294    /// Sets the default field projection used when `--fields` is absent.
295    #[must_use]
296    pub fn with_default_fields(mut self, default_fields: impl Into<String>) -> Self {
297        self.default_fields = Some(default_fields.into());
298        self
299    }
300
301    /// Selects the auth provider for this command.
302    #[must_use]
303    pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {
304        self.auth_provider = Some(provider.into());
305        self
306    }
307
308    /// Marks the command as no-auth.
309    ///
310    /// `no_auth(true)` sets [`AuthRequirement::None`]: the command never resolves
311    /// a credential and default-env injection is suppressed. `no_auth(false)`
312    /// restores the default [`AuthRequirement::Required`].
313    #[must_use]
314    pub fn no_auth(mut self, no_auth: bool) -> Self {
315        self.auth = if no_auth {
316            AuthRequirement::None
317        } else {
318            AuthRequirement::Required
319        };
320        self
321    }
322
323    /// Sets the command's [`AuthRequirement`] explicitly.
324    #[must_use]
325    pub fn auth(mut self, requirement: AuthRequirement) -> Self {
326        self.auth = requirement;
327        self
328    }
329
330    /// Marks authentication as optional ([`AuthRequirement::Optional`]).
331    ///
332    /// The engine does not resolve a credential before the handler runs; the
333    /// handler triggers the auth flow only by calling
334    /// [`CredentialResolver::resolve`]/[`try_resolve`](CredentialResolver::try_resolve).
335    /// Use for commands that should still run when the user is logged out.
336    #[must_use]
337    pub fn auth_optional(mut self) -> Self {
338        self.auth = AuthRequirement::Optional;
339        self
340    }
341
342    /// Sets the command risk tier.
343    #[must_use]
344    pub fn with_tier(mut self, tier: Tier) -> Self {
345        self.tier = Some(tier);
346        self
347    }
348
349    /// Adds provider-specific auth metadata.
350    #[must_use]
351    pub fn with_auth_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
352        self.auth_metadata.insert(key.into(), value.into());
353        self
354    }
355
356    /// Declares the OAuth scopes this command requires.
357    ///
358    /// Sugar over [`with_auth_metadata`](CommandSpec::with_auth_metadata) with the
359    /// `"scopes"` key (whitespace-joined). The scopes surface on
360    /// [`CommandMeta::scopes`](crate::CommandMeta) and reach the auth provider via
361    /// [`CredentialRequest`](crate::CredentialRequest); a provider that supports
362    /// scope step-up re-authenticates when the cached token lacks them.
363    #[must_use]
364    pub fn with_scopes(mut self, scopes: &[impl AsRef<str>]) -> Self {
365        let joined = scopes
366            .iter()
367            .map(AsRef::as_ref)
368            .collect::<Vec<_>>()
369            .join(" ");
370        // Mirror `CommandMeta::set_scopes`: an empty list clears the key rather
371        // than leaving an empty-but-present `auth_metadata["scopes"]`.
372        if joined.is_empty() {
373            self.auth_metadata.remove("scopes");
374        } else {
375            self.auth_metadata.insert("scopes".to_owned(), joined);
376        }
377        self
378    }
379
380    /// Adds a `clap` argument or option to this command.
381    #[must_use]
382    pub fn with_arg(mut self, arg: Arg) -> Self {
383        self.args.push(arg);
384        self
385    }
386
387    /// Adds a `clap` flag or option to this command.
388    #[must_use]
389    pub fn with_flag(self, flag: Arg) -> Self {
390        self.with_arg(flag)
391    }
392
393    /// Registers a compact framework schema from an [`OutputSchema`] type.
394    #[must_use]
395    pub fn with_output_schema<T: OutputSchema>(mut self) -> Self {
396        self.output_schema = Some(SchemaInfo {
397            command: String::new(),
398            fields: crate::output::fields_for::<T>(),
399            schema: None,
400        });
401        self
402    }
403
404    /// Registers JSON Schema generated from a Rust type with `schemars`.
405    #[must_use]
406    pub fn with_json_schema<T: JsonSchema>(mut self) -> Self {
407        self.output_schema = Some(crate::output::json_schema_info::<T>(""));
408        self
409    }
410
411    /// Marks whether the command should short-circuit under `--dry-run`.
412    #[must_use]
413    pub fn mutates(mut self, mutates: bool) -> Self {
414        self.mutates = mutates;
415        self
416    }
417
418    /// Builds middleware metadata from the spec.
419    #[must_use]
420    pub fn metadata(&self) -> CommandMeta {
421        let mut auth_metadata = self.auth_metadata.clone();
422        if let Some(provider) = &self.auth_provider
423            && !provider.is_empty()
424        {
425            auth_metadata.insert("provider".to_owned(), provider.clone());
426        }
427        if let Some(tier) = self.tier
428            && !auth_metadata.contains_key("tier")
429        {
430            auth_metadata.insert("tier".to_owned(), tier.to_string());
431        }
432        let scopes = auth_metadata
433            .get("scopes")
434            .map(|scopes| {
435                scopes
436                    .split_whitespace()
437                    .map(str::to_owned)
438                    .collect::<Vec<_>>()
439            })
440            .unwrap_or_default();
441
442        CommandMeta {
443            dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating),
444            auth_metadata,
445            scopes,
446        }
447    }
448
449    /// Builds the `clap` command for parser registration.
450    #[must_use]
451    pub fn clap_command(&self) -> Command {
452        let mut command = Command::new(self.name.clone()).about(self.short.clone());
453        if let Some(long) = &self.long
454            && !long.is_empty()
455        {
456            command = command.long_about(long.clone());
457        }
458        for alias in &self.aliases {
459            command = command.alias(alias.clone());
460        }
461        if self.hidden {
462            command = command.hide(true);
463        }
464        for arg in &self.args {
465            command = command.arg(arg.clone());
466        }
467        command
468    }
469}
470
471/// Declarative command group metadata.
472///
473/// Groups are noun-based containers. They do not run business logic directly;
474/// when invoked bare, the CLI renders group help.
475#[derive(Clone, Debug, Default)]
476pub struct GroupSpec {
477    /// Group command name.
478    pub name: String,
479    /// One-line group description.
480    pub short: String,
481    /// Optional long help text.
482    pub long: Option<String>,
483    /// Alternate group names accepted by the parser.
484    pub aliases: Vec<String>,
485    /// Whether the group runs but is hidden from discovery output.
486    pub hidden: bool,
487    /// Declarative child commands used for static tree construction.
488    pub commands: Vec<CommandSpec>,
489    /// Declarative nested groups used for static tree construction.
490    pub groups: Vec<GroupSpec>,
491}
492
493impl GroupSpec {
494    /// Creates a command group with the required name and one-line help.
495    #[must_use]
496    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
497        Self {
498            name: name.into(),
499            short: short.into(),
500            ..Self::default()
501        }
502    }
503
504    /// Sets expanded group 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 group 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 group in discovery output.
519    #[must_use]
520    pub fn hidden(mut self, hidden: bool) -> Self {
521        self.hidden = hidden;
522        self
523    }
524
525    /// Adds one declarative child command.
526    #[must_use]
527    pub fn with_command(mut self, command: CommandSpec) -> Self {
528        self.commands.push(command);
529        self
530    }
531
532    /// Adds one declarative nested group.
533    #[must_use]
534    pub fn with_group(mut self, group: GroupSpec) -> Self {
535        self.groups.push(group);
536        self
537    }
538
539    /// Builds the `clap` command for parser registration.
540    #[must_use]
541    pub fn clap_command(&self) -> Command {
542        let mut command = Command::new(self.name.clone()).about(self.short.clone());
543        if let Some(long) = &self.long
544            && !long.is_empty()
545        {
546            command = command.long_about(long.clone());
547        }
548        for alias in &self.aliases {
549            command = command.alias(alias.clone());
550        }
551        if self.hidden {
552            command = command.hide(true);
553        }
554        for group in &self.groups {
555            command = command.subcommand(group.clap_command());
556        }
557        for child in &self.commands {
558            command = command.subcommand(child.clap_command());
559        }
560        command
561    }
562}
563
564/// Executable leaf command.
565///
566/// `RuntimeCommandSpec` pairs a [`CommandSpec`] with async business logic.
567/// This split keeps metadata inspectable for help/search/schema generation
568/// before the handler ever runs.
569///
570/// Use [`RuntimeCommandSpec::new_streaming`] for commands that emit incremental
571/// NDJSON progress events (e.g. long-running deployments with `--follow`).
572#[derive(Clone)]
573pub struct RuntimeCommandSpec {
574    /// Declarative command metadata.
575    pub spec: CommandSpec,
576    /// Async command implementation.
577    pub handler: CommandHandler,
578    /// Optional streaming handler. When set, the engine writes NDJSON events
579    /// to stdout as they arrive instead of collecting a single envelope.
580    pub streaming_handler: Option<StreamingCommandHandler>,
581}
582
583impl std::fmt::Debug for RuntimeCommandSpec {
584    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        formatter
586            .debug_struct("RuntimeCommandSpec")
587            .field("spec", &self.spec)
588            .field("is_streaming", &self.streaming_handler.is_some())
589            .finish_non_exhaustive()
590    }
591}
592
593impl RuntimeCommandSpec {
594    /// Creates a runtime command with the common handler shape.
595    ///
596    /// The handler receives a lazy [`CredentialResolver`] and the effective args.
597    /// Call `resolver.resolve().await?` only when the command actually needs a
598    /// credential; commands that ignore it never trigger an auth flow. The
599    /// handler returns [`CommandResult`], where `data` must be JSON-serializable.
600    #[must_use]
601    pub fn new<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
602    where
603        F: Fn(CredentialResolver, ValueMap) -> Fut + Send + Sync + 'static,
604        Fut: Future<Output = Result<Output>> + Send + 'static,
605        Output: Into<CommandResult> + Send + 'static,
606    {
607        Self {
608            spec,
609            streaming_handler: None,
610            handler: Arc::new(move |context| {
611                let future = handler(context.credential, context.args);
612                Box::pin(async move { future.await.map(Into::into) })
613            }),
614        }
615    }
616
617    /// Creates a runtime command with the full invocation context.
618    #[must_use]
619    pub fn new_with_context<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
620    where
621        F: Fn(CommandContext) -> Fut + Send + Sync + 'static,
622        Fut: Future<Output = Result<Output>> + Send + 'static,
623        Output: Into<CommandResult> + Send + 'static,
624    {
625        Self {
626            spec,
627            streaming_handler: None,
628            handler: Arc::new(move |context| {
629                let future = handler(context);
630                Box::pin(async move { future.await.map(Into::into) })
631            }),
632        }
633    }
634
635    /// Creates a streaming command that emits NDJSON events to stdout.
636    ///
637    /// The handler receives context and a [`StreamSender`]. It should call
638    /// `sender.send(event).await` for each progress event, then return `Ok(())`.
639    /// The engine writes each event as a JSON line; stdout is flushed after each.
640    #[must_use]
641    pub fn new_streaming<F, Fut>(spec: CommandSpec, handler: F) -> Self
642    where
643        F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static,
644        Fut: Future<Output = Result<()>> + Send + 'static,
645    {
646        let streaming: StreamingCommandHandler = Arc::new(move |context, sender| {
647            let future = handler(context, sender);
648            Box::pin(future)
649        });
650        Self {
651            spec,
652            streaming_handler: Some(streaming),
653            handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })),
654        }
655    }
656
657    /// Creates a runtime command with typed argument deserialization.
658    ///
659    /// The handler receives a lazy [`CredentialResolver`] and the deserialized
660    /// args struct. Use with `CommandSpec::from_args::<T>()` to get end-to-end
661    /// type safety from argument definition through handler consumption.
662    ///
663    /// If the handler also needs the command path, middleware, or user-supplied
664    /// args, use [`RuntimeCommandSpec::new_with_context`] with
665    /// [`CommandContext::typed_args`] instead.
666    #[must_use]
667    pub fn new_typed<T, F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
668    where
669        T: clap::FromArgMatches + Send + 'static,
670        F: Fn(CredentialResolver, T) -> Fut + Send + Sync + 'static,
671        Fut: Future<Output = Result<Output>> + Send + 'static,
672        Output: Into<CommandResult> + Send + 'static,
673    {
674        let handler = Arc::new(handler);
675        Self {
676            spec,
677            handler: Arc::new(move |context| {
678                let credential = context.credential.clone();
679                let parsed = T::from_arg_matches(context.raw_matches.as_ref());
680                let handler = handler.clone();
681                Box::pin(async move {
682                    let args = parsed.map_err(|e| {
683                        crate::CliCoreError::Message(format!("argument parse error: {e}"))
684                    })?;
685                    handler(credential, args).await.map(Into::into)
686                })
687            }),
688            streaming_handler: None,
689        }
690    }
691}
692
693/// Executable command group with runtime children.
694#[derive(Clone, Debug, Default)]
695pub struct RuntimeGroupSpec {
696    /// Declarative group metadata.
697    pub group: GroupSpec,
698    /// Executable leaf commands under this group.
699    pub commands: Vec<RuntimeCommandSpec>,
700    /// Executable nested groups under this group.
701    pub groups: Vec<RuntimeGroupSpec>,
702}
703
704impl RuntimeGroupSpec {
705    /// Creates a runtime group from declarative group metadata.
706    #[must_use]
707    pub fn new(group: GroupSpec) -> Self {
708        Self {
709            group,
710            ..Self::default()
711        }
712    }
713
714    /// Adds one executable leaf command.
715    #[must_use]
716    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
717        self.commands.push(command);
718        self
719    }
720
721    /// Adds one executable nested group.
722    #[must_use]
723    pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self {
724        self.groups.push(group);
725        self
726    }
727
728    /// Builds the `clap` command for parser registration.
729    #[must_use]
730    pub fn clap_command(&self) -> Command {
731        let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone());
732        if let Some(long) = &self.group.long
733            && !long.is_empty()
734        {
735            command = command.long_about(long.clone());
736        }
737        for alias in &self.group.aliases {
738            command = command.alias(alias.clone());
739        }
740        if self.group.hidden {
741            command = command.hide(true);
742        }
743        for group in &self.groups {
744            command = command.subcommand(group.clap_command());
745        }
746        for child in &self.commands {
747            command = command.subcommand(child.spec.clap_command());
748        }
749        command
750    }
751
752    pub(crate) fn register_commands(
753        &self,
754        prefix: &mut Vec<String>,
755        out: &mut BTreeMap<String, RuntimeCommandSpec>,
756    ) {
757        prefix.push(self.group.name.clone());
758        for group in &self.groups {
759            group.register_commands(prefix, out);
760        }
761        for command in &self.commands {
762            prefix.push(command.spec.name.clone());
763            out.insert(prefix.join(":"), command.clone());
764            prefix.pop();
765        }
766        prefix.pop();
767    }
768}
769
770/// Extracts the colon-separated command path from parsed `clap` matches.
771#[must_use]
772pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String {
773    let mut parts = Vec::new();
774    let mut current = matches;
775    while let Some((name, submatches)) = current.subcommand() {
776        if name != root_name {
777            parts.push(name.to_owned());
778        }
779        current = submatches;
780    }
781    parts.join(":")
782}
783
784/// Builds a colon-separated command path from path parts.
785///
786/// The optional annotation is used only for isolated single-command tests.
787#[must_use]
788pub fn command_path_from_parts(parts: &[impl AsRef<str>], path_annotation: Option<&str>) -> String {
789    if parts.is_empty() {
790        return String::new();
791    }
792    if parts.len() > 1 {
793        return parts[1..]
794            .iter()
795            .map(AsRef::as_ref)
796            .collect::<Vec<_>>()
797            .join(":");
798    }
799    path_annotation
800        .filter(|annotation| !annotation.is_empty())
801        .map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned)
802}
803
804/// Returns the deepest subcommand matches.
805#[must_use]
806pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches {
807    let mut current = matches;
808    while let Some((_, submatches)) = current.subcommand() {
809        current = submatches;
810    }
811    current
812}
813
814/// Converts parsed command arguments into the JSON-ish map consumed by middleware.
815///
816/// When `changed_only` is true, only arguments that came from the command line
817/// are included. This is the user-args map used by authz and audit.
818#[must_use]
819pub fn command_args_from_matches(
820    matches: &ArgMatches,
821    spec: &CommandSpec,
822    changed_only: bool,
823) -> ValueMap {
824    let mut args = ValueMap::new();
825    for arg in &spec.args {
826        let id = arg.get_id().to_string();
827        let changed = matches
828            .value_source(&id)
829            .is_some_and(|source| source == clap::parser::ValueSource::CommandLine);
830        if changed_only && !changed {
831            continue;
832        }
833        if let Some(value) = arg_value_from_matches(matches, arg, &id) {
834            args.insert(id, value);
835        }
836    }
837    args
838}
839
840fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option<Value> {
841    matches.value_source(id)?;
842
843    if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
844        && let Some(value) = matches.get_one::<bool>(id)
845    {
846        return Some(Value::Bool(*value));
847    }
848
849    if let Some(value) = typed_arg_value_from_matches(matches, id) {
850        return Some(value);
851    }
852
853    if let Some(values) = matches.get_raw(id) {
854        let rendered = values
855            .map(|value| value.to_string_lossy().into_owned())
856            .collect::<Vec<_>>();
857        return match rendered.as_slice() {
858            [] => None,
859            [single] => Some(Value::String(single.clone())),
860            _ => Some(Value::Array(
861                rendered.into_iter().map(Value::String).collect(),
862            )),
863        };
864    }
865
866    if let Some(value) = matches.get_one::<String>(id) {
867        return Some(Value::String(value.clone()));
868    }
869    if let Some(value) = matches.get_one::<usize>(id) {
870        return Some(serde_json::json!(value));
871    }
872    if let Some(value) = matches.get_one::<u64>(id) {
873        return Some(serde_json::json!(value));
874    }
875    if let Some(value) = matches.get_one::<i64>(id) {
876        return Some(serde_json::json!(value));
877    }
878    None
879}
880
881fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option<Value> {
882    typed_values::<bool>(matches, id, Value::Bool)
883        .or_else(|| typed_values::<i8>(matches, id, |value| Value::Number(value.into())))
884        .or_else(|| typed_values::<i16>(matches, id, |value| Value::Number(value.into())))
885        .or_else(|| typed_values::<i64>(matches, id, |value| Value::Number(value.into())))
886        .or_else(|| typed_values::<i32>(matches, id, |value| Value::Number(value.into())))
887        .or_else(|| typed_values::<u8>(matches, id, |value| Value::Number(value.into())))
888        .or_else(|| typed_values::<u16>(matches, id, |value| Value::Number(value.into())))
889        .or_else(|| typed_values::<u64>(matches, id, |value| Value::Number(value.into())))
890        .or_else(|| typed_values::<u32>(matches, id, |value| Value::Number(value.into())))
891        .or_else(|| {
892            typed_values::<usize>(matches, id, |value| {
893                u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into()))
894            })
895        })
896        .or_else(|| {
897            typed_values::<f64>(matches, id, |value| {
898                Number::from_f64(value).map_or(Value::Null, Value::Number)
899            })
900        })
901        .or_else(|| {
902            typed_values::<f32>(matches, id, |value| {
903                Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number)
904            })
905        })
906        .or_else(|| typed_values::<String>(matches, id, Value::String))
907}
908
909fn typed_values<T>(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option<Value>
910where
911    T: Clone + Send + Sync + 'static,
912{
913    let Ok(Some(values)) = matches.try_get_many::<T>(id) else {
914        return None;
915    };
916    let values = values.cloned().map(to_value).collect::<Vec<_>>();
917    match values.as_slice() {
918        [] => None,
919        [single] => Some(single.clone()),
920        _ => Some(Value::Array(values)),
921    }
922}