1use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28use crate::core::command_def::{
29 ArgDef, CommandDef, CommandPolicyDef, FlagDef, ValueChoice, ValueKind,
30};
31use crate::core::command_policy::{CommandPath, CommandPolicy, VisibilityMode};
32
33pub const PLUGIN_PROTOCOL_V1: u32 = 1;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct DescribeV1 {
39 pub protocol_version: u32,
41 pub plugin_id: String,
43 pub plugin_version: String,
45 pub min_osp_version: Option<String>,
47 pub commands: Vec<DescribeCommandV1>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DescribeCommandV1 {
54 pub name: String,
56 #[serde(default)]
57 pub about: String,
59 #[serde(default)]
60 pub auth: Option<DescribeCommandAuthV1>,
62 #[serde(default)]
63 pub args: Vec<DescribeArgV1>,
65 #[serde(default)]
66 pub flags: BTreeMap<String, DescribeFlagV1>,
68 #[serde(default)]
69 pub subcommands: Vec<DescribeCommandV1>,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct DescribeCommandAuthV1 {
76 #[serde(default)]
77 pub visibility: Option<DescribeVisibilityModeV1>,
79 #[serde(default)]
80 pub required_capabilities: Vec<String>,
82 #[serde(default)]
83 pub feature_flags: Vec<String>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum DescribeVisibilityModeV1 {
91 Public,
93 Authenticated,
95 CapabilityGated,
97 Hidden,
99}
100
101impl DescribeVisibilityModeV1 {
102 pub fn as_visibility_mode(self) -> VisibilityMode {
116 match self {
117 DescribeVisibilityModeV1::Public => VisibilityMode::Public,
118 DescribeVisibilityModeV1::Authenticated => VisibilityMode::Authenticated,
119 DescribeVisibilityModeV1::CapabilityGated => VisibilityMode::CapabilityGated,
120 DescribeVisibilityModeV1::Hidden => VisibilityMode::Hidden,
121 }
122 }
123
124 pub fn as_label(self) -> &'static str {
134 match self {
135 DescribeVisibilityModeV1::Public => "public",
136 DescribeVisibilityModeV1::Authenticated => "authenticated",
137 DescribeVisibilityModeV1::CapabilityGated => "capability_gated",
138 DescribeVisibilityModeV1::Hidden => "hidden",
139 }
140 }
141}
142
143impl DescribeCommandAuthV1 {
144 pub fn hint(&self) -> Option<String> {
166 let mut parts = Vec::new();
167
168 match self.visibility {
169 Some(DescribeVisibilityModeV1::Public) | None => {}
170 Some(DescribeVisibilityModeV1::Authenticated) => parts.push("auth".to_string()),
171 Some(DescribeVisibilityModeV1::CapabilityGated) => {
172 if self.required_capabilities.len() == 1 {
173 parts.push(format!("cap: {}", self.required_capabilities[0]));
174 } else if self.required_capabilities.is_empty() {
175 parts.push("cap".to_string());
176 } else {
177 parts.push(format!("caps: {}", self.required_capabilities.len()));
178 }
179 }
180 Some(DescribeVisibilityModeV1::Hidden) => parts.push("hidden".to_string()),
181 }
182
183 match self.feature_flags.as_slice() {
184 [] => {}
185 [feature] => parts.push(format!("feature: {feature}")),
186 features => parts.push(format!("features: {}", features.len())),
187 }
188
189 (!parts.is_empty()).then(|| parts.join("; "))
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum DescribeValueTypeV1 {
197 Path,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct DescribeSuggestionV1 {
204 pub value: String,
206 #[serde(default)]
207 pub meta: Option<String>,
209 #[serde(default)]
210 pub display: Option<String>,
212 #[serde(default)]
213 pub sort: Option<String>,
215}
216
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
219pub struct DescribeArgV1 {
220 #[serde(default)]
221 pub name: Option<String>,
223 #[serde(default)]
224 pub about: Option<String>,
226 #[serde(default)]
227 pub multi: bool,
229 #[serde(default)]
230 pub value_type: Option<DescribeValueTypeV1>,
232 #[serde(default)]
233 pub suggestions: Vec<DescribeSuggestionV1>,
235}
236
237#[derive(Debug, Clone, Default, Serialize, Deserialize)]
239pub struct DescribeFlagV1 {
240 #[serde(default)]
241 pub about: Option<String>,
243 #[serde(default)]
244 pub flag_only: bool,
246 #[serde(default)]
247 pub multi: bool,
249 #[serde(default)]
250 pub value_type: Option<DescribeValueTypeV1>,
252 #[serde(default)]
253 pub suggestions: Vec<DescribeSuggestionV1>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ResponseV1 {
260 pub protocol_version: u32,
262 pub ok: bool,
264 pub data: serde_json::Value,
266 pub error: Option<ResponseErrorV1>,
268 #[serde(default)]
269 pub messages: Vec<ResponseMessageV1>,
271 pub meta: ResponseMetaV1,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ResponseErrorV1 {
278 pub code: String,
280 pub message: String,
282 #[serde(default)]
283 pub details: serde_json::Value,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, Default)]
289pub struct ResponseMetaV1 {
290 pub format_hint: Option<String>,
292 pub columns: Option<Vec<String>>,
294 #[serde(default)]
295 pub column_align: Vec<ColumnAlignmentV1>,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
301#[serde(rename_all = "lowercase")]
302pub enum ColumnAlignmentV1 {
303 #[default]
305 Default,
306 Left,
308 Center,
310 Right,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum ResponseMessageLevelV1 {
318 Error,
320 Warning,
322 Success,
324 Info,
326 Trace,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ResponseMessageV1 {
333 pub level: ResponseMessageLevelV1,
335 pub text: String,
337}
338
339impl DescribeV1 {
340 #[cfg(feature = "clap")]
341 pub fn from_clap_command(
343 plugin_id: impl Into<String>,
344 plugin_version: impl Into<String>,
345 min_osp_version: Option<String>,
346 command: clap::Command,
347 ) -> Self {
348 Self::from_clap_commands(
349 plugin_id,
350 plugin_version,
351 min_osp_version,
352 std::iter::once(command),
353 )
354 }
355
356 #[cfg(feature = "clap")]
357 pub fn from_clap_commands(
359 plugin_id: impl Into<String>,
360 plugin_version: impl Into<String>,
361 min_osp_version: Option<String>,
362 commands: impl IntoIterator<Item = clap::Command>,
363 ) -> Self {
364 Self {
365 protocol_version: PLUGIN_PROTOCOL_V1,
366 plugin_id: plugin_id.into(),
367 plugin_version: plugin_version.into(),
368 min_osp_version,
369 commands: commands
370 .into_iter()
371 .map(CommandDef::from_clap)
372 .map(|command| DescribeCommandV1::from(&command))
373 .collect(),
374 }
375 }
376
377 pub fn validate_v1(&self) -> Result<(), String> {
379 if self.protocol_version != PLUGIN_PROTOCOL_V1 {
380 return Err(format!(
381 "unsupported describe protocol version: {}",
382 self.protocol_version
383 ));
384 }
385 if self.plugin_id.trim().is_empty() {
386 return Err("plugin_id must not be empty".to_string());
387 }
388 for command in &self.commands {
389 validate_command(command)?;
390 }
391 Ok(())
392 }
393}
394
395impl DescribeCommandV1 {
396 pub fn command_policy(&self, path: CommandPath) -> Option<CommandPolicy> {
398 let auth = self.auth.as_ref()?;
399 let mut policy = CommandPolicy::new(path);
400 if let Some(visibility) = auth.visibility {
401 policy = policy.visibility(visibility.as_visibility_mode());
402 }
403 for capability in &auth.required_capabilities {
404 policy = policy.require_capability(capability.clone());
405 }
406 for feature in &auth.feature_flags {
407 policy = policy.feature_flag(feature.clone());
408 }
409 Some(policy)
410 }
411}
412
413impl ResponseV1 {
414 pub fn validate_v1(&self) -> Result<(), String> {
434 if self.protocol_version != PLUGIN_PROTOCOL_V1 {
435 return Err(format!(
436 "unsupported response protocol version: {}",
437 self.protocol_version
438 ));
439 }
440 if self.ok && self.error.is_some() {
441 return Err("ok=true requires error=null".to_string());
442 }
443 if !self.ok && self.error.is_none() {
444 return Err("ok=false requires error payload".to_string());
445 }
446 if self
447 .messages
448 .iter()
449 .any(|message| message.text.trim().is_empty())
450 {
451 return Err("response messages must not contain empty text".to_string());
452 }
453 Ok(())
454 }
455}
456
457#[cfg(feature = "clap")]
458impl DescribeCommandV1 {
459 pub fn from_clap(command: clap::Command) -> Self {
461 Self::from(&CommandDef::from_clap(command))
462 }
463}
464
465impl From<&CommandDef> for DescribeCommandV1 {
466 fn from(command: &CommandDef) -> Self {
467 Self {
468 name: command.name.clone(),
469 about: command.about.clone().unwrap_or_default(),
470 auth: (!command.policy.is_empty()).then(|| DescribeCommandAuthV1 {
471 visibility: match command.policy.visibility {
472 VisibilityMode::Public => None,
473 VisibilityMode::Authenticated => Some(DescribeVisibilityModeV1::Authenticated),
474 VisibilityMode::CapabilityGated => {
475 Some(DescribeVisibilityModeV1::CapabilityGated)
476 }
477 VisibilityMode::Hidden => Some(DescribeVisibilityModeV1::Hidden),
478 },
479 required_capabilities: command.policy.required_capabilities.clone(),
480 feature_flags: command.policy.feature_flags.clone(),
481 }),
482 args: command.args.iter().map(DescribeArgV1::from).collect(),
483 flags: command
484 .flags
485 .iter()
486 .flat_map(describe_flag_entries)
487 .collect(),
488 subcommands: command
489 .subcommands
490 .iter()
491 .map(DescribeCommandV1::from)
492 .collect(),
493 }
494 }
495}
496
497impl From<&DescribeCommandV1> for CommandDef {
498 fn from(command: &DescribeCommandV1) -> Self {
499 Self {
500 name: command.name.clone(),
501 about: (!command.about.trim().is_empty()).then(|| command.about.clone()),
502 long_about: None,
503 usage: None,
504 before_help: None,
505 after_help: None,
506 aliases: Vec::new(),
507 hidden: matches!(
508 command.auth.as_ref().and_then(|auth| auth.visibility),
509 Some(DescribeVisibilityModeV1::Hidden)
510 ),
511 sort_key: None,
512 policy: command
513 .auth
514 .as_ref()
515 .map(command_policy_from_describe)
516 .unwrap_or_default(),
517 args: command.args.iter().map(ArgDef::from).collect(),
518 flags: collect_describe_flags(&command.flags),
519 subcommands: command.subcommands.iter().map(CommandDef::from).collect(),
520 }
521 }
522}
523
524impl From<&ArgDef> for DescribeArgV1 {
525 fn from(arg: &ArgDef) -> Self {
526 Self {
527 name: arg.value_name.clone().or_else(|| Some(arg.id.clone())),
528 about: arg.help.clone(),
529 multi: arg.multi,
530 value_type: describe_value_type(arg.value_kind),
531 suggestions: arg.choices.iter().map(DescribeSuggestionV1::from).collect(),
532 }
533 }
534}
535
536impl From<&FlagDef> for DescribeFlagV1 {
537 fn from(flag: &FlagDef) -> Self {
538 Self {
539 about: flag.help.clone(),
540 flag_only: !flag.takes_value,
541 multi: flag.multi,
542 value_type: describe_value_type(flag.value_kind),
543 suggestions: flag
544 .choices
545 .iter()
546 .map(DescribeSuggestionV1::from)
547 .collect(),
548 }
549 }
550}
551
552impl From<&DescribeArgV1> for ArgDef {
553 fn from(arg: &DescribeArgV1) -> Self {
554 let mut def = ArgDef::new(arg.name.clone().unwrap_or_else(|| "value".to_string()));
555 if let Some(value_name) = &arg.name {
556 def = def.value_name(value_name.clone());
557 }
558 if let Some(help) = &arg.about {
559 def = def.help(help.clone());
560 }
561 if arg.multi {
562 def = def.multi();
563 }
564 if let Some(value_kind) = command_value_kind(arg.value_type) {
565 def = def.value_kind(value_kind);
566 }
567 def.choices(arg.suggestions.iter().map(ValueChoice::from))
568 }
569}
570
571impl From<&DescribeFlagV1> for FlagDef {
572 fn from(flag: &DescribeFlagV1) -> Self {
573 let mut def = FlagDef::new("flag");
574 if let Some(help) = &flag.about {
575 def = def.help(help.clone());
576 }
577 if !flag.flag_only {
578 def = def.takes_value("value");
579 }
580 if flag.multi {
581 def = def.multi();
582 }
583 if let Some(value_kind) = command_value_kind(flag.value_type) {
584 def = def.value_kind(value_kind);
585 }
586 def.choices(flag.suggestions.iter().map(ValueChoice::from))
587 }
588}
589
590impl From<&ValueChoice> for DescribeSuggestionV1 {
591 fn from(choice: &ValueChoice) -> Self {
592 Self {
593 value: choice.value.clone(),
594 meta: choice.help.clone(),
595 display: choice.display.clone(),
596 sort: choice.sort_key.clone(),
597 }
598 }
599}
600
601impl From<&DescribeSuggestionV1> for ValueChoice {
602 fn from(entry: &DescribeSuggestionV1) -> Self {
603 Self {
604 value: entry.value.clone(),
605 help: entry.meta.clone(),
606 display: entry.display.clone(),
607 sort_key: entry.sort.clone(),
608 }
609 }
610}
611
612fn validate_command(command: &DescribeCommandV1) -> Result<(), String> {
613 if command.name.trim().is_empty() {
614 return Err("command name must not be empty".to_string());
615 }
616 if let Some(auth) = &command.auth {
617 validate_command_auth(auth)?;
618 }
619
620 for (name, flag) in &command.flags {
621 if !name.starts_with('-') {
622 return Err(format!("flag `{name}` must start with `-`"));
623 }
624 validate_suggestions(&flag.suggestions, &format!("flag `{name}`"))?;
625 }
626
627 for arg in &command.args {
628 validate_suggestions(&arg.suggestions, "argument")?;
629 }
630
631 for subcommand in &command.subcommands {
632 validate_command(subcommand)?;
633 }
634
635 Ok(())
636}
637
638fn validate_suggestions(suggestions: &[DescribeSuggestionV1], owner: &str) -> Result<(), String> {
639 if suggestions
640 .iter()
641 .any(|entry| entry.value.trim().is_empty())
642 {
643 return Err(format!("{owner} suggestions must not contain empty values"));
644 }
645 Ok(())
646}
647
648fn validate_command_auth(auth: &DescribeCommandAuthV1) -> Result<(), String> {
649 if auth
650 .required_capabilities
651 .iter()
652 .any(|value| value.trim().is_empty())
653 {
654 return Err("required_capabilities must not contain empty values".to_string());
655 }
656 if auth
657 .feature_flags
658 .iter()
659 .any(|value| value.trim().is_empty())
660 {
661 return Err("feature_flags must not contain empty values".to_string());
662 }
663 Ok(())
664}
665
666fn describe_flag_entries(flag: &FlagDef) -> Vec<(String, DescribeFlagV1)> {
667 let value = DescribeFlagV1::from(flag);
668 let mut names = Vec::new();
669 if let Some(long) = flag.long.as_deref() {
670 names.push(format!("--{long}"));
671 }
672 if let Some(short) = flag.short {
673 names.push(format!("-{short}"));
674 }
675 names.extend(flag.aliases.iter().cloned());
676 names
677 .into_iter()
678 .map(|name| (name, value.clone()))
679 .collect()
680}
681
682fn group_describe_flag((name, flag): (&String, &DescribeFlagV1)) -> Option<FlagDef> {
683 if !name.starts_with('-') {
684 return None;
685 }
686
687 let mut def = FlagDef::from(flag);
688 if let Some(long) = name.strip_prefix("--") {
689 def.long = Some(long.to_string());
690 def.id = long.to_string();
691 } else if let Some(short) = name.strip_prefix('-') {
692 def.short = short.chars().next();
693 def.id = short.to_string();
694 }
695 Some(def)
696}
697
698fn collect_describe_flags(flags: &BTreeMap<String, DescribeFlagV1>) -> Vec<FlagDef> {
699 let mut grouped: BTreeMap<String, Vec<(&String, &DescribeFlagV1)>> = BTreeMap::new();
700 for entry in flags.iter() {
701 let signature = serde_json::to_string(entry.1).unwrap_or_default();
702 grouped.entry(signature).or_default().push(entry);
703 }
704
705 grouped
706 .into_values()
707 .filter_map(|group| {
708 let mut iter = group.into_iter();
709 let first = iter.next()?;
710 let mut def = group_describe_flag(first)?;
711 for (name, _) in iter {
712 if let Some(long) = name.strip_prefix("--") {
713 if def.long.is_none() {
714 def.long = Some(long.to_string());
715 if def.id == "flag" {
716 def.id = long.to_string();
717 }
718 } else if Some(long) != def.long.as_deref() {
719 def.aliases.push(format!("--{long}"));
720 }
721 } else if let Some(short) = name.strip_prefix('-') {
722 let short_char = short.chars().next();
723 if def.short.is_none() {
724 def.short = short_char;
725 if def.id == "flag" {
726 def.id = short.to_string();
727 }
728 } else if short_char != def.short {
729 def.aliases.push(format!("-{short}"));
730 }
731 }
732 }
733 Some(def)
734 })
735 .collect()
736}
737
738fn command_policy_from_describe(auth: &DescribeCommandAuthV1) -> CommandPolicyDef {
739 CommandPolicyDef {
740 visibility: match auth.visibility {
741 Some(DescribeVisibilityModeV1::Authenticated) => VisibilityMode::Authenticated,
742 Some(DescribeVisibilityModeV1::CapabilityGated) => VisibilityMode::CapabilityGated,
743 Some(DescribeVisibilityModeV1::Hidden) => VisibilityMode::Hidden,
744 Some(DescribeVisibilityModeV1::Public) | None => VisibilityMode::Public,
745 },
746 required_capabilities: auth.required_capabilities.clone(),
747 feature_flags: auth.feature_flags.clone(),
748 }
749}
750
751fn describe_value_type(value_kind: Option<ValueKind>) -> Option<DescribeValueTypeV1> {
752 match value_kind {
753 Some(ValueKind::Path) => Some(DescribeValueTypeV1::Path),
754 Some(ValueKind::Enum | ValueKind::FreeText) | None => None,
755 }
756}
757
758fn command_value_kind(value_type: Option<DescribeValueTypeV1>) -> Option<ValueKind> {
759 value_type.map(|_| ValueKind::Path)
760}
761
762#[cfg(test)]
763mod tests {
764 use std::collections::BTreeMap;
765
766 use super::{
767 DescribeCommandAuthV1, DescribeCommandV1, DescribeVisibilityModeV1, validate_command_auth,
768 };
769 use crate::core::command_policy::{CommandPath, VisibilityMode};
770
771 #[test]
772 fn command_auth_converts_to_generic_command_policy_unit() {
773 let command = DescribeCommandV1 {
774 name: "orch".to_string(),
775 about: String::new(),
776 auth: Some(DescribeCommandAuthV1 {
777 visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
778 required_capabilities: vec!["orch.approval.decide".to_string()],
779 feature_flags: vec!["orch".to_string()],
780 }),
781 args: Vec::new(),
782 flags: BTreeMap::new(),
783 subcommands: Vec::new(),
784 };
785
786 let policy = command
787 .command_policy(CommandPath::new(["orch", "approval", "decide"]))
788 .expect("auth metadata should build a policy");
789 assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
790 assert!(
791 policy
792 .required_capabilities
793 .contains("orch.approval.decide")
794 );
795 assert!(policy.feature_flags.contains("orch"));
796 }
797
798 #[test]
799 fn command_auth_validation_rejects_blank_entries_unit() {
800 let err = validate_command_auth(&DescribeCommandAuthV1 {
801 visibility: None,
802 required_capabilities: vec![" ".to_string()],
803 feature_flags: Vec::new(),
804 })
805 .expect_err("blank capabilities should be rejected");
806 assert!(err.contains("required_capabilities"));
807 }
808
809 #[test]
810 fn command_auth_hint_stays_compact_and_stable_unit() {
811 let auth = DescribeCommandAuthV1 {
812 visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
813 required_capabilities: vec!["orch.approval.decide".to_string()],
814 feature_flags: vec!["orch".to_string()],
815 };
816 assert_eq!(
817 auth.hint().as_deref(),
818 Some("cap: orch.approval.decide; feature: orch")
819 );
820 assert_eq!(
821 DescribeVisibilityModeV1::Authenticated.as_label(),
822 "authenticated"
823 );
824 }
825}
826
827#[cfg(all(test, feature = "clap"))]
828mod clap_tests {
829 use super::{DescribeCommandV1, DescribeV1, DescribeValueTypeV1};
830 use clap::{Arg, ArgAction, Command, ValueHint};
831
832 #[test]
833 fn clap_helper_captures_subcommands_flags_and_args() {
834 let command = Command::new("ldap").about("LDAP plugin").subcommand(
835 Command::new("user")
836 .about("Lookup LDAP users")
837 .arg(Arg::new("uid").help("User id"))
838 .arg(
839 Arg::new("attributes")
840 .long("attributes")
841 .short('a')
842 .help("Attributes to fetch")
843 .action(ArgAction::Set)
844 .value_parser(["uid", "cn", "mail"]),
845 )
846 .arg(
847 Arg::new("input")
848 .long("input")
849 .help("Read from file")
850 .value_hint(ValueHint::FilePath),
851 ),
852 );
853
854 let describe =
855 DescribeV1::from_clap_command("ldap", "0.1.0", Some("0.1.0".to_string()), command);
856
857 assert_eq!(describe.commands.len(), 1);
858 let ldap = &describe.commands[0];
859 assert_eq!(ldap.name, "ldap");
860 assert_eq!(ldap.subcommands.len(), 1);
861
862 let user = &ldap.subcommands[0];
863 assert_eq!(user.name, "user");
864 assert_eq!(user.args[0].name.as_deref(), Some("uid"));
865 assert!(user.flags.contains_key("--attributes"));
866 assert!(user.flags.contains_key("-a"));
867 assert_eq!(
868 user.flags["--attributes"]
869 .suggestions
870 .iter()
871 .map(|entry| entry.value.as_str())
872 .collect::<Vec<_>>(),
873 vec!["uid", "cn", "mail"]
874 );
875 assert_eq!(
876 user.flags["--input"].value_type,
877 Some(DescribeValueTypeV1::Path)
878 );
879 }
880
881 #[test]
882 fn clap_command_conversion_skips_hidden_items() {
883 let command = Command::new("ldap")
884 .subcommand(Command::new("visible"))
885 .subcommand(Command::new("hidden").hide(true))
886 .arg(Arg::new("secret").long("secret").hide(true));
887
888 let describe = DescribeCommandV1::from_clap(command);
889
890 assert_eq!(
891 describe
892 .subcommands
893 .iter()
894 .map(|subcommand| subcommand.name.as_str())
895 .collect::<Vec<_>>(),
896 vec!["visible"]
897 );
898 assert!(!describe.flags.contains_key("--secret"));
899 }
900}