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 module_has_all_tags(module: &Value, tags: &[&str]) -> bool {
78 let mod_tags: Vec<&str> = module
79 .get("tags")
80 .and_then(|t| t.as_array())
81 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
82 .unwrap_or_default();
83 tags.iter().all(|required| mod_tags.contains(required))
84}
85
86#[derive(Default)]
92pub struct ListOptions<'a> {
93 pub tags: &'a [&'a str],
94 pub explicit_format: Option<&'a str>,
95 pub search: Option<&'a str>,
96 pub status: Option<&'a str>,
97 pub annotations: &'a [&'a str],
98 pub sort: Option<&'a str>,
99 pub reverse: bool,
100 pub deprecated: bool,
101}
102
103pub fn cmd_list(
116 registry: &dyn RegistryProvider,
117 tags: &[&str],
118 explicit_format: Option<&str>,
119) -> Result<String, DiscoveryError> {
120 cmd_list_enhanced(
121 registry,
122 &ListOptions {
123 tags,
124 explicit_format,
125 ..Default::default()
126 },
127 )
128}
129
130pub fn cmd_list_enhanced(
132 registry: &dyn RegistryProvider,
133 opts: &ListOptions<'_>,
134) -> Result<String, DiscoveryError> {
135 for tag in opts.tags {
137 if !validate_tag(tag) {
138 return Err(DiscoveryError::InvalidTag(tag.to_string()));
139 }
140 }
141
142 let mut modules: Vec<Value> = registry
144 .list()
145 .into_iter()
146 .filter_map(|id| registry.get_definition(&id))
147 .collect();
148
149 if !opts.tags.is_empty() {
151 modules.retain(|m| module_has_all_tags(m, opts.tags));
152 }
153
154 if let Some(query) = opts.search {
156 let q = query.to_lowercase();
157 modules.retain(|m| {
158 let id = m
159 .get("module_id")
160 .or_else(|| m.get("id"))
161 .and_then(|v| v.as_str())
162 .unwrap_or("");
163 let desc = m.get("description").and_then(|v| v.as_str()).unwrap_or("");
164 id.to_lowercase().contains(&q) || desc.to_lowercase().contains(&q)
165 });
166 }
167
168 match opts.status.unwrap_or("enabled") {
170 "enabled" => {
171 modules.retain(|m| m.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true));
172 }
173 "disabled" => {
174 modules.retain(|m| m.get("enabled").and_then(|v| v.as_bool()) == Some(false));
175 }
176 _ => {} }
178
179 if !opts.deprecated {
181 modules.retain(|m| m.get("deprecated").and_then(|v| v.as_bool()) != Some(true));
182 }
183
184 if !opts.annotations.is_empty() {
186 for ann_flag in opts.annotations {
187 let attr = match *ann_flag {
188 "requires-approval" => "requires_approval",
189 other => other,
190 };
191 modules.retain(|m| {
192 m.get("annotations")
193 .and_then(|a| a.get(attr))
194 .and_then(|v| v.as_bool())
195 == Some(true)
196 });
197 }
198 }
199
200 let requested_sort = opts.sort.unwrap_or("id");
206 if requested_sort != "id" {
207 tracing::warn!(
208 "Usage data unavailable; --sort {} ignored, sorting by id.",
209 requested_sort
210 );
211 }
212 modules.sort_by(|a, b| {
213 let aid = a.get("module_id").and_then(|v| v.as_str()).unwrap_or("");
214 let bid = b.get("module_id").and_then(|v| v.as_str()).unwrap_or("");
215 aid.cmp(bid)
216 });
217
218 if opts.reverse {
220 modules.reverse();
221 }
222
223 let fmt = crate::output::resolve_format(opts.explicit_format);
224 Ok(crate::output::format_module_list(&modules, fmt, opts.tags))
225}
226
227pub fn cmd_describe(
240 registry: &dyn RegistryProvider,
241 module_id: &str,
242 explicit_format: Option<&str>,
243) -> Result<String, DiscoveryError> {
244 if crate::cli::validate_module_id(module_id).is_err() {
246 return Err(DiscoveryError::InvalidModuleId(module_id.to_string()));
247 }
248
249 let module = registry
250 .get_definition(module_id)
251 .ok_or_else(|| DiscoveryError::ModuleNotFound(module_id.to_string()))?;
252
253 let fmt = crate::output::resolve_format(explicit_format);
254 Ok(crate::output::format_module_detail(&module, fmt))
255}
256
257pub(crate) fn register_list_command(cli: Command) -> Command {
277 cli.subcommand(list_command())
278}
279
280pub(crate) fn register_describe_command(cli: Command) -> Command {
283 cli.subcommand(describe_command())
284}
285
286pub(crate) fn register_exec_command(cli: Command) -> Command {
289 cli.subcommand(crate::cli::exec_command())
290}
291
292pub fn register_discovery_commands(cli: Command, _registry: Arc<dyn RegistryProvider>) -> Command {
309 let cli = register_list_command(cli);
310 register_describe_command(cli)
311}
312
313fn list_command() -> Command {
318 Command::new("list")
319 .about("List available modules in the registry")
320 .arg(
321 Arg::new("tag")
322 .long("tag")
323 .action(ArgAction::Append)
324 .value_name("TAG")
325 .help("Filter modules by tag (AND logic). Repeatable."),
326 )
327 .arg(
328 Arg::new("format")
329 .long("format")
330 .value_parser(clap::builder::PossibleValuesParser::new([
331 "table", "json", "csv", "yaml", "jsonl",
332 ]))
333 .value_name("FORMAT")
334 .help("Output format. Default: table (TTY) or json (non-TTY)."),
335 )
336 .arg(
337 Arg::new("search")
338 .long("search")
339 .short('s')
340 .value_name("QUERY")
341 .help("Filter by substring match on ID and description."),
342 )
343 .arg(
344 Arg::new("status")
345 .long("status")
346 .value_parser(["enabled", "disabled", "all"])
347 .default_value("enabled")
348 .value_name("STATUS")
349 .help("Filter by module status. Default: enabled."),
350 )
351 .arg(
352 Arg::new("annotation")
353 .long("annotation")
354 .short('a')
355 .action(ArgAction::Append)
356 .value_parser([
360 "destructive",
361 "requires-approval",
362 "readonly",
363 "streaming",
364 "cacheable",
365 "idempotent",
366 "paginated",
367 ])
368 .value_name("ANN")
369 .help("Filter by annotation flag (AND logic). Repeatable."),
370 )
371 .arg(
372 Arg::new("sort")
373 .long("sort")
374 .value_parser(["id", "calls", "errors", "latency"])
380 .default_value("id")
381 .value_name("FIELD")
382 .help("Sort order. Default: id. Non-id values require usage data; warns and falls back when unavailable."),
383 )
384 .arg(
385 Arg::new("reverse")
386 .long("reverse")
387 .action(ArgAction::SetTrue)
388 .help("Reverse sort order."),
389 )
390 .arg(
391 Arg::new("deprecated")
392 .long("deprecated")
393 .action(ArgAction::SetTrue)
394 .help("Include deprecated modules."),
395 )
396 .arg(
397 Arg::new("deps")
398 .long("deps")
399 .action(ArgAction::SetTrue)
400 .help("Show dependency count column."),
401 )
402 .arg(
403 Arg::new("flat")
404 .long("flat")
405 .action(ArgAction::SetTrue)
406 .help("Show flat list (no grouping)."),
407 )
408}
409
410fn describe_command() -> Command {
411 Command::new("describe")
412 .about("Show metadata, schema, and annotations for a module")
413 .arg(
414 Arg::new("module_id")
415 .required(true)
416 .value_name("MODULE_ID")
417 .help("Canonical module identifier (e.g. math.add)"),
418 )
419 .arg(
420 Arg::new("format")
421 .long("format")
422 .value_parser(clap::builder::PossibleValuesParser::new(["table", "json"]))
423 .value_name("FORMAT")
424 .help("Output format. Default: table (TTY) or json (non-TTY)."),
425 )
426}
427
428pub struct ApCoreRegistryProvider {
438 registry: std::sync::Arc<apcore::Registry>,
439 discovered_names: Vec<String>,
440 descriptions: std::collections::HashMap<String, String>,
441}
442
443impl ApCoreRegistryProvider {
444 pub fn new(registry: apcore::Registry) -> Self {
449 Self {
450 registry: std::sync::Arc::new(registry),
451 discovered_names: Vec::new(),
452 descriptions: std::collections::HashMap::new(),
453 }
454 }
455
456 pub fn set_discovered_names(&mut self, names: Vec<String>) {
458 self.discovered_names = names;
459 }
460
461 pub fn set_descriptions(&mut self, descriptions: std::collections::HashMap<String, String>) {
463 self.descriptions = descriptions;
464 }
465}
466
467impl RegistryProvider for ApCoreRegistryProvider {
468 fn list(&self) -> Vec<String> {
469 let mut ids: Vec<String> = self
470 .registry
471 .list(None, None)
472 .iter()
473 .map(|s| s.to_string())
474 .collect();
475 for name in &self.discovered_names {
476 if !ids.contains(name) {
477 ids.push(name.clone());
478 }
479 }
480 ids
481 }
482
483 fn get_definition(&self, id: &str) -> Option<Value> {
484 self.registry
485 .get_definition(id)
486 .and_then(|d| serde_json::to_value(d).ok())
487 .map(|mut v| {
488 if let Some(desc) = self.descriptions.get(id) {
491 if let Some(obj) = v.as_object_mut() {
492 obj.insert("description".to_string(), Value::String(desc.clone()));
493 }
494 }
495 v
496 })
497 }
498
499 fn get_module_descriptor(
500 &self,
501 id: &str,
502 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
503 self.registry.get_definition(id)
504 }
505}
506
507#[cfg(any(test, feature = "test-support"))]
513#[doc(hidden)]
514pub struct MockRegistry {
515 modules: Vec<Value>,
516}
517
518#[cfg(any(test, feature = "test-support"))]
519#[doc(hidden)]
520impl MockRegistry {
521 pub fn new(modules: Vec<Value>) -> Self {
522 Self { modules }
523 }
524}
525
526#[cfg(any(test, feature = "test-support"))]
527impl RegistryProvider for MockRegistry {
528 fn list(&self) -> Vec<String> {
529 self.modules
530 .iter()
531 .filter_map(|m| {
532 m.get("module_id")
533 .and_then(|v| v.as_str())
534 .map(|s| s.to_string())
535 })
536 .collect()
537 }
538
539 fn get_definition(&self, id: &str) -> Option<Value> {
540 self.modules
541 .iter()
542 .find(|m| m.get("module_id").and_then(|v| v.as_str()) == Some(id))
543 .cloned()
544 }
545}
546
547#[cfg(any(test, feature = "test-support"))]
553#[doc(hidden)]
554pub fn mock_module(id: &str, description: &str, tags: &[&str]) -> Value {
555 serde_json::json!({
556 "module_id": id,
557 "description": description,
558 "tags": tags,
559 })
560}
561
562#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
573 fn test_validate_tag_valid_simple() {
574 assert!(validate_tag("math"), "single lowercase word must be valid");
575 }
576
577 #[test]
578 fn test_validate_tag_valid_with_digits_and_dash() {
579 assert!(validate_tag("ml-v2"), "digits and dash must be valid");
580 }
581
582 #[test]
583 fn test_validate_tag_valid_with_underscore() {
584 assert!(validate_tag("core_util"), "underscore must be valid");
585 }
586
587 #[test]
588 fn test_validate_tag_invalid_uppercase() {
589 assert!(!validate_tag("Math"), "uppercase start must be invalid");
590 }
591
592 #[test]
593 fn test_validate_tag_invalid_starts_with_digit() {
594 assert!(!validate_tag("1tag"), "digit start must be invalid");
595 }
596
597 #[test]
598 fn test_validate_tag_invalid_special_chars() {
599 assert!(!validate_tag("invalid!"), "special chars must be invalid");
600 }
601
602 #[test]
603 fn test_validate_tag_invalid_empty() {
604 assert!(!validate_tag(""), "empty string must be invalid");
605 }
606
607 #[test]
608 fn test_validate_tag_invalid_space() {
609 assert!(!validate_tag("has space"), "space must be invalid");
610 }
611
612 #[test]
615 fn test_mock_registry_list_returns_ids() {
616 let registry = MockRegistry::new(vec![
617 mock_module("math.add", "Add numbers", &["math", "core"]),
618 mock_module("text.upper", "Uppercase text", &["text"]),
619 ]);
620 let ids = registry.list();
621 assert_eq!(ids.len(), 2);
622 assert!(ids.contains(&"math.add".to_string()));
623 }
624
625 #[test]
626 fn test_mock_registry_get_definition_found() {
627 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
628 let def = registry.get_definition("math.add");
629 assert!(def.is_some());
630 assert_eq!(def.unwrap()["module_id"], "math.add");
631 }
632
633 #[test]
634 fn test_mock_registry_get_definition_not_found() {
635 let registry = MockRegistry::new(vec![]);
636 assert!(registry.get_definition("non.existent").is_none());
637 }
638
639 #[test]
642 fn test_cmd_list_all_modules_no_filter() {
643 let registry = MockRegistry::new(vec![
644 mock_module("math.add", "Add numbers", &["math", "core"]),
645 mock_module("text.upper", "Uppercase text", &["text"]),
646 ]);
647 let output = cmd_list(®istry, &[], Some("json")).unwrap();
648 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
649 let arr = parsed.as_array().unwrap();
650 assert_eq!(arr.len(), 2);
651 }
652
653 #[test]
654 fn test_cmd_list_empty_registry_table() {
655 let registry = MockRegistry::new(vec![]);
656 let output = cmd_list(®istry, &[], Some("table")).unwrap();
657 assert_eq!(output.trim(), "No modules found.");
658 }
659
660 #[test]
661 fn test_cmd_list_empty_registry_json() {
662 let registry = MockRegistry::new(vec![]);
663 let output = cmd_list(®istry, &[], Some("json")).unwrap();
664 assert_eq!(output.trim(), "[]");
665 }
666
667 #[test]
668 fn test_cmd_list_tag_filter_single_match() {
669 let registry = MockRegistry::new(vec![
670 mock_module("math.add", "Add numbers", &["math", "core"]),
671 mock_module("text.upper", "Uppercase text", &["text"]),
672 ]);
673 let output = cmd_list(®istry, &["math"], Some("json")).unwrap();
674 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
675 let arr = parsed.as_array().unwrap();
676 assert_eq!(arr.len(), 1);
677 assert_eq!(arr[0]["id"], "math.add");
678 }
679
680 #[test]
681 fn test_cmd_list_tag_filter_and_semantics() {
682 let registry = MockRegistry::new(vec![
683 mock_module("math.add", "Add numbers", &["math", "core"]),
684 mock_module("math.mul", "Multiply", &["math"]),
685 ]);
686 let output = cmd_list(®istry, &["math", "core"], Some("json")).unwrap();
688 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
689 let arr = parsed.as_array().unwrap();
690 assert_eq!(arr.len(), 1);
691 assert_eq!(arr[0]["id"], "math.add");
692 }
693
694 #[test]
695 fn test_cmd_list_tag_filter_no_match_table() {
696 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
697 let output = cmd_list(®istry, &["nonexistent"], Some("table")).unwrap();
698 assert!(output.contains("No modules found matching tags:"));
699 assert!(output.contains("nonexistent"));
700 }
701
702 #[test]
703 fn test_cmd_list_tag_filter_no_match_json() {
704 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
705 let output = cmd_list(®istry, &["nonexistent"], Some("json")).unwrap();
706 assert_eq!(output.trim(), "[]");
707 }
708
709 #[test]
710 fn test_cmd_list_invalid_tag_format_returns_error() {
711 let registry = MockRegistry::new(vec![]);
712 let result = cmd_list(®istry, &["INVALID!"], Some("json"));
713 assert!(result.is_err());
714 match result.unwrap_err() {
715 DiscoveryError::InvalidTag(tag) => assert_eq!(tag, "INVALID!"),
716 other => panic!("unexpected error: {other}"),
717 }
718 }
719
720 #[test]
721 fn test_cmd_list_description_truncated_in_table() {
722 let long_desc = "x".repeat(100);
723 let registry = MockRegistry::new(vec![mock_module("a.b", &long_desc, &[])]);
724 let output = cmd_list(®istry, &[], Some("table")).unwrap();
725 assert!(output.contains("..."), "long description must be truncated");
726 assert!(
727 !output.contains(&"x".repeat(100)),
728 "full description must not appear"
729 );
730 }
731
732 #[test]
733 fn test_cmd_list_json_contains_id_description_tags() {
734 let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &["x", "y"])]);
735 let output = cmd_list(®istry, &[], Some("json")).unwrap();
736 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
737 let entry = &parsed[0];
738 assert!(entry.get("id").is_some());
739 assert!(entry.get("description").is_some());
740 assert!(entry.get("tags").is_some());
741 }
742
743 #[test]
746 fn test_cmd_describe_valid_module_json() {
747 let registry = MockRegistry::new(vec![mock_module(
748 "math.add",
749 "Add two numbers",
750 &["math", "core"],
751 )]);
752 let output = cmd_describe(®istry, "math.add", Some("json")).unwrap();
753 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
754 assert_eq!(parsed["id"], "math.add");
755 assert_eq!(parsed["description"], "Add two numbers");
756 }
757
758 #[test]
759 fn test_cmd_describe_valid_module_table() {
760 let registry =
761 MockRegistry::new(vec![mock_module("math.add", "Add two numbers", &["math"])]);
762 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
763 assert!(output.contains("math.add"), "table must contain module id");
764 assert!(
765 output.contains("Add two numbers"),
766 "table must contain description"
767 );
768 }
769
770 #[test]
771 fn test_cmd_describe_not_found_returns_error() {
772 let registry = MockRegistry::new(vec![]);
773 let result = cmd_describe(®istry, "non.existent", Some("json"));
774 assert!(result.is_err());
775 match result.unwrap_err() {
776 DiscoveryError::ModuleNotFound(id) => assert_eq!(id, "non.existent"),
777 other => panic!("unexpected error: {other}"),
778 }
779 }
780
781 #[test]
782 fn test_cmd_describe_invalid_id_returns_error() {
783 let registry = MockRegistry::new(vec![]);
784 let result = cmd_describe(®istry, "INVALID!ID", Some("json"));
785 assert!(result.is_err());
786 match result.unwrap_err() {
787 DiscoveryError::InvalidModuleId(_) => {}
788 other => panic!("unexpected error: {other}"),
789 }
790 }
791
792 #[test]
793 fn test_cmd_describe_no_output_schema_table_omits_section() {
794 let registry = MockRegistry::new(vec![serde_json::json!({
796 "module_id": "math.add",
797 "description": "Add numbers",
798 "input_schema": {"type": "object"},
799 "tags": ["math"]
800 })]);
802 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
803 assert!(
804 !output.contains("Output Schema:"),
805 "output_schema section must be absent"
806 );
807 }
808
809 #[test]
810 fn test_cmd_describe_no_annotations_table_omits_section() {
811 let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
812 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
813 assert!(
814 !output.contains("Annotations:"),
815 "annotations section must be absent"
816 );
817 }
818
819 #[test]
820 fn test_cmd_describe_with_annotations_table_shows_section() {
821 let registry = MockRegistry::new(vec![serde_json::json!({
822 "module_id": "math.add",
823 "description": "Add numbers",
824 "annotations": {"readonly": true},
825 "tags": []
826 })]);
827 let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
828 assert!(
829 output.contains("Annotations:"),
830 "annotations section must be present"
831 );
832 assert!(output.contains("readonly"), "annotation key must appear");
833 }
834
835 #[test]
836 fn test_cmd_describe_json_omits_null_fields() {
837 let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
839 let output = cmd_describe(®istry, "a.b", Some("json")).unwrap();
840 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
841 assert!(parsed.get("input_schema").is_none());
842 assert!(parsed.get("output_schema").is_none());
843 assert!(parsed.get("annotations").is_none());
844 }
845
846 #[test]
847 fn test_cmd_describe_json_includes_all_fields() {
848 let registry = MockRegistry::new(vec![serde_json::json!({
849 "module_id": "math.add",
850 "description": "Add two numbers",
851 "input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
852 "output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
853 "annotations": {"readonly": false},
854 "tags": ["math", "core"]
855 })]);
856 let output = cmd_describe(®istry, "math.add", Some("json")).unwrap();
857 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
858 assert!(parsed.get("input_schema").is_some());
859 assert!(parsed.get("output_schema").is_some());
860 assert!(parsed.get("annotations").is_some());
861 assert!(parsed.get("tags").is_some());
862 }
863
864 #[test]
865 fn test_cmd_describe_with_x_fields_table_shows_extension_section() {
866 let registry = MockRegistry::new(vec![serde_json::json!({
867 "module_id": "a.b",
868 "description": "Desc",
869 "x-custom": "custom-value",
870 "tags": []
871 })]);
872 let output = cmd_describe(®istry, "a.b", Some("table")).unwrap();
873 assert!(
874 output.contains("Extension Metadata:") || output.contains("x-custom"),
875 "x-fields must appear in table output"
876 );
877 }
878
879 #[test]
882 fn test_register_discovery_commands_adds_list() {
883 use std::sync::Arc;
884 let registry = Arc::new(MockRegistry::new(vec![]));
885 let root = Command::new("apcore-cli");
886 let cmd = register_discovery_commands(root, registry);
887 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
888 assert!(
889 names.contains(&"list"),
890 "must have 'list' subcommand, got {names:?}"
891 );
892 }
893
894 #[test]
895 fn test_register_discovery_commands_adds_describe() {
896 use std::sync::Arc;
897 let registry = Arc::new(MockRegistry::new(vec![]));
898 let root = Command::new("apcore-cli");
899 let cmd = register_discovery_commands(root, registry);
900 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
901 assert!(
902 names.contains(&"describe"),
903 "must have 'describe' subcommand, got {names:?}"
904 );
905 }
906
907 #[test]
908 fn test_list_command_with_tag_filter() {
909 let cmd = list_command();
910 let arg_names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
911 assert!(arg_names.contains(&"tag"), "list must have --tag flag");
912 }
913
914 #[test]
915 fn test_describe_command_module_not_found() {
916 let cmd = describe_command();
918 let positionals: Vec<&str> = cmd
919 .get_positionals()
920 .filter_map(|a| a.get_id().as_str().into())
921 .collect();
922 assert!(
923 positionals.contains(&"module_id"),
924 "describe must have module_id positional, got {positionals:?}"
925 );
926 }
927
928 fn find_subcommand<'a>(cmd: &'a Command, name: &str) -> Option<&'a Command> {
931 cmd.get_subcommands().find(|c| c.get_name() == name)
932 }
933
934 #[test]
935 fn test_register_list_command_attaches_list() {
936 let root = Command::new("apcli");
937 let cmd = register_list_command(root);
938 let list = find_subcommand(&cmd, "list").expect("'list' subcommand must be attached");
939 let long_flags: Vec<&str> = list.get_opts().filter_map(|a| a.get_long()).collect();
940 assert!(
941 long_flags.contains(&"tag"),
942 "'list' must expose --tag flag, got {long_flags:?}"
943 );
944 }
945
946 #[test]
947 fn test_register_describe_command_attaches_describe() {
948 let root = Command::new("apcli");
949 let cmd = register_describe_command(root);
950 let describe =
951 find_subcommand(&cmd, "describe").expect("'describe' subcommand must be attached");
952 let positionals: Vec<&str> = describe
953 .get_positionals()
954 .map(|a| a.get_id().as_str())
955 .collect();
956 assert!(
957 positionals.contains(&"module_id"),
958 "'describe' must require module_id positional, got {positionals:?}"
959 );
960 let module_id_arg = describe
961 .get_arguments()
962 .find(|a| a.get_id().as_str() == "module_id")
963 .expect("module_id arg must exist");
964 assert!(
965 module_id_arg.is_required_set(),
966 "'describe' module_id positional must be required"
967 );
968 }
969
970 #[test]
971 fn test_register_exec_command_attaches_exec() {
972 let root = Command::new("apcli");
973 let cmd = register_exec_command(root);
974 let exec = find_subcommand(&cmd, "exec").expect("'exec' subcommand must be attached");
975 let positionals: Vec<&str> = exec
976 .get_positionals()
977 .map(|a| a.get_id().as_str())
978 .collect();
979 assert!(
980 positionals.contains(&"module_id"),
981 "'exec' must require module_id positional, got {positionals:?}"
982 );
983 let module_id_arg = exec
984 .get_arguments()
985 .find(|a| a.get_id().as_str() == "module_id")
986 .expect("module_id arg must exist");
987 assert!(
988 module_id_arg.is_required_set(),
989 "'exec' module_id positional must be required"
990 );
991 }
992
993 #[test]
994 fn test_register_validate_command_attaches_validate() {
995 let root = Command::new("apcli");
996 let cmd = crate::validate::register_validate_command(root);
997 assert!(
998 find_subcommand(&cmd, "validate").is_some(),
999 "'validate' subcommand must be attached"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_per_subcommand_registrars_can_be_called_independently() {
1005 let root = Command::new("apcli");
1008 let cmd = register_list_command(root);
1009 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
1010 assert!(
1011 names.contains(&"list"),
1012 "'list' must be present, got {names:?}"
1013 );
1014 assert!(
1015 !names.contains(&"describe"),
1016 "'describe' must NOT be present when only list was registered, got {names:?}"
1017 );
1018 assert!(
1019 !names.contains(&"exec"),
1020 "'exec' must NOT be present when only list was registered, got {names:?}"
1021 );
1022 assert!(
1023 !names.contains(&"validate"),
1024 "'validate' must NOT be present when only list was registered, got {names:?}"
1025 );
1026 }
1027}