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