1use std::sync::Arc;
5
6use clap::{Arg, ArgAction, Command};
7use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
16pub enum DiscoveryError {
17 #[error("module '{0}' not found")]
18 ModuleNotFound(String),
19
20 #[error("invalid module id: {0}")]
21 InvalidModuleId(String),
22
23 #[error("invalid tag format: '{0}'. Tags must match [a-z][a-z0-9_-]*.")]
24 InvalidTag(String),
25}
26
27pub trait RegistryProvider: Send + Sync {
38 fn list(&self) -> Vec<String>;
40
41 fn get_definition(&self, id: &str) -> Option<Value>;
43
44 fn get_module_descriptor(
49 &self,
50 id: &str,
51 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
52 self.get_definition(id)
53 .and_then(|v| serde_json::from_value(v).ok())
54 }
55}
56
57pub fn validate_tag(tag: &str) -> bool {
65 let mut chars = tag.chars();
66 match chars.next() {
67 Some(c) if c.is_ascii_lowercase() => {}
68 _ => return false,
69 }
70 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
71}
72
73fn validate_module_id_discovery(id: &str) -> bool {
78 crate::cli::validate_module_id(id).is_ok()
80}
81
82fn module_has_all_tags(module: &Value, tags: &[&str]) -> bool {
87 let mod_tags: Vec<&str> = module
88 .get("tags")
89 .and_then(|t| t.as_array())
90 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
91 .unwrap_or_default();
92 tags.iter().all(|required| mod_tags.contains(required))
93}
94
95#[derive(Default)]
101pub struct ListOptions<'a> {
102 pub tags: &'a [&'a str],
103 pub explicit_format: Option<&'a str>,
104 pub search: Option<&'a str>,
105 pub status: Option<&'a str>,
106 pub annotations: &'a [&'a str],
107 pub sort: Option<&'a str>,
108 pub reverse: bool,
109 pub deprecated: bool,
110}
111
112pub fn cmd_list(
119 registry: &dyn RegistryProvider,
120 tags: &[&str],
121 explicit_format: Option<&str>,
122) -> Result<String, DiscoveryError> {
123 cmd_list_enhanced(
124 registry,
125 &ListOptions {
126 tags,
127 explicit_format,
128 ..Default::default()
129 },
130 )
131}
132
133pub fn cmd_list_enhanced(
135 registry: &dyn RegistryProvider,
136 opts: &ListOptions<'_>,
137) -> Result<String, DiscoveryError> {
138 for tag in opts.tags {
140 if !validate_tag(tag) {
141 return Err(DiscoveryError::InvalidTag(tag.to_string()));
142 }
143 }
144
145 let mut modules: Vec<Value> = registry
147 .list()
148 .into_iter()
149 .filter_map(|id| registry.get_definition(&id))
150 .collect();
151
152 if !opts.tags.is_empty() {
154 modules.retain(|m| module_has_all_tags(m, opts.tags));
155 }
156
157 if let Some(query) = opts.search {
159 let q = query.to_lowercase();
160 modules.retain(|m| {
161 let id = m
162 .get("module_id")
163 .or_else(|| m.get("id"))
164 .and_then(|v| v.as_str())
165 .unwrap_or("");
166 let desc = m.get("description").and_then(|v| v.as_str()).unwrap_or("");
167 id.to_lowercase().contains(&q) || desc.to_lowercase().contains(&q)
168 });
169 }
170
171 match opts.status.unwrap_or("enabled") {
173 "enabled" => {
174 modules.retain(|m| m.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true));
175 }
176 "disabled" => {
177 modules.retain(|m| m.get("enabled").and_then(|v| v.as_bool()) == Some(false));
178 }
179 _ => {} }
181
182 if !opts.deprecated {
184 modules.retain(|m| m.get("deprecated").and_then(|v| v.as_bool()) != Some(true));
185 }
186
187 if !opts.annotations.is_empty() {
189 for ann_flag in opts.annotations {
190 let attr = match *ann_flag {
191 "requires-approval" => "requires_approval",
192 other => other,
193 };
194 modules.retain(|m| {
195 m.get("annotations")
196 .and_then(|a| a.get(attr))
197 .and_then(|v| v.as_bool())
198 == Some(true)
199 });
200 }
201 }
202
203 let sort_key = opts.sort.unwrap_or("id");
205 if matches!(sort_key, "calls" | "errors" | "latency") {
206 eprintln!(
207 "Warning: Usage data not available; sorting by id. Sort by {} requires system.usage modules.",
208 sort_key
209 );
210 }
211 modules.sort_by(|a, b| {
212 let aid = a.get("module_id").and_then(|v| v.as_str()).unwrap_or("");
213 let bid = b.get("module_id").and_then(|v| v.as_str()).unwrap_or("");
214 aid.cmp(bid)
215 });
216
217 if opts.reverse {
219 modules.reverse();
220 }
221
222 let fmt = crate::output::resolve_format(opts.explicit_format);
223 Ok(crate::output::format_module_list(&modules, fmt, opts.tags))
224}
225
226pub fn cmd_describe(
239 registry: &dyn RegistryProvider,
240 module_id: &str,
241 explicit_format: Option<&str>,
242) -> Result<String, DiscoveryError> {
243 if !validate_module_id_discovery(module_id) {
245 return Err(DiscoveryError::InvalidModuleId(module_id.to_string()));
246 }
247
248 let module = registry
249 .get_definition(module_id)
250 .ok_or_else(|| DiscoveryError::ModuleNotFound(module_id.to_string()))?;
251
252 let fmt = crate::output::resolve_format(explicit_format);
253 Ok(crate::output::format_module_detail(&module, fmt))
254}
255
256pub fn register_discovery_commands(cli: Command, _registry: Arc<dyn RegistryProvider>) -> Command {
265 cli.subcommand(list_command())
266 .subcommand(describe_command())
267}
268
269fn list_command() -> Command {
274 Command::new("list")
275 .about("List available modules in the registry")
276 .arg(
277 Arg::new("tag")
278 .long("tag")
279 .action(ArgAction::Append)
280 .value_name("TAG")
281 .help("Filter modules by tag (AND logic). Repeatable."),
282 )
283 .arg(
284 Arg::new("format")
285 .long("format")
286 .value_parser(clap::builder::PossibleValuesParser::new([
287 "table", "json", "csv", "yaml", "jsonl",
288 ]))
289 .value_name("FORMAT")
290 .help("Output format. Default: table (TTY) or json (non-TTY)."),
291 )
292 .arg(
293 Arg::new("search")
294 .long("search")
295 .short('s')
296 .value_name("QUERY")
297 .help("Filter by substring match on ID and description."),
298 )
299 .arg(
300 Arg::new("status")
301 .long("status")
302 .value_parser(["enabled", "disabled", "all"])
303 .default_value("enabled")
304 .value_name("STATUS")
305 .help("Filter by module status. Default: enabled."),
306 )
307 .arg(
308 Arg::new("annotation")
309 .long("annotation")
310 .short('a')
311 .action(ArgAction::Append)
312 .value_parser([
313 "destructive",
314 "requires-approval",
315 "readonly",
316 "streaming",
317 "cacheable",
318 "idempotent",
319 ])
320 .value_name("ANN")
321 .help("Filter by annotation flag (AND logic). Repeatable."),
322 )
323 .arg(
324 Arg::new("sort")
325 .long("sort")
326 .value_parser(["id", "calls", "errors", "latency"])
327 .default_value("id")
328 .value_name("FIELD")
329 .help("Sort order. Default: id."),
330 )
331 .arg(
332 Arg::new("reverse")
333 .long("reverse")
334 .action(ArgAction::SetTrue)
335 .help("Reverse sort order."),
336 )
337 .arg(
338 Arg::new("deprecated")
339 .long("deprecated")
340 .action(ArgAction::SetTrue)
341 .help("Include deprecated modules."),
342 )
343 .arg(
344 Arg::new("deps")
345 .long("deps")
346 .action(ArgAction::SetTrue)
347 .help("Show dependency count column."),
348 )
349 .arg(
350 Arg::new("flat")
351 .long("flat")
352 .action(ArgAction::SetTrue)
353 .help("Show flat list (no grouping)."),
354 )
355}
356
357fn describe_command() -> Command {
358 Command::new("describe")
359 .about("Show metadata, schema, and annotations for a module")
360 .arg(
361 Arg::new("module_id")
362 .required(true)
363 .value_name("MODULE_ID")
364 .help("Canonical module identifier (e.g. math.add)"),
365 )
366 .arg(
367 Arg::new("format")
368 .long("format")
369 .value_parser(clap::builder::PossibleValuesParser::new(["table", "json"]))
370 .value_name("FORMAT")
371 .help("Output format. Default: table (TTY) or json (non-TTY)."),
372 )
373}
374
375pub struct ApCoreRegistryProvider {
385 registry: apcore::Registry,
386 discovered_names: Vec<String>,
387 descriptions: std::collections::HashMap<String, String>,
388}
389
390impl ApCoreRegistryProvider {
391 pub fn new(registry: apcore::Registry) -> Self {
393 Self {
394 registry,
395 discovered_names: Vec::new(),
396 descriptions: std::collections::HashMap::new(),
397 }
398 }
399
400 pub fn set_discovered_names(&mut self, names: Vec<String>) {
402 self.discovered_names = names;
403 }
404
405 pub fn set_descriptions(&mut self, descriptions: std::collections::HashMap<String, String>) {
407 self.descriptions = descriptions;
408 }
409}
410
411impl RegistryProvider for ApCoreRegistryProvider {
412 fn list(&self) -> Vec<String> {
413 let mut ids: Vec<String> = self
414 .registry
415 .list(None, None)
416 .iter()
417 .map(|s| s.to_string())
418 .collect();
419 for name in &self.discovered_names {
420 if !ids.contains(name) {
421 ids.push(name.clone());
422 }
423 }
424 ids
425 }
426
427 fn get_definition(&self, id: &str) -> Option<Value> {
428 self.registry
429 .get_definition(id)
430 .and_then(|d| serde_json::to_value(d).ok())
431 .map(|mut v| {
432 if let Some(desc) = self.descriptions.get(id) {
435 if let Some(obj) = v.as_object_mut() {
436 obj.insert("description".to_string(), Value::String(desc.clone()));
437 }
438 }
439 v
440 })
441 }
442
443 fn get_module_descriptor(
444 &self,
445 id: &str,
446 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
447 self.registry.get_definition(id).cloned()
448 }
449}
450
451#[cfg(any(test, feature = "test-support"))]
457#[doc(hidden)]
458pub struct MockRegistry {
459 modules: Vec<Value>,
460}
461
462#[cfg(any(test, feature = "test-support"))]
463#[doc(hidden)]
464impl MockRegistry {
465 pub fn new(modules: Vec<Value>) -> Self {
466 Self { modules }
467 }
468}
469
470#[cfg(any(test, feature = "test-support"))]
471impl RegistryProvider for MockRegistry {
472 fn list(&self) -> Vec<String> {
473 self.modules
474 .iter()
475 .filter_map(|m| {
476 m.get("module_id")
477 .and_then(|v| v.as_str())
478 .map(|s| s.to_string())
479 })
480 .collect()
481 }
482
483 fn get_definition(&self, id: &str) -> Option<Value> {
484 self.modules
485 .iter()
486 .find(|m| m.get("module_id").and_then(|v| v.as_str()) == Some(id))
487 .cloned()
488 }
489}
490
491#[cfg(any(test, feature = "test-support"))]
497#[doc(hidden)]
498pub fn mock_module(id: &str, description: &str, tags: &[&str]) -> Value {
499 serde_json::json!({
500 "module_id": id,
501 "description": description,
502 "tags": tags,
503 })
504}
505
506#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
517 fn test_validate_tag_valid_simple() {
518 assert!(validate_tag("math"), "single lowercase word must be valid");
519 }
520
521 #[test]
522 fn test_validate_tag_valid_with_digits_and_dash() {
523 assert!(validate_tag("ml-v2"), "digits and dash must be valid");
524 }
525
526 #[test]
527 fn test_validate_tag_valid_with_underscore() {
528 assert!(validate_tag("core_util"), "underscore must be valid");
529 }
530
531 #[test]
532 fn test_validate_tag_invalid_uppercase() {
533 assert!(!validate_tag("Math"), "uppercase start must be invalid");
534 }
535
536 #[test]
537 fn test_validate_tag_invalid_starts_with_digit() {
538 assert!(!validate_tag("1tag"), "digit start must be invalid");
539 }
540
541 #[test]
542 fn test_validate_tag_invalid_special_chars() {
543 assert!(!validate_tag("invalid!"), "special chars must be invalid");
544 }
545
546 #[test]
547 fn test_validate_tag_invalid_empty() {
548 assert!(!validate_tag(""), "empty string must be invalid");
549 }
550
551 #[test]
552 fn test_validate_tag_invalid_space() {
553 assert!(!validate_tag("has space"), "space must be invalid");
554 }
555
556 #[test]
559 fn test_mock_registry_list_returns_ids() {
560 let registry = MockRegistry::new(vec![
561 mock_module("math.add", "Add numbers", &["math", "core"]),
562 mock_module("text.upper", "Uppercase text", &["text"]),
563 ]);
564 let ids = registry.list();
565 assert_eq!(ids.len(), 2);
566 assert!(ids.contains(&"math.add".to_string()));
567 }
568
569 #[test]
570 fn test_mock_registry_get_definition_found() {
571 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
572 let def = registry.get_definition("math.add");
573 assert!(def.is_some());
574 assert_eq!(def.unwrap()["module_id"], "math.add");
575 }
576
577 #[test]
578 fn test_mock_registry_get_definition_not_found() {
579 let registry = MockRegistry::new(vec![]);
580 assert!(registry.get_definition("non.existent").is_none());
581 }
582
583 #[test]
586 fn test_cmd_list_all_modules_no_filter() {
587 let registry = MockRegistry::new(vec![
588 mock_module("math.add", "Add numbers", &["math", "core"]),
589 mock_module("text.upper", "Uppercase text", &["text"]),
590 ]);
591 let output = cmd_list(®istry, &[], Some("json")).unwrap();
592 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
593 let arr = parsed.as_array().unwrap();
594 assert_eq!(arr.len(), 2);
595 }
596
597 #[test]
598 fn test_cmd_list_empty_registry_table() {
599 let registry = MockRegistry::new(vec![]);
600 let output = cmd_list(®istry, &[], Some("table")).unwrap();
601 assert_eq!(output.trim(), "No modules found.");
602 }
603
604 #[test]
605 fn test_cmd_list_empty_registry_json() {
606 let registry = MockRegistry::new(vec![]);
607 let output = cmd_list(®istry, &[], Some("json")).unwrap();
608 assert_eq!(output.trim(), "[]");
609 }
610
611 #[test]
612 fn test_cmd_list_tag_filter_single_match() {
613 let registry = MockRegistry::new(vec![
614 mock_module("math.add", "Add numbers", &["math", "core"]),
615 mock_module("text.upper", "Uppercase text", &["text"]),
616 ]);
617 let output = cmd_list(®istry, &["math"], Some("json")).unwrap();
618 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
619 let arr = parsed.as_array().unwrap();
620 assert_eq!(arr.len(), 1);
621 assert_eq!(arr[0]["id"], "math.add");
622 }
623
624 #[test]
625 fn test_cmd_list_tag_filter_and_semantics() {
626 let registry = MockRegistry::new(vec![
627 mock_module("math.add", "Add numbers", &["math", "core"]),
628 mock_module("math.mul", "Multiply", &["math"]),
629 ]);
630 let output = cmd_list(®istry, &["math", "core"], Some("json")).unwrap();
632 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
633 let arr = parsed.as_array().unwrap();
634 assert_eq!(arr.len(), 1);
635 assert_eq!(arr[0]["id"], "math.add");
636 }
637
638 #[test]
639 fn test_cmd_list_tag_filter_no_match_table() {
640 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
641 let output = cmd_list(®istry, &["nonexistent"], Some("table")).unwrap();
642 assert!(output.contains("No modules found matching tags:"));
643 assert!(output.contains("nonexistent"));
644 }
645
646 #[test]
647 fn test_cmd_list_tag_filter_no_match_json() {
648 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
649 let output = cmd_list(®istry, &["nonexistent"], Some("json")).unwrap();
650 assert_eq!(output.trim(), "[]");
651 }
652
653 #[test]
654 fn test_cmd_list_invalid_tag_format_returns_error() {
655 let registry = MockRegistry::new(vec![]);
656 let result = cmd_list(®istry, &["INVALID!"], Some("json"));
657 assert!(result.is_err());
658 match result.unwrap_err() {
659 DiscoveryError::InvalidTag(tag) => assert_eq!(tag, "INVALID!"),
660 other => panic!("unexpected error: {other}"),
661 }
662 }
663
664 #[test]
665 fn test_cmd_list_description_truncated_in_table() {
666 let long_desc = "x".repeat(100);
667 let registry = MockRegistry::new(vec![mock_module("a.b", &long_desc, &[])]);
668 let output = cmd_list(®istry, &[], Some("table")).unwrap();
669 assert!(output.contains("..."), "long description must be truncated");
670 assert!(
671 !output.contains(&"x".repeat(100)),
672 "full description must not appear"
673 );
674 }
675
676 #[test]
677 fn test_cmd_list_json_contains_id_description_tags() {
678 let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &["x", "y"])]);
679 let output = cmd_list(®istry, &[], Some("json")).unwrap();
680 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
681 let entry = &parsed[0];
682 assert!(entry.get("id").is_some());
683 assert!(entry.get("description").is_some());
684 assert!(entry.get("tags").is_some());
685 }
686
687 #[test]
690 fn test_cmd_describe_valid_module_json() {
691 let registry = MockRegistry::new(vec![mock_module(
692 "math.add",
693 "Add two numbers",
694 &["math", "core"],
695 )]);
696 let output = cmd_describe(®istry, "math.add", Some("json")).unwrap();
697 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
698 assert_eq!(parsed["id"], "math.add");
699 assert_eq!(parsed["description"], "Add two numbers");
700 }
701
702 #[test]
703 fn test_cmd_describe_valid_module_table() {
704 let registry =
705 MockRegistry::new(vec![mock_module("math.add", "Add two numbers", &["math"])]);
706 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
707 assert!(output.contains("math.add"), "table must contain module id");
708 assert!(
709 output.contains("Add two numbers"),
710 "table must contain description"
711 );
712 }
713
714 #[test]
715 fn test_cmd_describe_not_found_returns_error() {
716 let registry = MockRegistry::new(vec![]);
717 let result = cmd_describe(®istry, "non.existent", Some("json"));
718 assert!(result.is_err());
719 match result.unwrap_err() {
720 DiscoveryError::ModuleNotFound(id) => assert_eq!(id, "non.existent"),
721 other => panic!("unexpected error: {other}"),
722 }
723 }
724
725 #[test]
726 fn test_cmd_describe_invalid_id_returns_error() {
727 let registry = MockRegistry::new(vec![]);
728 let result = cmd_describe(®istry, "INVALID!ID", Some("json"));
729 assert!(result.is_err());
730 match result.unwrap_err() {
731 DiscoveryError::InvalidModuleId(_) => {}
732 other => panic!("unexpected error: {other}"),
733 }
734 }
735
736 #[test]
737 fn test_cmd_describe_no_output_schema_table_omits_section() {
738 let registry = MockRegistry::new(vec![serde_json::json!({
740 "module_id": "math.add",
741 "description": "Add numbers",
742 "input_schema": {"type": "object"},
743 "tags": ["math"]
744 })]);
746 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
747 assert!(
748 !output.contains("Output Schema:"),
749 "output_schema section must be absent"
750 );
751 }
752
753 #[test]
754 fn test_cmd_describe_no_annotations_table_omits_section() {
755 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
756 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
757 assert!(
758 !output.contains("Annotations:"),
759 "annotations section must be absent"
760 );
761 }
762
763 #[test]
764 fn test_cmd_describe_with_annotations_table_shows_section() {
765 let registry = MockRegistry::new(vec![serde_json::json!({
766 "module_id": "math.add",
767 "description": "Add numbers",
768 "annotations": {"readonly": true},
769 "tags": []
770 })]);
771 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
772 assert!(
773 output.contains("Annotations:"),
774 "annotations section must be present"
775 );
776 assert!(output.contains("readonly"), "annotation key must appear");
777 }
778
779 #[test]
780 fn test_cmd_describe_json_omits_null_fields() {
781 let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
783 let output = cmd_describe(®istry, "a.b", Some("json")).unwrap();
784 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
785 assert!(parsed.get("input_schema").is_none());
786 assert!(parsed.get("output_schema").is_none());
787 assert!(parsed.get("annotations").is_none());
788 }
789
790 #[test]
791 fn test_cmd_describe_json_includes_all_fields() {
792 let registry = MockRegistry::new(vec![serde_json::json!({
793 "module_id": "math.add",
794 "description": "Add two numbers",
795 "input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
796 "output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
797 "annotations": {"readonly": false},
798 "tags": ["math", "core"]
799 })]);
800 let output = cmd_describe(®istry, "math.add", Some("json")).unwrap();
801 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
802 assert!(parsed.get("input_schema").is_some());
803 assert!(parsed.get("output_schema").is_some());
804 assert!(parsed.get("annotations").is_some());
805 assert!(parsed.get("tags").is_some());
806 }
807
808 #[test]
809 fn test_cmd_describe_with_x_fields_table_shows_extension_section() {
810 let registry = MockRegistry::new(vec![serde_json::json!({
811 "module_id": "a.b",
812 "description": "Desc",
813 "x-custom": "custom-value",
814 "tags": []
815 })]);
816 let output = cmd_describe(®istry, "a.b", Some("table")).unwrap();
817 assert!(
818 output.contains("Extension Metadata:") || output.contains("x-custom"),
819 "x-fields must appear in table output"
820 );
821 }
822
823 #[test]
826 fn test_register_discovery_commands_adds_list() {
827 use std::sync::Arc;
828 let registry = Arc::new(MockRegistry::new(vec![]));
829 let root = Command::new("apcore-cli");
830 let cmd = register_discovery_commands(root, registry);
831 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
832 assert!(
833 names.contains(&"list"),
834 "must have 'list' subcommand, got {names:?}"
835 );
836 }
837
838 #[test]
839 fn test_register_discovery_commands_adds_describe() {
840 use std::sync::Arc;
841 let registry = Arc::new(MockRegistry::new(vec![]));
842 let root = Command::new("apcore-cli");
843 let cmd = register_discovery_commands(root, registry);
844 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
845 assert!(
846 names.contains(&"describe"),
847 "must have 'describe' subcommand, got {names:?}"
848 );
849 }
850
851 #[test]
852 fn test_list_command_with_tag_filter() {
853 let cmd = list_command();
854 let arg_names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
855 assert!(arg_names.contains(&"tag"), "list must have --tag flag");
856 }
857
858 #[test]
859 fn test_describe_command_module_not_found() {
860 let cmd = describe_command();
862 let positionals: Vec<&str> = cmd
863 .get_positionals()
864 .filter_map(|a| a.get_id().as_str().into())
865 .collect();
866 assert!(
867 positionals.contains(&"module_id"),
868 "describe must have module_id positional, got {positionals:?}"
869 );
870 }
871}