Skip to main content

apcore_cli/
discovery.rs

1// apcore-cli — Discovery subcommands (list + describe).
2// Protocol spec: FE-04
3
4use std::sync::Arc;
5
6use clap::{Arg, ArgAction, Command};
7use serde_json::Value;
8use thiserror::Error;
9
10// ---------------------------------------------------------------------------
11// DiscoveryError
12// ---------------------------------------------------------------------------
13
14/// Errors produced by discovery command handlers.
15#[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
27// ---------------------------------------------------------------------------
28// RegistryProvider trait
29// ---------------------------------------------------------------------------
30
31/// Unified registry interface used by both discovery commands and the CLI
32/// dispatcher. Provides JSON-based access (`get_definition`) for discovery
33/// and typed access (`get_module_descriptor`) for the dispatch pipeline.
34///
35/// The real `apcore::Registry` implements this trait via `ApCoreRegistryProvider`.
36/// Tests use `MockRegistry`.
37pub trait RegistryProvider: Send + Sync {
38    /// Return all module IDs in the registry.
39    fn list(&self) -> Vec<String>;
40
41    /// Return the JSON descriptor for a single module, or `None` if not found.
42    fn get_definition(&self, id: &str) -> Option<Value>;
43
44    /// Return the typed descriptor for a single module, or `None` if not found.
45    ///
46    /// The default implementation deserializes from `get_definition`. Adapters
47    /// wrapping a real `apcore::Registry` should override this for efficiency.
48    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
57// ---------------------------------------------------------------------------
58// validate_tag
59// ---------------------------------------------------------------------------
60
61/// Validate a tag string against the pattern `^[a-z][a-z0-9_-]*$`.
62///
63/// Returns `true` if valid, `false` otherwise. Does not exit the process.
64pub 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
73// ---------------------------------------------------------------------------
74// validate_module_id (local, mirrors cli::validate_module_id rules)
75// ---------------------------------------------------------------------------
76
77fn validate_module_id_discovery(id: &str) -> bool {
78    // Delegate to the canonical validator in cli module.
79    crate::cli::validate_module_id(id).is_ok()
80}
81
82// ---------------------------------------------------------------------------
83// module_has_all_tags helper
84// ---------------------------------------------------------------------------
85
86fn 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// ---------------------------------------------------------------------------
96// cmd_list
97// ---------------------------------------------------------------------------
98
99/// Execute the `list` subcommand logic.
100///
101/// Returns `Ok(String)` with the formatted output on success.
102/// Returns `Err(DiscoveryError)` on invalid tag format.
103///
104/// Exit code mapping for the caller: `DiscoveryError::InvalidTag` → exit 2.
105pub fn cmd_list(
106    registry: &dyn RegistryProvider,
107    tags: &[&str],
108    explicit_format: Option<&str>,
109) -> Result<String, DiscoveryError> {
110    // Validate all tag formats before filtering.
111    for tag in tags {
112        if !validate_tag(tag) {
113            return Err(DiscoveryError::InvalidTag(tag.to_string()));
114        }
115    }
116
117    // Collect all module definitions.
118    let mut modules: Vec<Value> = registry
119        .list()
120        .into_iter()
121        .filter_map(|id| registry.get_definition(&id))
122        .collect();
123
124    // Apply AND tag filter if any tags were specified.
125    if !tags.is_empty() {
126        modules.retain(|m| module_has_all_tags(m, tags));
127    }
128
129    let fmt = crate::output::resolve_format(explicit_format);
130    Ok(crate::output::format_module_list(&modules, fmt, tags))
131}
132
133// ---------------------------------------------------------------------------
134// cmd_describe
135// ---------------------------------------------------------------------------
136
137/// Execute the `describe` subcommand logic.
138///
139/// Returns `Ok(String)` with the formatted output on success.
140/// Returns `Err(DiscoveryError)` on invalid module ID or module not found.
141///
142/// Exit code mapping for the caller:
143/// - `DiscoveryError::InvalidModuleId` → exit 2
144/// - `DiscoveryError::ModuleNotFound`  → exit 44
145pub fn cmd_describe(
146    registry: &dyn RegistryProvider,
147    module_id: &str,
148    explicit_format: Option<&str>,
149) -> Result<String, DiscoveryError> {
150    // Validate module ID format.
151    if !validate_module_id_discovery(module_id) {
152        return Err(DiscoveryError::InvalidModuleId(module_id.to_string()));
153    }
154
155    let module = registry
156        .get_definition(module_id)
157        .ok_or_else(|| DiscoveryError::ModuleNotFound(module_id.to_string()))?;
158
159    let fmt = crate::output::resolve_format(explicit_format);
160    Ok(crate::output::format_module_detail(&module, fmt))
161}
162
163// ---------------------------------------------------------------------------
164// register_discovery_commands
165// ---------------------------------------------------------------------------
166
167/// Attach `list` and `describe` subcommands to the given root command.
168///
169/// Returns the root command with the subcommands added. Follows the clap v4
170/// builder idiom (commands are consumed and returned, not mutated in-place).
171pub fn register_discovery_commands(cli: Command, _registry: Arc<dyn RegistryProvider>) -> Command {
172    cli.subcommand(list_command())
173        .subcommand(describe_command())
174}
175
176// ---------------------------------------------------------------------------
177// list_command / describe_command builders
178// ---------------------------------------------------------------------------
179
180fn list_command() -> Command {
181    Command::new("list")
182        .about("List available modules in the registry")
183        .arg(
184            Arg::new("tag")
185                .long("tag")
186                .action(ArgAction::Append)
187                .value_name("TAG")
188                .help("Filter modules by tag (AND logic). Repeatable."),
189        )
190        .arg(
191            Arg::new("format")
192                .long("format")
193                .value_parser(clap::builder::PossibleValuesParser::new(["table", "json"]))
194                .value_name("FORMAT")
195                .help("Output format. Default: table (TTY) or json (non-TTY)."),
196        )
197}
198
199fn describe_command() -> Command {
200    Command::new("describe")
201        .about("Show metadata, schema, and annotations for a module")
202        .arg(
203            Arg::new("module_id")
204                .required(true)
205                .value_name("MODULE_ID")
206                .help("Canonical module identifier (e.g. math.add)"),
207        )
208        .arg(
209            Arg::new("format")
210                .long("format")
211                .value_parser(clap::builder::PossibleValuesParser::new(["table", "json"]))
212                .value_name("FORMAT")
213                .help("Output format. Default: table (TTY) or json (non-TTY)."),
214        )
215}
216
217// ---------------------------------------------------------------------------
218// ApCoreRegistryProvider — wraps apcore::Registry for discovery commands
219// ---------------------------------------------------------------------------
220
221/// Adapter that implements `RegistryProvider` for the real `apcore::Registry`.
222///
223/// Tracks discovered module names separately because `Registry::discover()`
224/// stores descriptors but not module implementations, so `Registry::list()`
225/// (which iterates over the modules map) would miss them.
226pub struct ApCoreRegistryProvider {
227    registry: apcore::Registry,
228    discovered_names: Vec<String>,
229    descriptions: std::collections::HashMap<String, String>,
230}
231
232impl ApCoreRegistryProvider {
233    /// Create a new adapter from a real apcore::Registry.
234    pub fn new(registry: apcore::Registry) -> Self {
235        Self {
236            registry,
237            discovered_names: Vec::new(),
238            descriptions: std::collections::HashMap::new(),
239        }
240    }
241
242    /// Record names of modules found via discovery so they appear in `list()`.
243    pub fn set_discovered_names(&mut self, names: Vec<String>) {
244        self.discovered_names = names;
245    }
246
247    /// Store module descriptions loaded from module.json files.
248    pub fn set_descriptions(&mut self, descriptions: std::collections::HashMap<String, String>) {
249        self.descriptions = descriptions;
250    }
251}
252
253impl RegistryProvider for ApCoreRegistryProvider {
254    fn list(&self) -> Vec<String> {
255        let mut ids: Vec<String> = self
256            .registry
257            .list(None, None)
258            .iter()
259            .map(|s| s.to_string())
260            .collect();
261        for name in &self.discovered_names {
262            if !ids.contains(name) {
263                ids.push(name.clone());
264            }
265        }
266        ids
267    }
268
269    fn get_definition(&self, id: &str) -> Option<Value> {
270        self.registry
271            .get_definition(id)
272            .and_then(|d| serde_json::to_value(d).ok())
273            .map(|mut v| {
274                // Inject description from discovery metadata if available,
275                // since ModuleDescriptor does not carry a description field.
276                if let Some(desc) = self.descriptions.get(id) {
277                    if let Some(obj) = v.as_object_mut() {
278                        obj.insert("description".to_string(), Value::String(desc.clone()));
279                    }
280                }
281                v
282            })
283    }
284
285    fn get_module_descriptor(
286        &self,
287        id: &str,
288    ) -> Option<apcore::registry::registry::ModuleDescriptor> {
289        self.registry.get_definition(id).cloned()
290    }
291}
292
293// ---------------------------------------------------------------------------
294// MockRegistry — gated behind cfg(test) or the test-support feature
295// ---------------------------------------------------------------------------
296
297/// Test helper: in-memory registry backed by a Vec of JSON module descriptors.
298#[cfg(any(test, feature = "test-support"))]
299#[doc(hidden)]
300pub struct MockRegistry {
301    modules: Vec<Value>,
302}
303
304#[cfg(any(test, feature = "test-support"))]
305#[doc(hidden)]
306impl MockRegistry {
307    pub fn new(modules: Vec<Value>) -> Self {
308        Self { modules }
309    }
310}
311
312#[cfg(any(test, feature = "test-support"))]
313impl RegistryProvider for MockRegistry {
314    fn list(&self) -> Vec<String> {
315        self.modules
316            .iter()
317            .filter_map(|m| {
318                m.get("module_id")
319                    .and_then(|v| v.as_str())
320                    .map(|s| s.to_string())
321            })
322            .collect()
323    }
324
325    fn get_definition(&self, id: &str) -> Option<Value> {
326        self.modules
327            .iter()
328            .find(|m| m.get("module_id").and_then(|v| v.as_str()) == Some(id))
329            .cloned()
330    }
331}
332
333// ---------------------------------------------------------------------------
334// mock_module helper — gated behind cfg(test) or the test-support feature
335// ---------------------------------------------------------------------------
336
337/// Test helper: build a minimal module descriptor JSON value.
338#[cfg(any(test, feature = "test-support"))]
339#[doc(hidden)]
340pub fn mock_module(id: &str, description: &str, tags: &[&str]) -> Value {
341    serde_json::json!({
342        "module_id": id,
343        "description": description,
344        "tags": tags,
345    })
346}
347
348// ---------------------------------------------------------------------------
349// Unit tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    // --- validate_tag ---
357
358    #[test]
359    fn test_validate_tag_valid_simple() {
360        assert!(validate_tag("math"), "single lowercase word must be valid");
361    }
362
363    #[test]
364    fn test_validate_tag_valid_with_digits_and_dash() {
365        assert!(validate_tag("ml-v2"), "digits and dash must be valid");
366    }
367
368    #[test]
369    fn test_validate_tag_valid_with_underscore() {
370        assert!(validate_tag("core_util"), "underscore must be valid");
371    }
372
373    #[test]
374    fn test_validate_tag_invalid_uppercase() {
375        assert!(!validate_tag("Math"), "uppercase start must be invalid");
376    }
377
378    #[test]
379    fn test_validate_tag_invalid_starts_with_digit() {
380        assert!(!validate_tag("1tag"), "digit start must be invalid");
381    }
382
383    #[test]
384    fn test_validate_tag_invalid_special_chars() {
385        assert!(!validate_tag("invalid!"), "special chars must be invalid");
386    }
387
388    #[test]
389    fn test_validate_tag_invalid_empty() {
390        assert!(!validate_tag(""), "empty string must be invalid");
391    }
392
393    #[test]
394    fn test_validate_tag_invalid_space() {
395        assert!(!validate_tag("has space"), "space must be invalid");
396    }
397
398    // --- RegistryProvider / MockRegistry ---
399
400    #[test]
401    fn test_mock_registry_list_returns_ids() {
402        let registry = MockRegistry::new(vec![
403            mock_module("math.add", "Add numbers", &["math", "core"]),
404            mock_module("text.upper", "Uppercase text", &["text"]),
405        ]);
406        let ids = registry.list();
407        assert_eq!(ids.len(), 2);
408        assert!(ids.contains(&"math.add".to_string()));
409    }
410
411    #[test]
412    fn test_mock_registry_get_definition_found() {
413        let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
414        let def = registry.get_definition("math.add");
415        assert!(def.is_some());
416        assert_eq!(def.unwrap()["module_id"], "math.add");
417    }
418
419    #[test]
420    fn test_mock_registry_get_definition_not_found() {
421        let registry = MockRegistry::new(vec![]);
422        assert!(registry.get_definition("non.existent").is_none());
423    }
424
425    // --- cmd_list ---
426
427    #[test]
428    fn test_cmd_list_all_modules_no_filter() {
429        let registry = MockRegistry::new(vec![
430            mock_module("math.add", "Add numbers", &["math", "core"]),
431            mock_module("text.upper", "Uppercase text", &["text"]),
432        ]);
433        let output = cmd_list(&registry, &[], Some("json")).unwrap();
434        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
435        let arr = parsed.as_array().unwrap();
436        assert_eq!(arr.len(), 2);
437    }
438
439    #[test]
440    fn test_cmd_list_empty_registry_table() {
441        let registry = MockRegistry::new(vec![]);
442        let output = cmd_list(&registry, &[], Some("table")).unwrap();
443        assert_eq!(output.trim(), "No modules found.");
444    }
445
446    #[test]
447    fn test_cmd_list_empty_registry_json() {
448        let registry = MockRegistry::new(vec![]);
449        let output = cmd_list(&registry, &[], Some("json")).unwrap();
450        assert_eq!(output.trim(), "[]");
451    }
452
453    #[test]
454    fn test_cmd_list_tag_filter_single_match() {
455        let registry = MockRegistry::new(vec![
456            mock_module("math.add", "Add numbers", &["math", "core"]),
457            mock_module("text.upper", "Uppercase text", &["text"]),
458        ]);
459        let output = cmd_list(&registry, &["math"], Some("json")).unwrap();
460        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
461        let arr = parsed.as_array().unwrap();
462        assert_eq!(arr.len(), 1);
463        assert_eq!(arr[0]["id"], "math.add");
464    }
465
466    #[test]
467    fn test_cmd_list_tag_filter_and_semantics() {
468        let registry = MockRegistry::new(vec![
469            mock_module("math.add", "Add numbers", &["math", "core"]),
470            mock_module("math.mul", "Multiply", &["math"]),
471        ]);
472        // Only math.add has BOTH "math" AND "core".
473        let output = cmd_list(&registry, &["math", "core"], Some("json")).unwrap();
474        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
475        let arr = parsed.as_array().unwrap();
476        assert_eq!(arr.len(), 1);
477        assert_eq!(arr[0]["id"], "math.add");
478    }
479
480    #[test]
481    fn test_cmd_list_tag_filter_no_match_table() {
482        let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
483        let output = cmd_list(&registry, &["nonexistent"], Some("table")).unwrap();
484        assert!(output.contains("No modules found matching tags:"));
485        assert!(output.contains("nonexistent"));
486    }
487
488    #[test]
489    fn test_cmd_list_tag_filter_no_match_json() {
490        let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
491        let output = cmd_list(&registry, &["nonexistent"], Some("json")).unwrap();
492        assert_eq!(output.trim(), "[]");
493    }
494
495    #[test]
496    fn test_cmd_list_invalid_tag_format_returns_error() {
497        let registry = MockRegistry::new(vec![]);
498        let result = cmd_list(&registry, &["INVALID!"], Some("json"));
499        assert!(result.is_err());
500        match result.unwrap_err() {
501            DiscoveryError::InvalidTag(tag) => assert_eq!(tag, "INVALID!"),
502            other => panic!("unexpected error: {other}"),
503        }
504    }
505
506    #[test]
507    fn test_cmd_list_description_truncated_in_table() {
508        let long_desc = "x".repeat(100);
509        let registry = MockRegistry::new(vec![mock_module("a.b", &long_desc, &[])]);
510        let output = cmd_list(&registry, &[], Some("table")).unwrap();
511        assert!(output.contains("..."), "long description must be truncated");
512        assert!(
513            !output.contains(&"x".repeat(100)),
514            "full description must not appear"
515        );
516    }
517
518    #[test]
519    fn test_cmd_list_json_contains_id_description_tags() {
520        let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &["x", "y"])]);
521        let output = cmd_list(&registry, &[], Some("json")).unwrap();
522        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
523        let entry = &parsed[0];
524        assert!(entry.get("id").is_some());
525        assert!(entry.get("description").is_some());
526        assert!(entry.get("tags").is_some());
527    }
528
529    // --- cmd_describe ---
530
531    #[test]
532    fn test_cmd_describe_valid_module_json() {
533        let registry = MockRegistry::new(vec![mock_module(
534            "math.add",
535            "Add two numbers",
536            &["math", "core"],
537        )]);
538        let output = cmd_describe(&registry, "math.add", Some("json")).unwrap();
539        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
540        assert_eq!(parsed["id"], "math.add");
541        assert_eq!(parsed["description"], "Add two numbers");
542    }
543
544    #[test]
545    fn test_cmd_describe_valid_module_table() {
546        let registry =
547            MockRegistry::new(vec![mock_module("math.add", "Add two numbers", &["math"])]);
548        let output = cmd_describe(&registry, "math.add", Some("table")).unwrap();
549        assert!(output.contains("math.add"), "table must contain module id");
550        assert!(
551            output.contains("Add two numbers"),
552            "table must contain description"
553        );
554    }
555
556    #[test]
557    fn test_cmd_describe_not_found_returns_error() {
558        let registry = MockRegistry::new(vec![]);
559        let result = cmd_describe(&registry, "non.existent", Some("json"));
560        assert!(result.is_err());
561        match result.unwrap_err() {
562            DiscoveryError::ModuleNotFound(id) => assert_eq!(id, "non.existent"),
563            other => panic!("unexpected error: {other}"),
564        }
565    }
566
567    #[test]
568    fn test_cmd_describe_invalid_id_returns_error() {
569        let registry = MockRegistry::new(vec![]);
570        let result = cmd_describe(&registry, "INVALID!ID", Some("json"));
571        assert!(result.is_err());
572        match result.unwrap_err() {
573            DiscoveryError::InvalidModuleId(_) => {}
574            other => panic!("unexpected error: {other}"),
575        }
576    }
577
578    #[test]
579    fn test_cmd_describe_no_output_schema_table_omits_section() {
580        // Module without output_schema: section must be absent from table output.
581        let registry = MockRegistry::new(vec![serde_json::json!({
582            "module_id": "math.add",
583            "description": "Add numbers",
584            "input_schema": {"type": "object"},
585            "tags": ["math"]
586            // note: no output_schema key
587        })]);
588        let output = cmd_describe(&registry, "math.add", Some("table")).unwrap();
589        assert!(
590            !output.contains("Output Schema:"),
591            "output_schema section must be absent"
592        );
593    }
594
595    #[test]
596    fn test_cmd_describe_no_annotations_table_omits_section() {
597        let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
598        let output = cmd_describe(&registry, "math.add", Some("table")).unwrap();
599        assert!(
600            !output.contains("Annotations:"),
601            "annotations section must be absent"
602        );
603    }
604
605    #[test]
606    fn test_cmd_describe_with_annotations_table_shows_section() {
607        let registry = MockRegistry::new(vec![serde_json::json!({
608            "module_id": "math.add",
609            "description": "Add numbers",
610            "annotations": {"readonly": true},
611            "tags": []
612        })]);
613        let output = cmd_describe(&registry, "math.add", Some("table")).unwrap();
614        assert!(
615            output.contains("Annotations:"),
616            "annotations section must be present"
617        );
618        assert!(output.contains("readonly"), "annotation key must appear");
619    }
620
621    #[test]
622    fn test_cmd_describe_json_omits_null_fields() {
623        // Module with no input_schema, output_schema, annotations.
624        let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
625        let output = cmd_describe(&registry, "a.b", Some("json")).unwrap();
626        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
627        assert!(parsed.get("input_schema").is_none());
628        assert!(parsed.get("output_schema").is_none());
629        assert!(parsed.get("annotations").is_none());
630    }
631
632    #[test]
633    fn test_cmd_describe_json_includes_all_fields() {
634        let registry = MockRegistry::new(vec![serde_json::json!({
635            "module_id": "math.add",
636            "description": "Add two numbers",
637            "input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
638            "output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
639            "annotations": {"readonly": false},
640            "tags": ["math", "core"]
641        })]);
642        let output = cmd_describe(&registry, "math.add", Some("json")).unwrap();
643        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
644        assert!(parsed.get("input_schema").is_some());
645        assert!(parsed.get("output_schema").is_some());
646        assert!(parsed.get("annotations").is_some());
647        assert!(parsed.get("tags").is_some());
648    }
649
650    #[test]
651    fn test_cmd_describe_with_x_fields_table_shows_extension_section() {
652        let registry = MockRegistry::new(vec![serde_json::json!({
653            "module_id": "a.b",
654            "description": "Desc",
655            "x-custom": "custom-value",
656            "tags": []
657        })]);
658        let output = cmd_describe(&registry, "a.b", Some("table")).unwrap();
659        assert!(
660            output.contains("Extension Metadata:") || output.contains("x-custom"),
661            "x-fields must appear in table output"
662        );
663    }
664
665    // --- register_discovery_commands ---
666
667    #[test]
668    fn test_register_discovery_commands_adds_list() {
669        use std::sync::Arc;
670        let registry = Arc::new(MockRegistry::new(vec![]));
671        let root = Command::new("apcore-cli");
672        let cmd = register_discovery_commands(root, registry);
673        let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
674        assert!(
675            names.contains(&"list"),
676            "must have 'list' subcommand, got {names:?}"
677        );
678    }
679
680    #[test]
681    fn test_register_discovery_commands_adds_describe() {
682        use std::sync::Arc;
683        let registry = Arc::new(MockRegistry::new(vec![]));
684        let root = Command::new("apcore-cli");
685        let cmd = register_discovery_commands(root, registry);
686        let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
687        assert!(
688            names.contains(&"describe"),
689            "must have 'describe' subcommand, got {names:?}"
690        );
691    }
692
693    #[test]
694    fn test_list_command_with_tag_filter() {
695        let cmd = list_command();
696        let arg_names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
697        assert!(arg_names.contains(&"tag"), "list must have --tag flag");
698    }
699
700    #[test]
701    fn test_describe_command_module_not_found() {
702        // Verify module_id positional arg is present.
703        let cmd = describe_command();
704        let positionals: Vec<&str> = cmd
705            .get_positionals()
706            .filter_map(|a| a.get_id().as_str().into())
707            .collect();
708        assert!(
709            positionals.contains(&"module_id"),
710            "describe must have module_id positional, got {positionals:?}"
711        );
712    }
713}