Skip to main content

osp_cli/core/
plugin.rs

1//! Wire-format DTOs for the plugin protocol.
2//!
3//! This module exists to define the stable boundary between `osp-cli` and
4//! external plugins. The app and plugin manager can evolve internally, but the
5//! JSON shapes in this module are the contract that both sides need to agree
6//! on.
7//!
8//! In broad terms:
9//!
10//! - `Describe*` types advertise commands, arguments, and policy metadata
11//! - `Response*` types carry execution results, messages, and render hints
12//! - validation helpers reject protocol-shape errors before higher-level code
13//!   tries to trust the payload
14//!
15//! Contract:
16//!
17//! - these types may depend on shared `core` metadata, but they should stay
18//!   free of host runtime concerns
19//! - any parsing/validation here should enforce protocol rules, not business
20//!   policy
21//! - caller-facing docs should describe stable wire behavior rather than
22//!   internal plugin manager details
23
24use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28use crate::core::command_def::{
29    ArgDef, CommandDef, CommandPolicyDef, FlagDef, ValueChoice, ValueKind,
30};
31use crate::core::command_policy::{CommandPath, CommandPolicy, VisibilityMode};
32
33/// Current plugin wire protocol version understood by this crate.
34pub const PLUGIN_PROTOCOL_V1: u32 = 1;
35
36/// `describe` payload emitted by a plugin that speaks protocol v1.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct DescribeV1 {
39    /// Protocol version declared by the plugin.
40    pub protocol_version: u32,
41    /// Stable plugin identifier.
42    pub plugin_id: String,
43    /// Plugin version string.
44    pub plugin_version: String,
45    /// Minimum `osp-cli` version required by the plugin, if any.
46    pub min_osp_version: Option<String>,
47    /// Top-level commands exported by the plugin.
48    pub commands: Vec<DescribeCommandV1>,
49}
50
51/// Recursive command description used in plugin metadata.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DescribeCommandV1 {
54    /// Command name exposed by the plugin.
55    pub name: String,
56    #[serde(default)]
57    /// Short help text for the command.
58    pub about: String,
59    #[serde(default)]
60    /// Optional authorization metadata for the command.
61    pub auth: Option<DescribeCommandAuthV1>,
62    #[serde(default)]
63    /// Positional argument descriptions in declaration order.
64    pub args: Vec<DescribeArgV1>,
65    #[serde(default)]
66    /// Flag descriptions keyed by protocol flag spelling.
67    pub flags: BTreeMap<String, DescribeFlagV1>,
68    #[serde(default)]
69    /// Nested subcommands under this command.
70    pub subcommands: Vec<DescribeCommandV1>,
71}
72
73/// Authorization metadata attached to a described command.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct DescribeCommandAuthV1 {
76    #[serde(default)]
77    /// Visibility level for the command.
78    pub visibility: Option<DescribeVisibilityModeV1>,
79    #[serde(default)]
80    /// Capabilities required to run the command.
81    pub required_capabilities: Vec<String>,
82    #[serde(default)]
83    /// Feature flags that must be enabled for the command.
84    pub feature_flags: Vec<String>,
85}
86
87/// Wire-format visibility mode used by plugin metadata.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum DescribeVisibilityModeV1 {
91    /// Command is visible and runnable without authentication.
92    Public,
93    /// Command requires an authenticated user.
94    Authenticated,
95    /// Command requires one or more capabilities.
96    CapabilityGated,
97    /// Command should be hidden from normal help surfaces.
98    Hidden,
99}
100
101impl DescribeVisibilityModeV1 {
102    /// Converts the protocol visibility label into the internal policy enum.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use osp_cli::core::command_policy::VisibilityMode;
108    /// use osp_cli::core::plugin::DescribeVisibilityModeV1;
109    ///
110    /// assert_eq!(
111    ///     DescribeVisibilityModeV1::CapabilityGated.as_visibility_mode(),
112    ///     VisibilityMode::CapabilityGated
113    /// );
114    /// ```
115    pub fn as_visibility_mode(self) -> VisibilityMode {
116        match self {
117            DescribeVisibilityModeV1::Public => VisibilityMode::Public,
118            DescribeVisibilityModeV1::Authenticated => VisibilityMode::Authenticated,
119            DescribeVisibilityModeV1::CapabilityGated => VisibilityMode::CapabilityGated,
120            DescribeVisibilityModeV1::Hidden => VisibilityMode::Hidden,
121        }
122    }
123
124    /// Returns the canonical protocol label for this visibility mode.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use osp_cli::core::plugin::DescribeVisibilityModeV1;
130    ///
131    /// assert_eq!(DescribeVisibilityModeV1::Hidden.as_label(), "hidden");
132    /// ```
133    pub fn as_label(self) -> &'static str {
134        match self {
135            DescribeVisibilityModeV1::Public => "public",
136            DescribeVisibilityModeV1::Authenticated => "authenticated",
137            DescribeVisibilityModeV1::CapabilityGated => "capability_gated",
138            DescribeVisibilityModeV1::Hidden => "hidden",
139        }
140    }
141}
142
143impl DescribeCommandAuthV1 {
144    /// Returns a compact help hint for non-default auth requirements.
145    ///
146    /// This is meant for help and completion surfaces where the full policy
147    /// object would be too noisy.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use osp_cli::core::plugin::{DescribeCommandAuthV1, DescribeVisibilityModeV1};
153    ///
154    /// let auth = DescribeCommandAuthV1 {
155    ///     visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
156    ///     required_capabilities: vec!["ldap.write".to_string()],
157    ///     feature_flags: vec!["write-mode".to_string()],
158    /// };
159    ///
160    /// assert_eq!(
161    ///     auth.hint().as_deref(),
162    ///     Some("cap: ldap.write; feature: write-mode")
163    /// );
164    /// ```
165    pub fn hint(&self) -> Option<String> {
166        let mut parts = Vec::new();
167
168        match self.visibility {
169            Some(DescribeVisibilityModeV1::Public) | None => {}
170            Some(DescribeVisibilityModeV1::Authenticated) => parts.push("auth".to_string()),
171            Some(DescribeVisibilityModeV1::CapabilityGated) => {
172                if self.required_capabilities.len() == 1 {
173                    parts.push(format!("cap: {}", self.required_capabilities[0]));
174                } else if self.required_capabilities.is_empty() {
175                    parts.push("cap".to_string());
176                } else {
177                    parts.push(format!("caps: {}", self.required_capabilities.len()));
178                }
179            }
180            Some(DescribeVisibilityModeV1::Hidden) => parts.push("hidden".to_string()),
181        }
182
183        match self.feature_flags.as_slice() {
184            [] => {}
185            [feature] => parts.push(format!("feature: {feature}")),
186            features => parts.push(format!("features: {}", features.len())),
187        }
188
189        (!parts.is_empty()).then(|| parts.join("; "))
190    }
191}
192
193/// Wire-format type hint for plugin argument values.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum DescribeValueTypeV1 {
197    /// Value represents a filesystem path.
198    Path,
199}
200
201/// Suggested value emitted in plugin metadata.
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct DescribeSuggestionV1 {
204    /// Raw suggestion value inserted into the command line.
205    pub value: String,
206    #[serde(default)]
207    /// Optional short metadata string.
208    pub meta: Option<String>,
209    #[serde(default)]
210    /// Optional display label for menu rendering.
211    pub display: Option<String>,
212    #[serde(default)]
213    /// Optional sort key used for ordering suggestions.
214    pub sort: Option<String>,
215}
216
217/// Positional argument description emitted by a plugin.
218#[derive(Debug, Clone, Default, Serialize, Deserialize)]
219pub struct DescribeArgV1 {
220    #[serde(default)]
221    /// Positional name or value label.
222    pub name: Option<String>,
223    #[serde(default)]
224    /// Short help text for the argument.
225    pub about: Option<String>,
226    #[serde(default)]
227    /// Whether the argument may be repeated.
228    pub multi: bool,
229    #[serde(default)]
230    /// Optional wire-format value type hint.
231    pub value_type: Option<DescribeValueTypeV1>,
232    #[serde(default)]
233    /// Suggested values for the argument.
234    pub suggestions: Vec<DescribeSuggestionV1>,
235}
236
237/// Flag description emitted by a plugin.
238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
239pub struct DescribeFlagV1 {
240    #[serde(default)]
241    /// Short help text for the flag.
242    pub about: Option<String>,
243    #[serde(default)]
244    /// Whether the flag is boolean-only and takes no value.
245    pub flag_only: bool,
246    #[serde(default)]
247    /// Whether the flag may be repeated.
248    pub multi: bool,
249    #[serde(default)]
250    /// Optional wire-format value type hint.
251    pub value_type: Option<DescribeValueTypeV1>,
252    #[serde(default)]
253    /// Suggested values for the flag.
254    pub suggestions: Vec<DescribeSuggestionV1>,
255}
256
257/// Protocol v1 command response envelope.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ResponseV1 {
260    /// Protocol version declared by the response.
261    pub protocol_version: u32,
262    /// Whether the command completed successfully.
263    pub ok: bool,
264    /// Response payload produced by the plugin.
265    pub data: serde_json::Value,
266    /// Structured error payload present when `ok` is `false`.
267    pub error: Option<ResponseErrorV1>,
268    #[serde(default)]
269    /// User-facing messages emitted alongside the payload.
270    pub messages: Vec<ResponseMessageV1>,
271    /// Rendering and presentation metadata for the payload.
272    pub meta: ResponseMetaV1,
273}
274
275/// Structured error payload returned when `ok` is `false`.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ResponseErrorV1 {
278    /// Stable machine-readable error code.
279    pub code: String,
280    /// Human-readable error message.
281    pub message: String,
282    #[serde(default)]
283    /// Arbitrary structured error details.
284    pub details: serde_json::Value,
285}
286
287/// Rendering hints attached to a plugin response.
288#[derive(Debug, Clone, Serialize, Deserialize, Default)]
289pub struct ResponseMetaV1 {
290    /// Preferred output format for rendering the payload.
291    pub format_hint: Option<String>,
292    /// Preferred column order for row-based payloads.
293    pub columns: Option<Vec<String>>,
294    #[serde(default)]
295    /// Preferred alignment hints for displayed columns.
296    pub column_align: Vec<ColumnAlignmentV1>,
297}
298
299/// Column alignment hint used in plugin response metadata.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
301#[serde(rename_all = "lowercase")]
302pub enum ColumnAlignmentV1 {
303    /// Use the renderer's default alignment.
304    #[default]
305    Default,
306    /// Left-align the column.
307    Left,
308    /// Center-align the column.
309    Center,
310    /// Right-align the column.
311    Right,
312}
313
314/// Message severity carried in plugin responses.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum ResponseMessageLevelV1 {
318    /// Error-level message.
319    Error,
320    /// Warning-level message.
321    Warning,
322    /// Success-level message.
323    Success,
324    /// Informational message.
325    Info,
326    /// Trace or debug-style message.
327    Trace,
328}
329
330/// User-facing message emitted alongside a plugin response.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ResponseMessageV1 {
333    /// Severity level for the message.
334    pub level: ResponseMessageLevelV1,
335    /// Human-readable message text.
336    pub text: String,
337}
338
339impl DescribeV1 {
340    #[cfg(feature = "clap")]
341    /// Builds a v1 describe payload from a single `clap` command tree.
342    pub fn from_clap_command(
343        plugin_id: impl Into<String>,
344        plugin_version: impl Into<String>,
345        min_osp_version: Option<String>,
346        command: clap::Command,
347    ) -> Self {
348        Self::from_clap_commands(
349            plugin_id,
350            plugin_version,
351            min_osp_version,
352            std::iter::once(command),
353        )
354    }
355
356    #[cfg(feature = "clap")]
357    /// Builds a v1 describe payload from multiple top-level `clap` commands.
358    pub fn from_clap_commands(
359        plugin_id: impl Into<String>,
360        plugin_version: impl Into<String>,
361        min_osp_version: Option<String>,
362        commands: impl IntoIterator<Item = clap::Command>,
363    ) -> Self {
364        Self {
365            protocol_version: PLUGIN_PROTOCOL_V1,
366            plugin_id: plugin_id.into(),
367            plugin_version: plugin_version.into(),
368            min_osp_version,
369            commands: commands
370                .into_iter()
371                .map(CommandDef::from_clap)
372                .map(|command| DescribeCommandV1::from(&command))
373                .collect(),
374        }
375    }
376
377    /// Validates the describe payload and returns an error string on protocol violations.
378    pub fn validate_v1(&self) -> Result<(), String> {
379        if self.protocol_version != PLUGIN_PROTOCOL_V1 {
380            return Err(format!(
381                "unsupported describe protocol version: {}",
382                self.protocol_version
383            ));
384        }
385        if self.plugin_id.trim().is_empty() {
386            return Err("plugin_id must not be empty".to_string());
387        }
388        for command in &self.commands {
389            validate_command(command)?;
390        }
391        Ok(())
392    }
393}
394
395impl DescribeCommandV1 {
396    /// Converts command auth metadata into an internal command policy for `path`.
397    pub fn command_policy(&self, path: CommandPath) -> Option<CommandPolicy> {
398        let auth = self.auth.as_ref()?;
399        let mut policy = CommandPolicy::new(path);
400        if let Some(visibility) = auth.visibility {
401            policy = policy.visibility(visibility.as_visibility_mode());
402        }
403        for capability in &auth.required_capabilities {
404            policy = policy.require_capability(capability.clone());
405        }
406        for feature in &auth.feature_flags {
407            policy = policy.feature_flag(feature.clone());
408        }
409        Some(policy)
410    }
411}
412
413impl ResponseV1 {
414    /// Validates the response envelope before the app trusts its payload.
415    ///
416    /// # Examples
417    ///
418    /// ```
419    /// use osp_cli::core::plugin::{ResponseMetaV1, ResponseV1};
420    /// use serde_json::json;
421    ///
422    /// let response = ResponseV1 {
423    ///     protocol_version: 1,
424    ///     ok: true,
425    ///     data: json!({"uid": "alice"}),
426    ///     error: None,
427    ///     messages: Vec::new(),
428    ///     meta: ResponseMetaV1::default(),
429    /// };
430    ///
431    /// assert!(response.validate_v1().is_ok());
432    /// ```
433    pub fn validate_v1(&self) -> Result<(), String> {
434        if self.protocol_version != PLUGIN_PROTOCOL_V1 {
435            return Err(format!(
436                "unsupported response protocol version: {}",
437                self.protocol_version
438            ));
439        }
440        if self.ok && self.error.is_some() {
441            return Err("ok=true requires error=null".to_string());
442        }
443        if !self.ok && self.error.is_none() {
444            return Err("ok=false requires error payload".to_string());
445        }
446        if self
447            .messages
448            .iter()
449            .any(|message| message.text.trim().is_empty())
450        {
451            return Err("response messages must not contain empty text".to_string());
452        }
453        Ok(())
454    }
455}
456
457#[cfg(feature = "clap")]
458impl DescribeCommandV1 {
459    /// Converts a `clap` command into a protocol v1 command description.
460    pub fn from_clap(command: clap::Command) -> Self {
461        Self::from(&CommandDef::from_clap(command))
462    }
463}
464
465impl From<&CommandDef> for DescribeCommandV1 {
466    fn from(command: &CommandDef) -> Self {
467        Self {
468            name: command.name.clone(),
469            about: command.about.clone().unwrap_or_default(),
470            auth: (!command.policy.is_empty()).then(|| DescribeCommandAuthV1 {
471                visibility: match command.policy.visibility {
472                    VisibilityMode::Public => None,
473                    VisibilityMode::Authenticated => Some(DescribeVisibilityModeV1::Authenticated),
474                    VisibilityMode::CapabilityGated => {
475                        Some(DescribeVisibilityModeV1::CapabilityGated)
476                    }
477                    VisibilityMode::Hidden => Some(DescribeVisibilityModeV1::Hidden),
478                },
479                required_capabilities: command.policy.required_capabilities.clone(),
480                feature_flags: command.policy.feature_flags.clone(),
481            }),
482            args: command.args.iter().map(DescribeArgV1::from).collect(),
483            flags: command
484                .flags
485                .iter()
486                .flat_map(describe_flag_entries)
487                .collect(),
488            subcommands: command
489                .subcommands
490                .iter()
491                .map(DescribeCommandV1::from)
492                .collect(),
493        }
494    }
495}
496
497impl From<&DescribeCommandV1> for CommandDef {
498    fn from(command: &DescribeCommandV1) -> Self {
499        Self {
500            name: command.name.clone(),
501            about: (!command.about.trim().is_empty()).then(|| command.about.clone()),
502            long_about: None,
503            usage: None,
504            before_help: None,
505            after_help: None,
506            aliases: Vec::new(),
507            hidden: matches!(
508                command.auth.as_ref().and_then(|auth| auth.visibility),
509                Some(DescribeVisibilityModeV1::Hidden)
510            ),
511            sort_key: None,
512            policy: command
513                .auth
514                .as_ref()
515                .map(command_policy_from_describe)
516                .unwrap_or_default(),
517            args: command.args.iter().map(ArgDef::from).collect(),
518            flags: collect_describe_flags(&command.flags),
519            subcommands: command.subcommands.iter().map(CommandDef::from).collect(),
520        }
521    }
522}
523
524impl From<&ArgDef> for DescribeArgV1 {
525    fn from(arg: &ArgDef) -> Self {
526        Self {
527            name: arg.value_name.clone().or_else(|| Some(arg.id.clone())),
528            about: arg.help.clone(),
529            multi: arg.multi,
530            value_type: describe_value_type(arg.value_kind),
531            suggestions: arg.choices.iter().map(DescribeSuggestionV1::from).collect(),
532        }
533    }
534}
535
536impl From<&FlagDef> for DescribeFlagV1 {
537    fn from(flag: &FlagDef) -> Self {
538        Self {
539            about: flag.help.clone(),
540            flag_only: !flag.takes_value,
541            multi: flag.multi,
542            value_type: describe_value_type(flag.value_kind),
543            suggestions: flag
544                .choices
545                .iter()
546                .map(DescribeSuggestionV1::from)
547                .collect(),
548        }
549    }
550}
551
552impl From<&DescribeArgV1> for ArgDef {
553    fn from(arg: &DescribeArgV1) -> Self {
554        let mut def = ArgDef::new(arg.name.clone().unwrap_or_else(|| "value".to_string()));
555        if let Some(value_name) = &arg.name {
556            def = def.value_name(value_name.clone());
557        }
558        if let Some(help) = &arg.about {
559            def = def.help(help.clone());
560        }
561        if arg.multi {
562            def = def.multi();
563        }
564        if let Some(value_kind) = command_value_kind(arg.value_type) {
565            def = def.value_kind(value_kind);
566        }
567        def.choices(arg.suggestions.iter().map(ValueChoice::from))
568    }
569}
570
571impl From<&DescribeFlagV1> for FlagDef {
572    fn from(flag: &DescribeFlagV1) -> Self {
573        let mut def = FlagDef::new("flag");
574        if let Some(help) = &flag.about {
575            def = def.help(help.clone());
576        }
577        if !flag.flag_only {
578            def = def.takes_value("value");
579        }
580        if flag.multi {
581            def = def.multi();
582        }
583        if let Some(value_kind) = command_value_kind(flag.value_type) {
584            def = def.value_kind(value_kind);
585        }
586        def.choices(flag.suggestions.iter().map(ValueChoice::from))
587    }
588}
589
590impl From<&ValueChoice> for DescribeSuggestionV1 {
591    fn from(choice: &ValueChoice) -> Self {
592        Self {
593            value: choice.value.clone(),
594            meta: choice.help.clone(),
595            display: choice.display.clone(),
596            sort: choice.sort_key.clone(),
597        }
598    }
599}
600
601impl From<&DescribeSuggestionV1> for ValueChoice {
602    fn from(entry: &DescribeSuggestionV1) -> Self {
603        Self {
604            value: entry.value.clone(),
605            help: entry.meta.clone(),
606            display: entry.display.clone(),
607            sort_key: entry.sort.clone(),
608        }
609    }
610}
611
612fn validate_command(command: &DescribeCommandV1) -> Result<(), String> {
613    if command.name.trim().is_empty() {
614        return Err("command name must not be empty".to_string());
615    }
616    if let Some(auth) = &command.auth {
617        validate_command_auth(auth)?;
618    }
619
620    for (name, flag) in &command.flags {
621        if !name.starts_with('-') {
622            return Err(format!("flag `{name}` must start with `-`"));
623        }
624        validate_suggestions(&flag.suggestions, &format!("flag `{name}`"))?;
625    }
626
627    for arg in &command.args {
628        validate_suggestions(&arg.suggestions, "argument")?;
629    }
630
631    for subcommand in &command.subcommands {
632        validate_command(subcommand)?;
633    }
634
635    Ok(())
636}
637
638fn validate_suggestions(suggestions: &[DescribeSuggestionV1], owner: &str) -> Result<(), String> {
639    if suggestions
640        .iter()
641        .any(|entry| entry.value.trim().is_empty())
642    {
643        return Err(format!("{owner} suggestions must not contain empty values"));
644    }
645    Ok(())
646}
647
648fn validate_command_auth(auth: &DescribeCommandAuthV1) -> Result<(), String> {
649    if auth
650        .required_capabilities
651        .iter()
652        .any(|value| value.trim().is_empty())
653    {
654        return Err("required_capabilities must not contain empty values".to_string());
655    }
656    if auth
657        .feature_flags
658        .iter()
659        .any(|value| value.trim().is_empty())
660    {
661        return Err("feature_flags must not contain empty values".to_string());
662    }
663    Ok(())
664}
665
666fn describe_flag_entries(flag: &FlagDef) -> Vec<(String, DescribeFlagV1)> {
667    let value = DescribeFlagV1::from(flag);
668    let mut names = Vec::new();
669    if let Some(long) = flag.long.as_deref() {
670        names.push(format!("--{long}"));
671    }
672    if let Some(short) = flag.short {
673        names.push(format!("-{short}"));
674    }
675    names.extend(flag.aliases.iter().cloned());
676    names
677        .into_iter()
678        .map(|name| (name, value.clone()))
679        .collect()
680}
681
682fn group_describe_flag((name, flag): (&String, &DescribeFlagV1)) -> Option<FlagDef> {
683    if !name.starts_with('-') {
684        return None;
685    }
686
687    let mut def = FlagDef::from(flag);
688    if let Some(long) = name.strip_prefix("--") {
689        def.long = Some(long.to_string());
690        def.id = long.to_string();
691    } else if let Some(short) = name.strip_prefix('-') {
692        def.short = short.chars().next();
693        def.id = short.to_string();
694    }
695    Some(def)
696}
697
698fn collect_describe_flags(flags: &BTreeMap<String, DescribeFlagV1>) -> Vec<FlagDef> {
699    let mut grouped: BTreeMap<String, Vec<(&String, &DescribeFlagV1)>> = BTreeMap::new();
700    for entry in flags.iter() {
701        let signature = serde_json::to_string(entry.1).unwrap_or_default();
702        grouped.entry(signature).or_default().push(entry);
703    }
704
705    grouped
706        .into_values()
707        .filter_map(|group| {
708            let mut iter = group.into_iter();
709            let first = iter.next()?;
710            let mut def = group_describe_flag(first)?;
711            for (name, _) in iter {
712                if let Some(long) = name.strip_prefix("--") {
713                    if def.long.is_none() {
714                        def.long = Some(long.to_string());
715                        if def.id == "flag" {
716                            def.id = long.to_string();
717                        }
718                    } else if Some(long) != def.long.as_deref() {
719                        def.aliases.push(format!("--{long}"));
720                    }
721                } else if let Some(short) = name.strip_prefix('-') {
722                    let short_char = short.chars().next();
723                    if def.short.is_none() {
724                        def.short = short_char;
725                        if def.id == "flag" {
726                            def.id = short.to_string();
727                        }
728                    } else if short_char != def.short {
729                        def.aliases.push(format!("-{short}"));
730                    }
731                }
732            }
733            Some(def)
734        })
735        .collect()
736}
737
738fn command_policy_from_describe(auth: &DescribeCommandAuthV1) -> CommandPolicyDef {
739    CommandPolicyDef {
740        visibility: match auth.visibility {
741            Some(DescribeVisibilityModeV1::Authenticated) => VisibilityMode::Authenticated,
742            Some(DescribeVisibilityModeV1::CapabilityGated) => VisibilityMode::CapabilityGated,
743            Some(DescribeVisibilityModeV1::Hidden) => VisibilityMode::Hidden,
744            Some(DescribeVisibilityModeV1::Public) | None => VisibilityMode::Public,
745        },
746        required_capabilities: auth.required_capabilities.clone(),
747        feature_flags: auth.feature_flags.clone(),
748    }
749}
750
751fn describe_value_type(value_kind: Option<ValueKind>) -> Option<DescribeValueTypeV1> {
752    match value_kind {
753        Some(ValueKind::Path) => Some(DescribeValueTypeV1::Path),
754        Some(ValueKind::Enum | ValueKind::FreeText) | None => None,
755    }
756}
757
758fn command_value_kind(value_type: Option<DescribeValueTypeV1>) -> Option<ValueKind> {
759    value_type.map(|_| ValueKind::Path)
760}
761
762#[cfg(test)]
763mod tests {
764    use std::collections::BTreeMap;
765
766    use super::{
767        DescribeCommandAuthV1, DescribeCommandV1, DescribeVisibilityModeV1, validate_command_auth,
768    };
769    use crate::core::command_policy::{CommandPath, VisibilityMode};
770
771    #[test]
772    fn command_auth_converts_to_generic_command_policy_unit() {
773        let command = DescribeCommandV1 {
774            name: "orch".to_string(),
775            about: String::new(),
776            auth: Some(DescribeCommandAuthV1 {
777                visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
778                required_capabilities: vec!["orch.approval.decide".to_string()],
779                feature_flags: vec!["orch".to_string()],
780            }),
781            args: Vec::new(),
782            flags: BTreeMap::new(),
783            subcommands: Vec::new(),
784        };
785
786        let policy = command
787            .command_policy(CommandPath::new(["orch", "approval", "decide"]))
788            .expect("auth metadata should build a policy");
789        assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
790        assert!(
791            policy
792                .required_capabilities
793                .contains("orch.approval.decide")
794        );
795        assert!(policy.feature_flags.contains("orch"));
796    }
797
798    #[test]
799    fn command_auth_validation_rejects_blank_entries_unit() {
800        let err = validate_command_auth(&DescribeCommandAuthV1 {
801            visibility: None,
802            required_capabilities: vec![" ".to_string()],
803            feature_flags: Vec::new(),
804        })
805        .expect_err("blank capabilities should be rejected");
806        assert!(err.contains("required_capabilities"));
807    }
808
809    #[test]
810    fn command_auth_hint_stays_compact_and_stable_unit() {
811        let auth = DescribeCommandAuthV1 {
812            visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
813            required_capabilities: vec!["orch.approval.decide".to_string()],
814            feature_flags: vec!["orch".to_string()],
815        };
816        assert_eq!(
817            auth.hint().as_deref(),
818            Some("cap: orch.approval.decide; feature: orch")
819        );
820        assert_eq!(
821            DescribeVisibilityModeV1::Authenticated.as_label(),
822            "authenticated"
823        );
824    }
825}
826
827#[cfg(all(test, feature = "clap"))]
828mod clap_tests {
829    use super::{DescribeCommandV1, DescribeV1, DescribeValueTypeV1};
830    use clap::{Arg, ArgAction, Command, ValueHint};
831
832    #[test]
833    fn clap_helper_captures_subcommands_flags_and_args() {
834        let command = Command::new("ldap").about("LDAP plugin").subcommand(
835            Command::new("user")
836                .about("Lookup LDAP users")
837                .arg(Arg::new("uid").help("User id"))
838                .arg(
839                    Arg::new("attributes")
840                        .long("attributes")
841                        .short('a')
842                        .help("Attributes to fetch")
843                        .action(ArgAction::Set)
844                        .value_parser(["uid", "cn", "mail"]),
845                )
846                .arg(
847                    Arg::new("input")
848                        .long("input")
849                        .help("Read from file")
850                        .value_hint(ValueHint::FilePath),
851                ),
852        );
853
854        let describe =
855            DescribeV1::from_clap_command("ldap", "0.1.0", Some("0.1.0".to_string()), command);
856
857        assert_eq!(describe.commands.len(), 1);
858        let ldap = &describe.commands[0];
859        assert_eq!(ldap.name, "ldap");
860        assert_eq!(ldap.subcommands.len(), 1);
861
862        let user = &ldap.subcommands[0];
863        assert_eq!(user.name, "user");
864        assert_eq!(user.args[0].name.as_deref(), Some("uid"));
865        assert!(user.flags.contains_key("--attributes"));
866        assert!(user.flags.contains_key("-a"));
867        assert_eq!(
868            user.flags["--attributes"]
869                .suggestions
870                .iter()
871                .map(|entry| entry.value.as_str())
872                .collect::<Vec<_>>(),
873            vec!["uid", "cn", "mail"]
874        );
875        assert_eq!(
876            user.flags["--input"].value_type,
877            Some(DescribeValueTypeV1::Path)
878        );
879    }
880
881    #[test]
882    fn clap_command_conversion_skips_hidden_items() {
883        let command = Command::new("ldap")
884            .subcommand(Command::new("visible"))
885            .subcommand(Command::new("hidden").hide(true))
886            .arg(Arg::new("secret").long("secret").hide(true));
887
888        let describe = DescribeCommandV1::from_clap(command);
889
890        assert_eq!(
891            describe
892                .subcommands
893                .iter()
894                .map(|subcommand| subcommand.name.as_str())
895                .collect::<Vec<_>>(),
896            vec!["visible"]
897        );
898        assert!(!describe.flags.contains_key("--secret"));
899    }
900}