Skip to main content

osp_cli/core/
plugin.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::core::command_policy::{CommandPath, CommandPolicy, VisibilityMode};
6
7pub const PLUGIN_PROTOCOL_V1: u32 = 1;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DescribeV1 {
11    pub protocol_version: u32,
12    pub plugin_id: String,
13    pub plugin_version: String,
14    pub min_osp_version: Option<String>,
15    pub commands: Vec<DescribeCommandV1>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DescribeCommandV1 {
20    pub name: String,
21    #[serde(default)]
22    pub about: String,
23    #[serde(default)]
24    pub auth: Option<DescribeCommandAuthV1>,
25    #[serde(default)]
26    pub args: Vec<DescribeArgV1>,
27    #[serde(default)]
28    pub flags: BTreeMap<String, DescribeFlagV1>,
29    #[serde(default)]
30    pub subcommands: Vec<DescribeCommandV1>,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct DescribeCommandAuthV1 {
35    #[serde(default)]
36    pub visibility: Option<DescribeVisibilityModeV1>,
37    #[serde(default)]
38    pub required_capabilities: Vec<String>,
39    #[serde(default)]
40    pub feature_flags: Vec<String>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum DescribeVisibilityModeV1 {
46    Public,
47    Authenticated,
48    CapabilityGated,
49    Hidden,
50}
51
52impl DescribeVisibilityModeV1 {
53    pub fn as_visibility_mode(self) -> VisibilityMode {
54        match self {
55            DescribeVisibilityModeV1::Public => VisibilityMode::Public,
56            DescribeVisibilityModeV1::Authenticated => VisibilityMode::Authenticated,
57            DescribeVisibilityModeV1::CapabilityGated => VisibilityMode::CapabilityGated,
58            DescribeVisibilityModeV1::Hidden => VisibilityMode::Hidden,
59        }
60    }
61
62    pub fn as_label(self) -> &'static str {
63        match self {
64            DescribeVisibilityModeV1::Public => "public",
65            DescribeVisibilityModeV1::Authenticated => "authenticated",
66            DescribeVisibilityModeV1::CapabilityGated => "capability_gated",
67            DescribeVisibilityModeV1::Hidden => "hidden",
68        }
69    }
70}
71
72impl DescribeCommandAuthV1 {
73    pub fn hint(&self) -> Option<String> {
74        let mut parts = Vec::new();
75
76        match self.visibility {
77            Some(DescribeVisibilityModeV1::Public) | None => {}
78            Some(DescribeVisibilityModeV1::Authenticated) => parts.push("auth".to_string()),
79            Some(DescribeVisibilityModeV1::CapabilityGated) => {
80                if self.required_capabilities.len() == 1 {
81                    parts.push(format!("cap: {}", self.required_capabilities[0]));
82                } else if self.required_capabilities.is_empty() {
83                    parts.push("cap".to_string());
84                } else {
85                    parts.push(format!("caps: {}", self.required_capabilities.len()));
86                }
87            }
88            Some(DescribeVisibilityModeV1::Hidden) => parts.push("hidden".to_string()),
89        }
90
91        match self.feature_flags.as_slice() {
92            [] => {}
93            [feature] => parts.push(format!("feature: {feature}")),
94            features => parts.push(format!("features: {}", features.len())),
95        }
96
97        (!parts.is_empty()).then(|| parts.join("; "))
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum DescribeValueTypeV1 {
104    Path,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct DescribeSuggestionV1 {
109    pub value: String,
110    #[serde(default)]
111    pub meta: Option<String>,
112    #[serde(default)]
113    pub display: Option<String>,
114    #[serde(default)]
115    pub sort: Option<String>,
116}
117
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119pub struct DescribeArgV1 {
120    #[serde(default)]
121    pub name: Option<String>,
122    #[serde(default)]
123    pub about: Option<String>,
124    #[serde(default)]
125    pub multi: bool,
126    #[serde(default)]
127    pub value_type: Option<DescribeValueTypeV1>,
128    #[serde(default)]
129    pub suggestions: Vec<DescribeSuggestionV1>,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct DescribeFlagV1 {
134    #[serde(default)]
135    pub about: Option<String>,
136    #[serde(default)]
137    pub flag_only: bool,
138    #[serde(default)]
139    pub multi: bool,
140    #[serde(default)]
141    pub value_type: Option<DescribeValueTypeV1>,
142    #[serde(default)]
143    pub suggestions: Vec<DescribeSuggestionV1>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ResponseV1 {
148    pub protocol_version: u32,
149    pub ok: bool,
150    pub data: serde_json::Value,
151    pub error: Option<ResponseErrorV1>,
152    #[serde(default)]
153    pub messages: Vec<ResponseMessageV1>,
154    pub meta: ResponseMetaV1,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ResponseErrorV1 {
159    pub code: String,
160    pub message: String,
161    #[serde(default)]
162    pub details: serde_json::Value,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166pub struct ResponseMetaV1 {
167    pub format_hint: Option<String>,
168    pub columns: Option<Vec<String>>,
169    #[serde(default)]
170    pub column_align: Vec<ColumnAlignmentV1>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
174#[serde(rename_all = "lowercase")]
175pub enum ColumnAlignmentV1 {
176    #[default]
177    Default,
178    Left,
179    Center,
180    Right,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "lowercase")]
185pub enum ResponseMessageLevelV1 {
186    Error,
187    Warning,
188    Success,
189    Info,
190    Trace,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct ResponseMessageV1 {
195    pub level: ResponseMessageLevelV1,
196    pub text: String,
197}
198
199impl DescribeV1 {
200    #[cfg(feature = "clap")]
201    pub fn from_clap_command(
202        plugin_id: impl Into<String>,
203        plugin_version: impl Into<String>,
204        min_osp_version: Option<String>,
205        command: clap::Command,
206    ) -> Self {
207        Self::from_clap_commands(
208            plugin_id,
209            plugin_version,
210            min_osp_version,
211            std::iter::once(command),
212        )
213    }
214
215    #[cfg(feature = "clap")]
216    pub fn from_clap_commands(
217        plugin_id: impl Into<String>,
218        plugin_version: impl Into<String>,
219        min_osp_version: Option<String>,
220        commands: impl IntoIterator<Item = clap::Command>,
221    ) -> Self {
222        Self {
223            protocol_version: PLUGIN_PROTOCOL_V1,
224            plugin_id: plugin_id.into(),
225            plugin_version: plugin_version.into(),
226            min_osp_version,
227            commands: commands
228                .into_iter()
229                .map(DescribeCommandV1::from_clap)
230                .collect(),
231        }
232    }
233
234    pub fn validate_v1(&self) -> Result<(), String> {
235        if self.protocol_version != PLUGIN_PROTOCOL_V1 {
236            return Err(format!(
237                "unsupported describe protocol version: {}",
238                self.protocol_version
239            ));
240        }
241        if self.plugin_id.trim().is_empty() {
242            return Err("plugin_id must not be empty".to_string());
243        }
244        for command in &self.commands {
245            validate_command(command)?;
246        }
247        Ok(())
248    }
249}
250
251impl DescribeCommandV1 {
252    pub fn command_policy(&self, path: CommandPath) -> Option<CommandPolicy> {
253        let auth = self.auth.as_ref()?;
254        let mut policy = CommandPolicy::new(path);
255        if let Some(visibility) = auth.visibility {
256            policy = policy.visibility(visibility.as_visibility_mode());
257        }
258        for capability in &auth.required_capabilities {
259            policy = policy.require_capability(capability.clone());
260        }
261        for feature in &auth.feature_flags {
262            policy = policy.feature_flag(feature.clone());
263        }
264        Some(policy)
265    }
266}
267
268impl ResponseV1 {
269    pub fn validate_v1(&self) -> Result<(), String> {
270        if self.protocol_version != PLUGIN_PROTOCOL_V1 {
271            return Err(format!(
272                "unsupported response protocol version: {}",
273                self.protocol_version
274            ));
275        }
276        if self.ok && self.error.is_some() {
277            return Err("ok=true requires error=null".to_string());
278        }
279        if !self.ok && self.error.is_none() {
280            return Err("ok=false requires error payload".to_string());
281        }
282        if self
283            .messages
284            .iter()
285            .any(|message| message.text.trim().is_empty())
286        {
287            return Err("response messages must not contain empty text".to_string());
288        }
289        Ok(())
290    }
291}
292
293#[cfg(feature = "clap")]
294impl DescribeCommandV1 {
295    pub fn from_clap(command: clap::Command) -> Self {
296        describe_command_from_clap(command)
297    }
298}
299
300fn validate_command(command: &DescribeCommandV1) -> Result<(), String> {
301    if command.name.trim().is_empty() {
302        return Err("command name must not be empty".to_string());
303    }
304    if let Some(auth) = &command.auth {
305        validate_command_auth(auth)?;
306    }
307
308    for (name, flag) in &command.flags {
309        if !name.starts_with('-') {
310            return Err(format!("flag `{name}` must start with `-`"));
311        }
312        validate_suggestions(&flag.suggestions, &format!("flag `{name}`"))?;
313    }
314
315    for arg in &command.args {
316        validate_suggestions(&arg.suggestions, "argument")?;
317    }
318
319    for subcommand in &command.subcommands {
320        validate_command(subcommand)?;
321    }
322
323    Ok(())
324}
325
326fn validate_suggestions(suggestions: &[DescribeSuggestionV1], owner: &str) -> Result<(), String> {
327    if suggestions
328        .iter()
329        .any(|entry| entry.value.trim().is_empty())
330    {
331        return Err(format!("{owner} suggestions must not contain empty values"));
332    }
333    Ok(())
334}
335
336fn validate_command_auth(auth: &DescribeCommandAuthV1) -> Result<(), String> {
337    if auth
338        .required_capabilities
339        .iter()
340        .any(|value| value.trim().is_empty())
341    {
342        return Err("required_capabilities must not contain empty values".to_string());
343    }
344    if auth
345        .feature_flags
346        .iter()
347        .any(|value| value.trim().is_empty())
348    {
349        return Err("feature_flags must not contain empty values".to_string());
350    }
351    Ok(())
352}
353
354#[cfg(feature = "clap")]
355fn describe_command_from_clap(command: clap::Command) -> DescribeCommandV1 {
356    let positionals = command
357        .get_positionals()
358        .filter(|arg| !arg.is_hide_set())
359        .map(describe_arg_from_clap)
360        .collect::<Vec<_>>();
361
362    let mut flags = BTreeMap::new();
363    for arg in command.get_arguments().filter(|arg| !arg.is_positional()) {
364        if arg.is_hide_set() {
365            continue;
366        }
367        let flag = describe_flag_from_clap(arg);
368        for name in visible_flag_names(arg) {
369            flags.insert(name, flag.clone());
370        }
371    }
372
373    DescribeCommandV1 {
374        name: command.get_name().to_string(),
375        about: styled_to_plain(command.get_about()),
376        auth: None,
377        args: positionals,
378        flags,
379        subcommands: command
380            .get_subcommands()
381            .filter(|subcommand| !subcommand.is_hide_set())
382            .map(|subcommand| describe_command_from_clap(subcommand.clone()))
383            .collect(),
384    }
385}
386
387#[cfg(feature = "clap")]
388fn describe_arg_from_clap(arg: &clap::Arg) -> DescribeArgV1 {
389    DescribeArgV1 {
390        name: arg
391            .get_value_names()
392            .and_then(|names| names.first())
393            .map(ToString::to_string)
394            .or_else(|| Some(arg.get_id().as_str().to_string())),
395        about: Some(styled_to_plain(
396            arg.get_long_help().or_else(|| arg.get_help()),
397        ))
398        .filter(|text| !text.is_empty()),
399        multi: arg.get_num_args().is_some_and(range_is_multiple)
400            || matches!(arg.get_action(), clap::ArgAction::Append),
401        value_type: value_type_from_hint(arg.get_value_hint()),
402        suggestions: describe_suggestions_from_clap(arg),
403    }
404}
405
406#[cfg(feature = "clap")]
407fn describe_flag_from_clap(arg: &clap::Arg) -> DescribeFlagV1 {
408    DescribeFlagV1 {
409        about: Some(styled_to_plain(
410            arg.get_long_help().or_else(|| arg.get_help()),
411        ))
412        .filter(|text| !text.is_empty()),
413        flag_only: !arg.get_action().takes_values(),
414        multi: arg.get_num_args().is_some_and(range_is_multiple)
415            || matches!(arg.get_action(), clap::ArgAction::Append),
416        value_type: value_type_from_hint(arg.get_value_hint()),
417        suggestions: describe_suggestions_from_clap(arg),
418    }
419}
420
421#[cfg(feature = "clap")]
422fn describe_suggestions_from_clap(arg: &clap::Arg) -> Vec<DescribeSuggestionV1> {
423    arg.get_possible_values()
424        .into_iter()
425        .filter(|value| !value.is_hide_set())
426        .map(|value| DescribeSuggestionV1 {
427            value: value.get_name().to_string(),
428            meta: value.get_help().map(ToString::to_string),
429            display: None,
430            sort: None,
431        })
432        .collect()
433}
434
435#[cfg(feature = "clap")]
436fn visible_flag_names(arg: &clap::Arg) -> Vec<String> {
437    let mut names = Vec::new();
438    if let Some(longs) = arg.get_long_and_visible_aliases() {
439        names.extend(longs.into_iter().map(|name| format!("--{name}")));
440    }
441    if let Some(shorts) = arg.get_short_and_visible_aliases() {
442        names.extend(shorts.into_iter().map(|name| format!("-{name}")));
443    }
444    names
445}
446
447#[cfg(feature = "clap")]
448fn value_type_from_hint(hint: clap::ValueHint) -> Option<DescribeValueTypeV1> {
449    match hint {
450        clap::ValueHint::AnyPath
451        | clap::ValueHint::FilePath
452        | clap::ValueHint::DirPath
453        | clap::ValueHint::ExecutablePath => Some(DescribeValueTypeV1::Path),
454        _ => None,
455    }
456}
457
458#[cfg(feature = "clap")]
459fn styled_to_plain(value: Option<&clap::builder::StyledStr>) -> String {
460    value.map(ToString::to_string).unwrap_or_default()
461}
462
463#[cfg(feature = "clap")]
464fn range_is_multiple(range: clap::builder::ValueRange) -> bool {
465    range.min_values() > 1 || range.max_values() > 1
466}
467
468#[cfg(test)]
469mod tests {
470    use std::collections::BTreeMap;
471
472    use super::{
473        DescribeCommandAuthV1, DescribeCommandV1, DescribeVisibilityModeV1, validate_command_auth,
474    };
475    use crate::core::command_policy::{CommandPath, VisibilityMode};
476
477    #[test]
478    fn command_auth_converts_to_generic_command_policy_unit() {
479        let command = DescribeCommandV1 {
480            name: "orch".to_string(),
481            about: String::new(),
482            auth: Some(DescribeCommandAuthV1 {
483                visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
484                required_capabilities: vec!["orch.approval.decide".to_string()],
485                feature_flags: vec!["orch".to_string()],
486            }),
487            args: Vec::new(),
488            flags: BTreeMap::new(),
489            subcommands: Vec::new(),
490        };
491
492        let policy = command
493            .command_policy(CommandPath::new(["orch", "approval", "decide"]))
494            .expect("auth metadata should build a policy");
495        assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
496        assert!(
497            policy
498                .required_capabilities
499                .contains("orch.approval.decide")
500        );
501        assert!(policy.feature_flags.contains("orch"));
502    }
503
504    #[test]
505    fn command_auth_validation_rejects_blank_entries_unit() {
506        let err = validate_command_auth(&DescribeCommandAuthV1 {
507            visibility: None,
508            required_capabilities: vec![" ".to_string()],
509            feature_flags: Vec::new(),
510        })
511        .expect_err("blank capabilities should be rejected");
512        assert!(err.contains("required_capabilities"));
513    }
514
515    #[test]
516    fn command_auth_hint_stays_compact_and_stable_unit() {
517        let auth = DescribeCommandAuthV1 {
518            visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
519            required_capabilities: vec!["orch.approval.decide".to_string()],
520            feature_flags: vec!["orch".to_string()],
521        };
522        assert_eq!(
523            auth.hint().as_deref(),
524            Some("cap: orch.approval.decide; feature: orch")
525        );
526        assert_eq!(
527            DescribeVisibilityModeV1::Authenticated.as_label(),
528            "authenticated"
529        );
530    }
531}
532
533#[cfg(all(test, feature = "clap"))]
534mod clap_tests {
535    use super::{DescribeCommandV1, DescribeV1, DescribeValueTypeV1};
536    use clap::{Arg, ArgAction, Command, ValueHint};
537
538    #[test]
539    fn clap_helper_captures_subcommands_flags_and_args() {
540        let command = Command::new("ldap").about("LDAP plugin").subcommand(
541            Command::new("user")
542                .about("Lookup LDAP users")
543                .arg(Arg::new("uid").help("User id"))
544                .arg(
545                    Arg::new("attributes")
546                        .long("attributes")
547                        .short('a')
548                        .help("Attributes to fetch")
549                        .action(ArgAction::Set)
550                        .value_parser(["uid", "cn", "mail"]),
551                )
552                .arg(
553                    Arg::new("input")
554                        .long("input")
555                        .help("Read from file")
556                        .value_hint(ValueHint::FilePath),
557                ),
558        );
559
560        let describe =
561            DescribeV1::from_clap_command("ldap", "0.1.0", Some("0.1.0".to_string()), command);
562
563        assert_eq!(describe.commands.len(), 1);
564        let ldap = &describe.commands[0];
565        assert_eq!(ldap.name, "ldap");
566        assert_eq!(ldap.subcommands.len(), 1);
567
568        let user = &ldap.subcommands[0];
569        assert_eq!(user.name, "user");
570        assert_eq!(user.args[0].name.as_deref(), Some("uid"));
571        assert!(user.flags.contains_key("--attributes"));
572        assert!(user.flags.contains_key("-a"));
573        assert_eq!(
574            user.flags["--attributes"]
575                .suggestions
576                .iter()
577                .map(|entry| entry.value.as_str())
578                .collect::<Vec<_>>(),
579            vec!["uid", "cn", "mail"]
580        );
581        assert_eq!(
582            user.flags["--input"].value_type,
583            Some(DescribeValueTypeV1::Path)
584        );
585    }
586
587    #[test]
588    fn clap_command_conversion_skips_hidden_items() {
589        let command = Command::new("ldap")
590            .subcommand(Command::new("visible"))
591            .subcommand(Command::new("hidden").hide(true))
592            .arg(Arg::new("secret").long("secret").hide(true));
593
594        let describe = DescribeCommandV1::from_clap(command);
595
596        assert_eq!(
597            describe
598                .subcommands
599                .iter()
600                .map(|subcommand| subcommand.name.as_str())
601                .collect::<Vec<_>>(),
602            vec!["visible"]
603        );
604        assert!(!describe.flags.contains_key("--secret"));
605    }
606}