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}