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