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