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};
6
7use crate::{
8    CommandMeta, Credential, Middleware, OutputSchema, Result, SchemaInfo, Tier,
9    middleware::ValueMap,
10};
11
12/// Boxed future returned by runtime command handlers.
13pub type CommandFuture = Pin<Box<dyn Future<Output = Result<CommandResult>> + Send>>;
14/// Shared command handler used by [`RuntimeCommandSpec`].
15pub type CommandHandler = Arc<dyn Fn(CommandContext) -> CommandFuture + Send + Sync>;
16
17/// Data returned by a command handler.
18///
19/// Command handlers should return renderable data and keep output metadata on
20/// [`CommandSpec`]. The metadata field is reserved for future command-result
21/// extensions that are not known when the command is registered.
22#[derive(Clone, Debug, PartialEq)]
23pub struct CommandResult {
24    /// JSON data rendered by the configured output formatter.
25    pub data: Value,
26    /// Optional command-result extension metadata.
27    pub metadata: CommandResultMetadata,
28}
29
30impl CommandResult {
31    /// Creates a command result from renderable JSON data.
32    #[must_use]
33    pub fn new(data: Value) -> Self {
34        Self {
35            data,
36            metadata: CommandResultMetadata::default(),
37        }
38    }
39}
40
41impl From<Value> for CommandResult {
42    fn from(data: Value) -> Self {
43        Self::new(data)
44    }
45}
46
47/// Optional metadata a command can attach to its result.
48#[non_exhaustive]
49#[derive(Clone, Debug, Default, Eq, PartialEq)]
50pub struct CommandResultMetadata {}
51
52/// Runtime context passed to advanced command handlers.
53///
54/// Most commands can use [`RuntimeCommandSpec::new`] and receive just the
55/// credential and effective args. Use this context when a command needs the
56/// colon path, user-supplied args, or a snapshot of middleware state.
57#[derive(Clone, Debug)]
58pub struct CommandContext {
59    /// Credential resolved by middleware. No-auth commands receive `None`.
60    pub credential: Option<Credential>,
61    /// Effective arguments, including defaults and framework-injected values.
62    pub args: ValueMap,
63    /// Arguments explicitly supplied by the user.
64    pub user_args: ValueMap,
65    /// Colon-separated command path such as `project:list`.
66    pub command_path: String,
67    /// Middleware snapshot for this invocation.
68    pub middleware: Middleware,
69}
70
71/// Declarative leaf command metadata and parser arguments.
72///
73/// `CommandSpec` intentionally keeps command metadata next to the command's
74/// handler. This is the primary copy/paste surface for teams adding commands.
75#[derive(Clone, Debug, Default)]
76pub struct CommandSpec {
77    /// Leaf command name.
78    pub name: String,
79    /// One-line command description.
80    pub short: String,
81    /// Optional long help text.
82    pub long: Option<String>,
83    /// Alternate command names accepted by the parser.
84    pub aliases: Vec<String>,
85    /// Whether the command runs but is hidden from help, tree, and search.
86    pub hidden: bool,
87    /// Backend/system id used in output metadata and generic error envelopes.
88    pub system: Option<String>,
89    /// Default comma-separated field projection.
90    pub default_fields: Option<String>,
91    /// Whether the command bypasses credential resolution.
92    pub no_auth: bool,
93    /// Auth provider name for this command.
94    pub auth_provider: Option<String>,
95    /// Risk tier used by authentication, authorization, and dry-run.
96    pub tier: Option<Tier>,
97    /// Explicit dry-run prompt marker for commands without a tier.
98    pub mutates: bool,
99    /// Provider-specific auth metadata.
100    pub auth_metadata: BTreeMap<String, String>,
101    /// Command-specific `clap` arguments.
102    pub args: Vec<Arg>,
103    /// Optional output schema published through `--schema` and help.
104    pub output_schema: Option<SchemaInfo>,
105}
106
107impl CommandSpec {
108    /// Creates a command spec with the required name and one-line help.
109    #[must_use]
110    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
111        Self {
112            name: name.into(),
113            short: short.into(),
114            ..Self::default()
115        }
116    }
117
118    /// Sets expanded command help.
119    #[must_use]
120    pub fn with_long(mut self, long: impl Into<String>) -> Self {
121        self.long = Some(long.into());
122        self
123    }
124
125    /// Adds one command alias.
126    #[must_use]
127    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
128        self.aliases.push(alias.into());
129        self
130    }
131
132    /// Hides or shows this command in discovery output.
133    #[must_use]
134    pub fn hidden(mut self, hidden: bool) -> Self {
135        self.hidden = hidden;
136        self
137    }
138
139    /// Sets the backend/system id for output metadata and error attribution.
140    #[must_use]
141    pub fn with_system(mut self, system: impl Into<String>) -> Self {
142        self.system = Some(system.into());
143        self
144    }
145
146    /// Sets the default field projection used when `--fields` is absent.
147    #[must_use]
148    pub fn with_default_fields(mut self, default_fields: impl Into<String>) -> Self {
149        self.default_fields = Some(default_fields.into());
150        self
151    }
152
153    /// Selects the auth provider for this command.
154    #[must_use]
155    pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {
156        self.auth_provider = Some(provider.into());
157        self
158    }
159
160    /// Marks the command as no-auth.
161    #[must_use]
162    pub fn no_auth(mut self, no_auth: bool) -> Self {
163        self.no_auth = no_auth;
164        self
165    }
166
167    /// Sets the command risk tier.
168    #[must_use]
169    pub fn with_tier(mut self, tier: Tier) -> Self {
170        self.tier = Some(tier);
171        self
172    }
173
174    /// Adds provider-specific auth metadata.
175    #[must_use]
176    pub fn with_auth_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
177        self.auth_metadata.insert(key.into(), value.into());
178        self
179    }
180
181    /// Adds a `clap` argument or option to this command.
182    #[must_use]
183    pub fn with_arg(mut self, arg: Arg) -> Self {
184        self.args.push(arg);
185        self
186    }
187
188    /// Adds a `clap` flag or option to this command.
189    #[must_use]
190    pub fn with_flag(self, flag: Arg) -> Self {
191        self.with_arg(flag)
192    }
193
194    /// Registers a compact framework schema from an [`OutputSchema`] type.
195    #[must_use]
196    pub fn with_output_schema<T: OutputSchema>(mut self) -> Self {
197        self.output_schema = Some(SchemaInfo {
198            command: String::new(),
199            fields: crate::output::fields_for::<T>(),
200            schema: None,
201        });
202        self
203    }
204
205    /// Registers JSON Schema generated from a Rust type with `schemars`.
206    #[must_use]
207    pub fn with_json_schema<T: JsonSchema>(mut self) -> Self {
208        self.output_schema = Some(crate::output::json_schema_info::<T>(""));
209        self
210    }
211
212    /// Marks whether the command should short-circuit under `--dry-run`.
213    #[must_use]
214    pub fn mutates(mut self, mutates: bool) -> Self {
215        self.mutates = mutates;
216        self
217    }
218
219    /// Builds middleware metadata from the spec.
220    #[must_use]
221    pub fn metadata(&self) -> CommandMeta {
222        let mut auth_metadata = self.auth_metadata.clone();
223        if let Some(provider) = &self.auth_provider
224            && !provider.is_empty()
225        {
226            auth_metadata.insert("provider".to_owned(), provider.clone());
227        }
228        if let Some(tier) = self.tier
229            && !auth_metadata.contains_key("tier")
230        {
231            auth_metadata.insert("tier".to_owned(), tier.to_string());
232        }
233        let scopes = auth_metadata
234            .get("scopes")
235            .map(|scopes| {
236                scopes
237                    .split_whitespace()
238                    .map(str::to_owned)
239                    .collect::<Vec<_>>()
240            })
241            .unwrap_or_default();
242
243        CommandMeta {
244            dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating),
245            auth_metadata,
246            scopes,
247        }
248    }
249
250    /// Builds the `clap` command for parser registration.
251    #[must_use]
252    pub fn clap_command(&self) -> Command {
253        let mut command = Command::new(self.name.clone()).about(self.short.clone());
254        if let Some(long) = &self.long
255            && !long.is_empty()
256        {
257            command = command.long_about(long.clone());
258        }
259        for alias in &self.aliases {
260            command = command.alias(alias.clone());
261        }
262        if self.hidden {
263            command = command.hide(true);
264        }
265        for arg in &self.args {
266            command = command.arg(arg.clone());
267        }
268        command
269    }
270}
271
272/// Declarative command group metadata.
273///
274/// Groups are noun-based containers. They do not run business logic directly;
275/// when invoked bare, the CLI renders group help.
276#[derive(Clone, Debug, Default)]
277pub struct GroupSpec {
278    /// Group command name.
279    pub name: String,
280    /// One-line group description.
281    pub short: String,
282    /// Optional long help text.
283    pub long: Option<String>,
284    /// Alternate group names accepted by the parser.
285    pub aliases: Vec<String>,
286    /// Whether the group runs but is hidden from discovery output.
287    pub hidden: bool,
288    /// Declarative child commands used for static tree construction.
289    pub commands: Vec<CommandSpec>,
290    /// Declarative nested groups used for static tree construction.
291    pub groups: Vec<GroupSpec>,
292}
293
294impl GroupSpec {
295    /// Creates a command group with the required name and one-line help.
296    #[must_use]
297    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
298        Self {
299            name: name.into(),
300            short: short.into(),
301            ..Self::default()
302        }
303    }
304
305    /// Sets expanded group help.
306    #[must_use]
307    pub fn with_long(mut self, long: impl Into<String>) -> Self {
308        self.long = Some(long.into());
309        self
310    }
311
312    /// Adds one group alias.
313    #[must_use]
314    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
315        self.aliases.push(alias.into());
316        self
317    }
318
319    /// Hides or shows this group in discovery output.
320    #[must_use]
321    pub fn hidden(mut self, hidden: bool) -> Self {
322        self.hidden = hidden;
323        self
324    }
325
326    /// Adds one declarative child command.
327    #[must_use]
328    pub fn with_command(mut self, command: CommandSpec) -> Self {
329        self.commands.push(command);
330        self
331    }
332
333    /// Adds one declarative nested group.
334    #[must_use]
335    pub fn with_group(mut self, group: GroupSpec) -> Self {
336        self.groups.push(group);
337        self
338    }
339
340    /// Builds the `clap` command for parser registration.
341    #[must_use]
342    pub fn clap_command(&self) -> Command {
343        let mut command = Command::new(self.name.clone()).about(self.short.clone());
344        if let Some(long) = &self.long
345            && !long.is_empty()
346        {
347            command = command.long_about(long.clone());
348        }
349        for alias in &self.aliases {
350            command = command.alias(alias.clone());
351        }
352        if self.hidden {
353            command = command.hide(true);
354        }
355        for group in &self.groups {
356            command = command.subcommand(group.clap_command());
357        }
358        for child in &self.commands {
359            command = command.subcommand(child.clap_command());
360        }
361        command
362    }
363}
364
365/// Executable leaf command.
366///
367/// `RuntimeCommandSpec` pairs a [`CommandSpec`] with async business logic.
368/// This split keeps metadata inspectable for help/search/schema generation
369/// before the handler ever runs.
370#[derive(Clone)]
371pub struct RuntimeCommandSpec {
372    /// Declarative command metadata.
373    pub spec: CommandSpec,
374    /// Async command implementation.
375    pub handler: CommandHandler,
376}
377
378impl std::fmt::Debug for RuntimeCommandSpec {
379    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        formatter
381            .debug_struct("RuntimeCommandSpec")
382            .field("spec", &self.spec)
383            .finish_non_exhaustive()
384    }
385}
386
387impl RuntimeCommandSpec {
388    /// Creates a runtime command with the common handler shape.
389    ///
390    /// The handler receives the optional credential and effective args. It
391    /// returns [`CommandResult`], where `data` must be JSON-serializable.
392    #[must_use]
393    pub fn new<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
394    where
395        F: Fn(Option<Credential>, ValueMap) -> Fut + Send + Sync + 'static,
396        Fut: Future<Output = Result<Output>> + Send + 'static,
397        Output: Into<CommandResult> + Send + 'static,
398    {
399        Self {
400            spec,
401            handler: Arc::new(move |context| {
402                let future = handler(context.credential, context.args);
403                Box::pin(async move { future.await.map(Into::into) })
404            }),
405        }
406    }
407
408    /// Creates a runtime command with the full invocation context.
409    #[must_use]
410    pub fn new_with_context<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
411    where
412        F: Fn(CommandContext) -> Fut + Send + Sync + 'static,
413        Fut: Future<Output = Result<Output>> + Send + 'static,
414        Output: Into<CommandResult> + Send + 'static,
415    {
416        Self {
417            spec,
418            handler: Arc::new(move |context| {
419                let future = handler(context);
420                Box::pin(async move { future.await.map(Into::into) })
421            }),
422        }
423    }
424}
425
426/// Executable command group with runtime children.
427#[derive(Clone, Debug, Default)]
428pub struct RuntimeGroupSpec {
429    /// Declarative group metadata.
430    pub group: GroupSpec,
431    /// Executable leaf commands under this group.
432    pub commands: Vec<RuntimeCommandSpec>,
433    /// Executable nested groups under this group.
434    pub groups: Vec<RuntimeGroupSpec>,
435}
436
437impl RuntimeGroupSpec {
438    /// Creates a runtime group from declarative group metadata.
439    #[must_use]
440    pub fn new(group: GroupSpec) -> Self {
441        Self {
442            group,
443            ..Self::default()
444        }
445    }
446
447    /// Adds one executable leaf command.
448    #[must_use]
449    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
450        self.commands.push(command);
451        self
452    }
453
454    /// Adds one executable nested group.
455    #[must_use]
456    pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self {
457        self.groups.push(group);
458        self
459    }
460
461    /// Builds the `clap` command for parser registration.
462    #[must_use]
463    pub fn clap_command(&self) -> Command {
464        let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone());
465        if let Some(long) = &self.group.long
466            && !long.is_empty()
467        {
468            command = command.long_about(long.clone());
469        }
470        for alias in &self.group.aliases {
471            command = command.alias(alias.clone());
472        }
473        if self.group.hidden {
474            command = command.hide(true);
475        }
476        for group in &self.groups {
477            command = command.subcommand(group.clap_command());
478        }
479        for child in &self.commands {
480            command = command.subcommand(child.spec.clap_command());
481        }
482        command
483    }
484
485    pub(crate) fn register_commands(
486        &self,
487        prefix: &mut Vec<String>,
488        out: &mut BTreeMap<String, RuntimeCommandSpec>,
489    ) {
490        prefix.push(self.group.name.clone());
491        for group in &self.groups {
492            group.register_commands(prefix, out);
493        }
494        for command in &self.commands {
495            prefix.push(command.spec.name.clone());
496            out.insert(prefix.join(":"), command.clone());
497            prefix.pop();
498        }
499        prefix.pop();
500    }
501}
502
503/// Extracts the colon-separated command path from parsed `clap` matches.
504#[must_use]
505pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String {
506    let mut parts = Vec::new();
507    let mut current = matches;
508    while let Some((name, submatches)) = current.subcommand() {
509        if name != root_name {
510            parts.push(name.to_owned());
511        }
512        current = submatches;
513    }
514    parts.join(":")
515}
516
517/// Builds a colon-separated command path from path parts.
518///
519/// The optional annotation is used only for isolated single-command tests.
520#[must_use]
521pub fn command_path_from_parts(parts: &[impl AsRef<str>], path_annotation: Option<&str>) -> String {
522    if parts.is_empty() {
523        return String::new();
524    }
525    if parts.len() > 1 {
526        return parts[1..]
527            .iter()
528            .map(AsRef::as_ref)
529            .collect::<Vec<_>>()
530            .join(":");
531    }
532    path_annotation
533        .filter(|annotation| !annotation.is_empty())
534        .map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned)
535}
536
537/// Returns the deepest subcommand matches.
538#[must_use]
539pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches {
540    let mut current = matches;
541    while let Some((_, submatches)) = current.subcommand() {
542        current = submatches;
543    }
544    current
545}
546
547/// Converts parsed command arguments into the JSON-ish map consumed by middleware.
548///
549/// When `changed_only` is true, only arguments that came from the command line
550/// are included. This is the user-args map used by authz and audit.
551#[must_use]
552pub fn command_args_from_matches(
553    matches: &ArgMatches,
554    spec: &CommandSpec,
555    changed_only: bool,
556) -> ValueMap {
557    let mut args = ValueMap::new();
558    for arg in &spec.args {
559        let id = arg.get_id().to_string();
560        let changed = matches
561            .value_source(&id)
562            .is_some_and(|source| source == clap::parser::ValueSource::CommandLine);
563        if changed_only && !changed {
564            continue;
565        }
566        if let Some(value) = arg_value_from_matches(matches, arg, &id) {
567            args.insert(id, value);
568        }
569    }
570    args
571}
572
573fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option<Value> {
574    matches.value_source(id)?;
575
576    if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
577        && let Some(value) = matches.get_one::<bool>(id)
578    {
579        return Some(Value::Bool(*value));
580    }
581
582    if let Some(value) = typed_arg_value_from_matches(matches, id) {
583        return Some(value);
584    }
585
586    if let Some(values) = matches.get_raw(id) {
587        let rendered = values
588            .map(|value| value.to_string_lossy().into_owned())
589            .collect::<Vec<_>>();
590        return match rendered.as_slice() {
591            [] => None,
592            [single] => Some(Value::String(single.clone())),
593            _ => Some(Value::Array(
594                rendered.into_iter().map(Value::String).collect(),
595            )),
596        };
597    }
598
599    if let Some(value) = matches.get_one::<String>(id) {
600        return Some(Value::String(value.clone()));
601    }
602    if let Some(value) = matches.get_one::<usize>(id) {
603        return Some(serde_json::json!(value));
604    }
605    if let Some(value) = matches.get_one::<u64>(id) {
606        return Some(serde_json::json!(value));
607    }
608    if let Some(value) = matches.get_one::<i64>(id) {
609        return Some(serde_json::json!(value));
610    }
611    None
612}
613
614fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option<Value> {
615    typed_values::<bool>(matches, id, Value::Bool)
616        .or_else(|| typed_values::<i8>(matches, id, |value| Value::Number(value.into())))
617        .or_else(|| typed_values::<i16>(matches, id, |value| Value::Number(value.into())))
618        .or_else(|| typed_values::<i64>(matches, id, |value| Value::Number(value.into())))
619        .or_else(|| typed_values::<i32>(matches, id, |value| Value::Number(value.into())))
620        .or_else(|| typed_values::<u8>(matches, id, |value| Value::Number(value.into())))
621        .or_else(|| typed_values::<u16>(matches, id, |value| Value::Number(value.into())))
622        .or_else(|| typed_values::<u64>(matches, id, |value| Value::Number(value.into())))
623        .or_else(|| typed_values::<u32>(matches, id, |value| Value::Number(value.into())))
624        .or_else(|| {
625            typed_values::<usize>(matches, id, |value| {
626                u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into()))
627            })
628        })
629        .or_else(|| {
630            typed_values::<f64>(matches, id, |value| {
631                Number::from_f64(value).map_or(Value::Null, Value::Number)
632            })
633        })
634        .or_else(|| {
635            typed_values::<f32>(matches, id, |value| {
636                Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number)
637            })
638        })
639        .or_else(|| typed_values::<String>(matches, id, Value::String))
640}
641
642fn typed_values<T>(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option<Value>
643where
644    T: Clone + Send + Sync + 'static,
645{
646    let Ok(Some(values)) = matches.try_get_many::<T>(id) else {
647        return None;
648    };
649    let values = values.cloned().map(to_value).collect::<Vec<_>>();
650    match values.as_slice() {
651        [] => None,
652        [single] => Some(single.clone()),
653        _ => Some(Value::Array(values)),
654    }
655}