Skip to main content

osp_cli/core/
command_def.rs

1//! Declarative command metadata shared by help, completion, and plugin layers.
2//!
3//! This module exists to describe commands in a neutral in-memory form before
4//! any one presentation or transport layer gets involved. Help rendering,
5//! completion tree building, and plugin describe payloads can all consume the
6//! same structure instead of each inventing their own command model.
7//!
8//! In broad terms:
9//!
10//! - [`crate::core::command_def::CommandDef`] describes one command node plus
11//!   nested subcommands
12//! - [`crate::core::command_def::ArgDef`] and
13//!   [`crate::core::command_def::FlagDef`] describe the user-facing invocation
14//!   surface
15//! - [`crate::core::command_def::CommandPolicyDef`] carries the coarse
16//!   visibility/auth requirements that
17//!   travel with a command definition
18//!
19//! Contract:
20//!
21//! - this module owns declarative command shape, not runtime dispatch
22//! - the types here should stay presentation-neutral and broadly reusable
23//! - richer runtime policy evaluation lives in
24//!   [`crate::core::command_policy`], not here
25
26use crate::core::command_policy::VisibilityMode;
27
28/// Declarative command description used for help, completion, and plugin metadata.
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct CommandDef {
31    /// Canonical command name shown in the command path.
32    pub name: String,
33    /// Short summary used in compact help output.
34    pub about: Option<String>,
35    /// Expanded description used in detailed help output.
36    pub long_about: Option<String>,
37    /// Explicit usage line when generated usage should be overridden.
38    pub usage: Option<String>,
39    /// Text inserted before generated help content.
40    pub before_help: Option<String>,
41    /// Text appended after generated help content.
42    pub after_help: Option<String>,
43    /// Alternate visible names accepted for the command.
44    pub aliases: Vec<String>,
45    /// Whether the command should be hidden from generated discovery output.
46    pub hidden: bool,
47    /// Optional presentation key used to order sibling commands.
48    pub sort_key: Option<String>,
49    /// Policy metadata that controls command visibility and availability.
50    pub policy: CommandPolicyDef,
51    /// Positional arguments accepted by the command.
52    pub args: Vec<ArgDef>,
53    /// Flags and options accepted by the command.
54    pub flags: Vec<FlagDef>,
55    /// Nested subcommands exposed below this command.
56    pub subcommands: Vec<CommandDef>,
57}
58
59/// Simplified policy description attached to a [`CommandDef`].
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CommandPolicyDef {
62    /// Visibility mode applied to the command.
63    pub visibility: VisibilityMode,
64    /// Capabilities required to execute or reveal the command.
65    pub required_capabilities: Vec<String>,
66    /// Feature flags that must be enabled for the command to exist.
67    pub feature_flags: Vec<String>,
68}
69
70impl Default for CommandPolicyDef {
71    fn default() -> Self {
72        Self {
73            visibility: VisibilityMode::Public,
74            required_capabilities: Vec::new(),
75            feature_flags: Vec::new(),
76        }
77    }
78}
79
80impl CommandPolicyDef {
81    /// Returns `true` when the policy matches the default public,
82    /// unrestricted state.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use osp_cli::core::command_def::CommandPolicyDef;
88    /// use osp_cli::core::command_policy::VisibilityMode;
89    ///
90    /// assert!(CommandPolicyDef::default().is_empty());
91    /// assert!(!CommandPolicyDef {
92    ///     visibility: VisibilityMode::Authenticated,
93    ///     required_capabilities: Vec::new(),
94    ///     feature_flags: Vec::new(),
95    /// }
96    /// .is_empty());
97    /// ```
98    pub fn is_empty(&self) -> bool {
99        self.visibility == VisibilityMode::Public
100            && self.required_capabilities.is_empty()
101            && self.feature_flags.is_empty()
102    }
103}
104
105/// Positional argument definition for a command.
106#[derive(Debug, Clone, PartialEq, Eq, Default)]
107pub struct ArgDef {
108    /// Stable identifier for the argument.
109    pub id: String,
110    /// Placeholder shown for the argument value in help text.
111    pub value_name: Option<String>,
112    /// Help text shown for the argument.
113    pub help: Option<String>,
114    /// Optional help section heading for the argument.
115    pub help_heading: Option<String>,
116    /// Whether the argument must be supplied.
117    pub required: bool,
118    /// Whether the argument accepts multiple values.
119    pub multi: bool,
120    /// Semantic hint for completions and UI presentation.
121    pub value_kind: Option<ValueKind>,
122    /// Enumerated values suggested for the argument.
123    pub choices: Vec<ValueChoice>,
124    /// Default values applied when the argument is omitted.
125    pub defaults: Vec<String>,
126}
127
128/// Flag or option definition for a command.
129#[derive(Debug, Clone, PartialEq, Eq, Default)]
130pub struct FlagDef {
131    /// Stable identifier for the flag or option.
132    pub id: String,
133    /// Single-character short form without the leading `-`.
134    pub short: Option<char>,
135    /// Long form without the leading `--`.
136    pub long: Option<String>,
137    /// Alternate visible spellings accepted for the flag.
138    pub aliases: Vec<String>,
139    /// Help text shown for the flag.
140    pub help: Option<String>,
141    /// Optional help section heading for the flag.
142    pub help_heading: Option<String>,
143    /// Whether the flag consumes a value.
144    pub takes_value: bool,
145    /// Placeholder shown for the flag value in help text.
146    pub value_name: Option<String>,
147    /// Whether the flag must be supplied.
148    pub required: bool,
149    /// Whether the flag accepts multiple values or occurrences.
150    pub multi: bool,
151    /// Whether the flag should be hidden from generated discovery output.
152    pub hidden: bool,
153    /// Semantic hint for the flag value.
154    pub value_kind: Option<ValueKind>,
155    /// Enumerated values suggested for the flag.
156    pub choices: Vec<ValueChoice>,
157    /// Default values applied when the flag is omitted.
158    pub defaults: Vec<String>,
159}
160
161/// Semantic type hint for argument and option values.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ValueKind {
164    /// Filesystem path input.
165    Path,
166    /// Value chosen from a fixed set of named options.
167    Enum,
168    /// Unstructured text input.
169    FreeText,
170}
171
172/// Suggested value for an argument or flag.
173#[derive(Debug, Clone, PartialEq, Eq, Default)]
174pub struct ValueChoice {
175    /// Underlying value passed to the command.
176    pub value: String,
177    /// Help text describing when to use this value.
178    pub help: Option<String>,
179    /// Alternate label shown instead of the raw value.
180    pub display: Option<String>,
181    /// Optional presentation key used to order sibling values.
182    pub sort_key: Option<String>,
183}
184
185impl CommandDef {
186    /// Creates a command definition with the provided command name.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use osp_cli::core::command_def::{ArgDef, CommandDef, FlagDef, ValueChoice, ValueKind};
192    ///
193    /// let command = CommandDef::new("theme")
194    ///     .about("Inspect themes")
195    ///     .arg(
196    ///         ArgDef::new("name")
197    ///             .help("Theme name")
198    ///             .value_kind(ValueKind::Enum)
199    ///             .choices([
200    ///                 ValueChoice::new("dracula"),
201    ///                 ValueChoice::new("tokyonight"),
202    ///             ]),
203    ///     )
204    ///     .flag(FlagDef::new("raw").long("raw").help("Show raw values"))
205    ///     .subcommand(CommandDef::new("list").about("List available themes"));
206    ///
207    /// assert_eq!(command.name, "theme");
208    /// assert_eq!(command.args[0].choices.len(), 2);
209    /// assert_eq!(command.flags[0].long.as_deref(), Some("raw"));
210    /// assert_eq!(command.subcommands[0].name, "list");
211    /// ```
212    pub fn new(name: impl Into<String>) -> Self {
213        Self {
214            name: name.into(),
215            ..Self::default()
216        }
217    }
218
219    /// Sets the short help text and returns the updated definition.
220    pub fn about(mut self, about: impl Into<String>) -> Self {
221        self.about = Some(about.into());
222        self
223    }
224
225    /// Sets the long help text and returns the updated definition.
226    pub fn long_about(mut self, long_about: impl Into<String>) -> Self {
227        self.long_about = Some(long_about.into());
228        self
229    }
230
231    /// Sets the explicit usage line and returns the updated definition.
232    pub fn usage(mut self, usage: impl Into<String>) -> Self {
233        self.usage = Some(usage.into());
234        self
235    }
236
237    /// Sets text shown before generated help output.
238    pub fn before_help(mut self, text: impl Into<String>) -> Self {
239        self.before_help = Some(text.into());
240        self
241    }
242
243    /// Sets text shown after generated help output.
244    pub fn after_help(mut self, text: impl Into<String>) -> Self {
245        self.after_help = Some(text.into());
246        self
247    }
248
249    /// Appends a visible alias and returns the updated definition.
250    pub fn alias(mut self, alias: impl Into<String>) -> Self {
251        self.aliases.push(alias.into());
252        self
253    }
254
255    /// Appends multiple visible aliases and returns the updated definition.
256    pub fn aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
257        self.aliases.extend(aliases.into_iter().map(Into::into));
258        self
259    }
260
261    /// Marks the command as hidden from generated help and discovery output.
262    pub fn hidden(mut self) -> Self {
263        self.hidden = true;
264        self
265    }
266
267    /// Sets a sort key used when presenting the command alongside peers.
268    pub fn sort(mut self, sort_key: impl Into<String>) -> Self {
269        self.sort_key = Some(sort_key.into());
270        self
271    }
272
273    /// Replaces the command policy metadata.
274    pub fn policy(mut self, policy: CommandPolicyDef) -> Self {
275        self.policy = policy;
276        self
277    }
278
279    /// Appends a positional argument definition.
280    pub fn arg(mut self, arg: ArgDef) -> Self {
281        self.args.push(arg);
282        self
283    }
284
285    /// Appends multiple positional argument definitions.
286    pub fn args(mut self, args: impl IntoIterator<Item = ArgDef>) -> Self {
287        self.args.extend(args);
288        self
289    }
290
291    /// Appends a flag definition.
292    pub fn flag(mut self, flag: FlagDef) -> Self {
293        self.flags.push(flag);
294        self
295    }
296
297    /// Appends multiple flag definitions.
298    pub fn flags(mut self, flags: impl IntoIterator<Item = FlagDef>) -> Self {
299        self.flags.extend(flags);
300        self
301    }
302
303    /// Appends a nested subcommand definition.
304    pub fn subcommand(mut self, subcommand: CommandDef) -> Self {
305        self.subcommands.push(subcommand);
306        self
307    }
308
309    /// Appends multiple nested subcommand definitions.
310    pub fn subcommands(mut self, subcommands: impl IntoIterator<Item = CommandDef>) -> Self {
311        self.subcommands.extend(subcommands);
312        self
313    }
314}
315
316impl ArgDef {
317    /// Creates a positional argument definition with the provided identifier.
318    pub fn new(id: impl Into<String>) -> Self {
319        Self {
320            id: id.into(),
321            ..Self::default()
322        }
323    }
324
325    /// Sets the displayed value name for the argument.
326    pub fn value_name(mut self, value_name: impl Into<String>) -> Self {
327        self.value_name = Some(value_name.into());
328        self
329    }
330
331    /// Sets the help text for the argument.
332    pub fn help(mut self, help: impl Into<String>) -> Self {
333        self.help = Some(help.into());
334        self
335    }
336
337    /// Marks the argument as required.
338    pub fn required(mut self) -> Self {
339        self.required = true;
340        self
341    }
342
343    /// Marks the argument as accepting multiple values.
344    pub fn multi(mut self) -> Self {
345        self.multi = true;
346        self
347    }
348
349    /// Sets the semantic value kind for the argument.
350    pub fn value_kind(mut self, value_kind: ValueKind) -> Self {
351        self.value_kind = Some(value_kind);
352        self
353    }
354
355    /// Appends supported value choices for the argument.
356    pub fn choices(mut self, choices: impl IntoIterator<Item = ValueChoice>) -> Self {
357        self.choices.extend(choices);
358        self
359    }
360
361    /// Appends default values for the argument.
362    pub fn defaults(mut self, defaults: impl IntoIterator<Item = impl Into<String>>) -> Self {
363        self.defaults.extend(defaults.into_iter().map(Into::into));
364        self
365    }
366}
367
368impl FlagDef {
369    /// Creates a flag definition with the provided identifier.
370    pub fn new(id: impl Into<String>) -> Self {
371        Self {
372            id: id.into(),
373            ..Self::default()
374        }
375    }
376
377    /// Sets the short option name.
378    pub fn short(mut self, short: char) -> Self {
379        self.short = Some(short);
380        self
381    }
382
383    /// Sets the long option name without the leading `--`.
384    pub fn long(mut self, long: impl Into<String>) -> Self {
385        self.long = Some(long.into());
386        self
387    }
388
389    /// Appends an alias name for this flag.
390    pub fn alias(mut self, alias: impl Into<String>) -> Self {
391        self.aliases.push(alias.into());
392        self
393    }
394
395    /// Appends multiple alias names for this flag.
396    pub fn aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
397        self.aliases.extend(aliases.into_iter().map(Into::into));
398        self
399    }
400
401    /// Sets the help text for the flag.
402    pub fn help(mut self, help: impl Into<String>) -> Self {
403        self.help = Some(help.into());
404        self
405    }
406
407    /// Marks the flag as taking a value and sets its displayed value name.
408    pub fn takes_value(mut self, value_name: impl Into<String>) -> Self {
409        self.takes_value = true;
410        self.value_name = Some(value_name.into());
411        self
412    }
413
414    /// Marks the flag as required.
415    pub fn required(mut self) -> Self {
416        self.required = true;
417        self
418    }
419
420    /// Marks the flag as accepting multiple values or occurrences.
421    pub fn multi(mut self) -> Self {
422        self.multi = true;
423        self
424    }
425
426    /// Marks the flag as hidden from generated help and discovery output.
427    pub fn hidden(mut self) -> Self {
428        self.hidden = true;
429        self
430    }
431
432    /// Sets the semantic value kind for the flag's value.
433    pub fn value_kind(mut self, value_kind: ValueKind) -> Self {
434        self.value_kind = Some(value_kind);
435        self
436    }
437
438    /// Appends supported value choices for the flag.
439    pub fn choices(mut self, choices: impl IntoIterator<Item = ValueChoice>) -> Self {
440        self.choices.extend(choices);
441        self
442    }
443
444    /// Appends default values for the flag.
445    pub fn defaults(mut self, defaults: impl IntoIterator<Item = impl Into<String>>) -> Self {
446        self.defaults.extend(defaults.into_iter().map(Into::into));
447        self
448    }
449
450    /// Marks the flag as not taking a value and clears any stored value name.
451    pub fn takes_no_value(mut self) -> Self {
452        self.takes_value = false;
453        self.value_name = None;
454        self
455    }
456}
457
458impl ValueChoice {
459    /// Creates a suggested value entry.
460    ///
461    /// # Examples
462    ///
463    /// ```
464    /// use osp_cli::core::command_def::ValueChoice;
465    ///
466    /// let choice = ValueChoice::new("dracula")
467    ///     .help("Dark theme")
468    ///     .display("Dracula")
469    ///     .sort("010");
470    ///
471    /// assert_eq!(choice.value, "dracula");
472    /// assert_eq!(choice.help.as_deref(), Some("Dark theme"));
473    /// assert_eq!(choice.display.as_deref(), Some("Dracula"));
474    /// assert_eq!(choice.sort_key.as_deref(), Some("010"));
475    /// ```
476    pub fn new(value: impl Into<String>) -> Self {
477        Self {
478            value: value.into(),
479            ..Self::default()
480        }
481    }
482
483    /// Sets the help text associated with this suggested value.
484    pub fn help(mut self, help: impl Into<String>) -> Self {
485        self.help = Some(help.into());
486        self
487    }
488
489    /// Sets the display label shown for this suggested value.
490    pub fn display(mut self, display: impl Into<String>) -> Self {
491        self.display = Some(display.into());
492        self
493    }
494
495    /// Sets the presentation sort key for this suggested value.
496    pub fn sort(mut self, sort_key: impl Into<String>) -> Self {
497        self.sort_key = Some(sort_key.into());
498        self
499    }
500}
501
502#[cfg(feature = "clap")]
503impl CommandDef {
504    /// Converts a `clap` command tree into a [`CommandDef`] tree.
505    pub fn from_clap(command: clap::Command) -> Self {
506        clap_command_to_def(command)
507    }
508}
509
510#[cfg(feature = "clap")]
511fn clap_command_to_def(command: clap::Command) -> CommandDef {
512    let mut usage_command = command.clone();
513    let usage = normalize_usage_line(usage_command.render_usage().to_string());
514
515    CommandDef {
516        name: command.get_name().to_string(),
517        about: styled_to_plain(command.get_about()),
518        long_about: styled_to_plain(command.get_long_about()),
519        usage,
520        before_help: styled_to_plain(
521            command
522                .get_before_long_help()
523                .or_else(|| command.get_before_help()),
524        ),
525        after_help: styled_to_plain(
526            command
527                .get_after_long_help()
528                .or_else(|| command.get_after_help()),
529        ),
530        aliases: command
531            .get_visible_aliases()
532            .map(ToString::to_string)
533            .collect(),
534        hidden: command.is_hide_set(),
535        sort_key: None,
536        policy: CommandPolicyDef::default(),
537        args: command
538            .get_positionals()
539            .filter(|arg| !arg.is_hide_set())
540            .map(arg_def_from_clap)
541            .collect(),
542        flags: command
543            .get_arguments()
544            .filter(|arg| !arg.is_positional() && !arg.is_hide_set())
545            .map(flag_def_from_clap)
546            .collect(),
547        subcommands: command
548            .get_subcommands()
549            .filter(|subcommand| !subcommand.is_hide_set())
550            .map(|subcommand| clap_command_to_def(subcommand.clone()))
551            .collect(),
552    }
553}
554
555#[cfg(feature = "clap")]
556fn arg_def_from_clap(arg: &clap::Arg) -> ArgDef {
557    ArgDef {
558        id: arg.get_id().as_str().to_string(),
559        value_name: arg
560            .get_value_names()
561            .and_then(|names| names.first())
562            .map(ToString::to_string),
563        help: styled_to_plain(arg.get_long_help().or_else(|| arg.get_help())),
564        help_heading: arg.get_help_heading().map(ToString::to_string),
565        required: arg.is_required_set(),
566        multi: arg.get_num_args().is_some_and(range_is_multiple)
567            || matches!(arg.get_action(), clap::ArgAction::Append),
568        value_kind: value_kind_from_hint(arg.get_value_hint()),
569        choices: arg
570            .get_possible_values()
571            .into_iter()
572            .filter(|value| !value.is_hide_set())
573            .map(|value| {
574                let mut choice = ValueChoice::new(value.get_name());
575                if let Some(help) = value.get_help() {
576                    choice = choice.help(help.to_string());
577                }
578                choice
579            })
580            .collect(),
581        defaults: arg
582            .get_default_values()
583            .iter()
584            .map(|value| value.to_string_lossy().to_string())
585            .collect(),
586    }
587}
588
589#[cfg(feature = "clap")]
590fn flag_def_from_clap(arg: &clap::Arg) -> FlagDef {
591    let aliases = arg
592        .get_long_and_visible_aliases()
593        .into_iter()
594        .flatten()
595        .filter(|alias| Some(*alias) != arg.get_long())
596        .map(|alias| format!("--{alias}"))
597        .chain(
598            arg.get_short_and_visible_aliases()
599                .into_iter()
600                .flatten()
601                .filter(|alias| Some(*alias) != arg.get_short())
602                .map(|alias| format!("-{alias}")),
603        )
604        .collect::<Vec<_>>();
605
606    FlagDef {
607        id: arg.get_id().as_str().to_string(),
608        short: arg.get_short(),
609        long: arg.get_long().map(ToString::to_string),
610        aliases,
611        help: styled_to_plain(arg.get_long_help().or_else(|| arg.get_help())),
612        help_heading: arg.get_help_heading().map(ToString::to_string),
613        takes_value: arg.get_action().takes_values(),
614        value_name: arg
615            .get_value_names()
616            .and_then(|names| names.first())
617            .map(ToString::to_string),
618        required: arg.is_required_set(),
619        multi: arg.get_num_args().is_some_and(range_is_multiple)
620            || matches!(arg.get_action(), clap::ArgAction::Append),
621        hidden: arg.is_hide_set(),
622        value_kind: value_kind_from_hint(arg.get_value_hint()),
623        choices: arg
624            .get_possible_values()
625            .into_iter()
626            .filter(|value| !value.is_hide_set())
627            .map(|value| {
628                let mut choice = ValueChoice::new(value.get_name());
629                if let Some(help) = value.get_help() {
630                    choice = choice.help(help.to_string());
631                }
632                choice
633            })
634            .collect(),
635        defaults: arg
636            .get_default_values()
637            .iter()
638            .map(|value| value.to_string_lossy().to_string())
639            .collect(),
640    }
641}
642
643#[cfg(feature = "clap")]
644fn styled_to_plain(value: Option<&clap::builder::StyledStr>) -> Option<String> {
645    value
646        .map(ToString::to_string)
647        .map(|text| text.trim().to_string())
648        .filter(|text| !text.is_empty())
649}
650
651#[cfg(feature = "clap")]
652fn range_is_multiple(range: clap::builder::ValueRange) -> bool {
653    range.min_values() > 1 || range.max_values() > 1
654}
655
656#[cfg(feature = "clap")]
657fn value_kind_from_hint(hint: clap::ValueHint) -> Option<ValueKind> {
658    match hint {
659        clap::ValueHint::AnyPath
660        | clap::ValueHint::FilePath
661        | clap::ValueHint::DirPath
662        | clap::ValueHint::ExecutablePath => Some(ValueKind::Path),
663        _ => None,
664    }
665}
666
667#[cfg(feature = "clap")]
668fn normalize_usage_line(value: String) -> Option<String> {
669    value
670        .trim()
671        .strip_prefix("Usage:")
672        .map(str::trim)
673        .filter(|usage| !usage.is_empty())
674        .map(ToString::to_string)
675}
676
677#[cfg(test)]
678mod tests;