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