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