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}