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;