Skip to main content

osp_cli/core/
plugin.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5pub const PLUGIN_PROTOCOL_V1: u32 = 1;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct DescribeV1 {
9    pub protocol_version: u32,
10    pub plugin_id: String,
11    pub plugin_version: String,
12    pub min_osp_version: Option<String>,
13    pub commands: Vec<DescribeCommandV1>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct DescribeCommandV1 {
18    pub name: String,
19    #[serde(default)]
20    pub about: String,
21    #[serde(default)]
22    pub args: Vec<DescribeArgV1>,
23    #[serde(default)]
24    pub flags: BTreeMap<String, DescribeFlagV1>,
25    #[serde(default)]
26    pub subcommands: Vec<DescribeCommandV1>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum DescribeValueTypeV1 {
32    Path,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct DescribeSuggestionV1 {
37    pub value: String,
38    #[serde(default)]
39    pub meta: Option<String>,
40    #[serde(default)]
41    pub display: Option<String>,
42    #[serde(default)]
43    pub sort: Option<String>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct DescribeArgV1 {
48    #[serde(default)]
49    pub name: Option<String>,
50    #[serde(default)]
51    pub about: Option<String>,
52    #[serde(default)]
53    pub multi: bool,
54    #[serde(default)]
55    pub value_type: Option<DescribeValueTypeV1>,
56    #[serde(default)]
57    pub suggestions: Vec<DescribeSuggestionV1>,
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct DescribeFlagV1 {
62    #[serde(default)]
63    pub about: Option<String>,
64    #[serde(default)]
65    pub flag_only: bool,
66    #[serde(default)]
67    pub multi: bool,
68    #[serde(default)]
69    pub value_type: Option<DescribeValueTypeV1>,
70    #[serde(default)]
71    pub suggestions: Vec<DescribeSuggestionV1>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ResponseV1 {
76    pub protocol_version: u32,
77    pub ok: bool,
78    pub data: serde_json::Value,
79    pub error: Option<ResponseErrorV1>,
80    #[serde(default)]
81    pub messages: Vec<ResponseMessageV1>,
82    pub meta: ResponseMetaV1,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ResponseErrorV1 {
87    pub code: String,
88    pub message: String,
89    #[serde(default)]
90    pub details: serde_json::Value,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, Default)]
94pub struct ResponseMetaV1 {
95    pub format_hint: Option<String>,
96    pub columns: Option<Vec<String>>,
97    #[serde(default)]
98    pub column_align: Vec<ColumnAlignmentV1>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "lowercase")]
103pub enum ColumnAlignmentV1 {
104    #[default]
105    Default,
106    Left,
107    Center,
108    Right,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum ResponseMessageLevelV1 {
114    Error,
115    Warning,
116    Success,
117    Info,
118    Trace,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ResponseMessageV1 {
123    pub level: ResponseMessageLevelV1,
124    pub text: String,
125}
126
127impl DescribeV1 {
128    #[cfg(feature = "clap")]
129    pub fn from_clap_command(
130        plugin_id: impl Into<String>,
131        plugin_version: impl Into<String>,
132        min_osp_version: Option<String>,
133        command: clap::Command,
134    ) -> Self {
135        Self::from_clap_commands(
136            plugin_id,
137            plugin_version,
138            min_osp_version,
139            std::iter::once(command),
140        )
141    }
142
143    #[cfg(feature = "clap")]
144    pub fn from_clap_commands(
145        plugin_id: impl Into<String>,
146        plugin_version: impl Into<String>,
147        min_osp_version: Option<String>,
148        commands: impl IntoIterator<Item = clap::Command>,
149    ) -> Self {
150        Self {
151            protocol_version: PLUGIN_PROTOCOL_V1,
152            plugin_id: plugin_id.into(),
153            plugin_version: plugin_version.into(),
154            min_osp_version,
155            commands: commands
156                .into_iter()
157                .map(DescribeCommandV1::from_clap)
158                .collect(),
159        }
160    }
161
162    pub fn validate_v1(&self) -> Result<(), String> {
163        if self.protocol_version != PLUGIN_PROTOCOL_V1 {
164            return Err(format!(
165                "unsupported describe protocol version: {}",
166                self.protocol_version
167            ));
168        }
169        if self.plugin_id.trim().is_empty() {
170            return Err("plugin_id must not be empty".to_string());
171        }
172        for command in &self.commands {
173            validate_command(command)?;
174        }
175        Ok(())
176    }
177}
178
179impl ResponseV1 {
180    pub fn validate_v1(&self) -> Result<(), String> {
181        if self.protocol_version != PLUGIN_PROTOCOL_V1 {
182            return Err(format!(
183                "unsupported response protocol version: {}",
184                self.protocol_version
185            ));
186        }
187        if self.ok && self.error.is_some() {
188            return Err("ok=true requires error=null".to_string());
189        }
190        if !self.ok && self.error.is_none() {
191            return Err("ok=false requires error payload".to_string());
192        }
193        if self
194            .messages
195            .iter()
196            .any(|message| message.text.trim().is_empty())
197        {
198            return Err("response messages must not contain empty text".to_string());
199        }
200        Ok(())
201    }
202}
203
204#[cfg(feature = "clap")]
205impl DescribeCommandV1 {
206    pub fn from_clap(command: clap::Command) -> Self {
207        describe_command_from_clap(command)
208    }
209}
210
211fn validate_command(command: &DescribeCommandV1) -> Result<(), String> {
212    if command.name.trim().is_empty() {
213        return Err("command name must not be empty".to_string());
214    }
215
216    for (name, flag) in &command.flags {
217        if !name.starts_with('-') {
218            return Err(format!("flag `{name}` must start with `-`"));
219        }
220        validate_suggestions(&flag.suggestions, &format!("flag `{name}`"))?;
221    }
222
223    for arg in &command.args {
224        validate_suggestions(&arg.suggestions, "argument")?;
225    }
226
227    for subcommand in &command.subcommands {
228        validate_command(subcommand)?;
229    }
230
231    Ok(())
232}
233
234fn validate_suggestions(suggestions: &[DescribeSuggestionV1], owner: &str) -> Result<(), String> {
235    if suggestions
236        .iter()
237        .any(|entry| entry.value.trim().is_empty())
238    {
239        return Err(format!("{owner} suggestions must not contain empty values"));
240    }
241    Ok(())
242}
243
244#[cfg(feature = "clap")]
245fn describe_command_from_clap(command: clap::Command) -> DescribeCommandV1 {
246    let positionals = command
247        .get_positionals()
248        .filter(|arg| !arg.is_hide_set())
249        .map(describe_arg_from_clap)
250        .collect::<Vec<_>>();
251
252    let mut flags = BTreeMap::new();
253    for arg in command.get_arguments().filter(|arg| !arg.is_positional()) {
254        if arg.is_hide_set() {
255            continue;
256        }
257        let flag = describe_flag_from_clap(arg);
258        for name in visible_flag_names(arg) {
259            flags.insert(name, flag.clone());
260        }
261    }
262
263    DescribeCommandV1 {
264        name: command.get_name().to_string(),
265        about: styled_to_plain(command.get_about()),
266        args: positionals,
267        flags,
268        subcommands: command
269            .get_subcommands()
270            .filter(|subcommand| !subcommand.is_hide_set())
271            .map(|subcommand| describe_command_from_clap(subcommand.clone()))
272            .collect(),
273    }
274}
275
276#[cfg(feature = "clap")]
277fn describe_arg_from_clap(arg: &clap::Arg) -> DescribeArgV1 {
278    DescribeArgV1 {
279        name: arg
280            .get_value_names()
281            .and_then(|names| names.first())
282            .map(ToString::to_string)
283            .or_else(|| Some(arg.get_id().as_str().to_string())),
284        about: Some(styled_to_plain(
285            arg.get_long_help().or_else(|| arg.get_help()),
286        ))
287        .filter(|text| !text.is_empty()),
288        multi: arg.get_num_args().is_some_and(range_is_multiple)
289            || matches!(arg.get_action(), clap::ArgAction::Append),
290        value_type: value_type_from_hint(arg.get_value_hint()),
291        suggestions: describe_suggestions_from_clap(arg),
292    }
293}
294
295#[cfg(feature = "clap")]
296fn describe_flag_from_clap(arg: &clap::Arg) -> DescribeFlagV1 {
297    DescribeFlagV1 {
298        about: Some(styled_to_plain(
299            arg.get_long_help().or_else(|| arg.get_help()),
300        ))
301        .filter(|text| !text.is_empty()),
302        flag_only: !arg.get_action().takes_values(),
303        multi: arg.get_num_args().is_some_and(range_is_multiple)
304            || matches!(arg.get_action(), clap::ArgAction::Append),
305        value_type: value_type_from_hint(arg.get_value_hint()),
306        suggestions: describe_suggestions_from_clap(arg),
307    }
308}
309
310#[cfg(feature = "clap")]
311fn describe_suggestions_from_clap(arg: &clap::Arg) -> Vec<DescribeSuggestionV1> {
312    arg.get_possible_values()
313        .into_iter()
314        .filter(|value| !value.is_hide_set())
315        .map(|value| DescribeSuggestionV1 {
316            value: value.get_name().to_string(),
317            meta: value.get_help().map(ToString::to_string),
318            display: None,
319            sort: None,
320        })
321        .collect()
322}
323
324#[cfg(feature = "clap")]
325fn visible_flag_names(arg: &clap::Arg) -> Vec<String> {
326    let mut names = Vec::new();
327    if let Some(longs) = arg.get_long_and_visible_aliases() {
328        names.extend(longs.into_iter().map(|name| format!("--{name}")));
329    }
330    if let Some(shorts) = arg.get_short_and_visible_aliases() {
331        names.extend(shorts.into_iter().map(|name| format!("-{name}")));
332    }
333    names
334}
335
336#[cfg(feature = "clap")]
337fn value_type_from_hint(hint: clap::ValueHint) -> Option<DescribeValueTypeV1> {
338    match hint {
339        clap::ValueHint::AnyPath
340        | clap::ValueHint::FilePath
341        | clap::ValueHint::DirPath
342        | clap::ValueHint::ExecutablePath => Some(DescribeValueTypeV1::Path),
343        _ => None,
344    }
345}
346
347#[cfg(feature = "clap")]
348fn styled_to_plain(value: Option<&clap::builder::StyledStr>) -> String {
349    value.map(ToString::to_string).unwrap_or_default()
350}
351
352#[cfg(feature = "clap")]
353fn range_is_multiple(range: clap::builder::ValueRange) -> bool {
354    range.min_values() > 1 || range.max_values() > 1
355}
356
357#[cfg(all(test, feature = "clap"))]
358mod clap_tests {
359    use super::{DescribeCommandV1, DescribeV1, DescribeValueTypeV1};
360    use clap::{Arg, ArgAction, Command, ValueHint};
361
362    #[test]
363    fn clap_helper_captures_subcommands_flags_and_args() {
364        let command = Command::new("ldap").about("LDAP plugin").subcommand(
365            Command::new("user")
366                .about("Lookup LDAP users")
367                .arg(Arg::new("uid").help("User id"))
368                .arg(
369                    Arg::new("attributes")
370                        .long("attributes")
371                        .short('a')
372                        .help("Attributes to fetch")
373                        .action(ArgAction::Set)
374                        .value_parser(["uid", "cn", "mail"]),
375                )
376                .arg(
377                    Arg::new("input")
378                        .long("input")
379                        .help("Read from file")
380                        .value_hint(ValueHint::FilePath),
381                ),
382        );
383
384        let describe =
385            DescribeV1::from_clap_command("ldap", "0.1.0", Some("0.1.0".to_string()), command);
386
387        assert_eq!(describe.commands.len(), 1);
388        let ldap = &describe.commands[0];
389        assert_eq!(ldap.name, "ldap");
390        assert_eq!(ldap.subcommands.len(), 1);
391
392        let user = &ldap.subcommands[0];
393        assert_eq!(user.name, "user");
394        assert_eq!(user.args[0].name.as_deref(), Some("uid"));
395        assert!(user.flags.contains_key("--attributes"));
396        assert!(user.flags.contains_key("-a"));
397        assert_eq!(
398            user.flags["--attributes"]
399                .suggestions
400                .iter()
401                .map(|entry| entry.value.as_str())
402                .collect::<Vec<_>>(),
403            vec!["uid", "cn", "mail"]
404        );
405        assert_eq!(
406            user.flags["--input"].value_type,
407            Some(DescribeValueTypeV1::Path)
408        );
409    }
410
411    #[test]
412    fn clap_command_conversion_skips_hidden_items() {
413        let command = Command::new("ldap")
414            .subcommand(Command::new("visible"))
415            .subcommand(Command::new("hidden").hide(true))
416            .arg(Arg::new("secret").long("secret").hide(true));
417
418        let describe = DescribeCommandV1::from_clap(command);
419
420        assert_eq!(
421            describe
422                .subcommands
423                .iter()
424                .map(|subcommand| subcommand.name.as_str())
425                .collect::<Vec<_>>(),
426            vec!["visible"]
427        );
428        assert!(!describe.flags.contains_key("--secret"));
429    }
430}