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/// Options for the enhanced list command (FE-11 F7).
100#[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
112/// Execute the `list` subcommand logic.
113///
114/// Returns `Ok(String)` with the formatted output on success.
115/// Returns `Err(DiscoveryError)` on invalid tag format.
116///
117/// Exit code mapping for the caller: `DiscoveryError::InvalidTag` -> exit 2.
118pub 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
133/// Enhanced list with full filter/sort options (FE-11 F7).
134pub fn cmd_list_enhanced(
135    registry: &dyn RegistryProvider,
136    opts: &ListOptions<'_>,
137) -> Result<String, DiscoveryError> {
138    // Validate all tag formats before filtering.
139    for tag in opts.tags {
140        if !validate_tag(tag) {
141            return Err(DiscoveryError::InvalidTag(tag.to_string()));
142        }
143    }
144
145    // Collect all module definitions.
146    let mut modules: Vec<Value> = registry
147        .list()
148        .into_iter()
149        .filter_map(|id| registry.get_definition(&id))
150        .collect();
151
152    // Apply AND tag filter if any tags were specified.
153    if !opts.tags.is_empty() {
154        modules.retain(|m| module_has_all_tags(m, opts.tags));
155    }
156
157    // F7: Search filter (case-insensitive substring on id + description).
158    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    // F7: Status filter.
172    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        _ => {} // "all": no filter
180    }
181
182    // F7: Deprecated filter (excluded by default).
183    if !opts.deprecated {
184        modules.retain(|m| m.get("deprecated").and_then(|v| v.as_bool()) != Some(true));
185    }
186
187    // F7: Annotation filter (AND logic).
188    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    // F7: Sort — usage-based sorts require system.usage modules.
204    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    // F7: Reverse sort.
218    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
226// ---------------------------------------------------------------------------
227// cmd_describe
228// ---------------------------------------------------------------------------
229
230/// Execute the `describe` subcommand logic.
231///
232/// Returns `Ok(String)` with the formatted output on success.
233/// Returns `Err(DiscoveryError)` on invalid module ID or module not found.
234///
235/// Exit code mapping for the caller:
236/// - `DiscoveryError::InvalidModuleId` → exit 2
237/// - `DiscoveryError::ModuleNotFound`  → exit 44
238pub fn cmd_describe(
239    registry: &dyn RegistryProvider,
240    module_id: &str,
241    explicit_format: Option<&str>,
242) -> Result<String, DiscoveryError> {
243    // Validate module ID format.
244    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
256// ---------------------------------------------------------------------------
257// register_discovery_commands
258// ---------------------------------------------------------------------------
259
260/// Attach `list` and `describe` subcommands to the given root command.
261///
262/// Returns the root command with the subcommands added. Follows the clap v4
263/// builder idiom (commands are consumed and returned, not mutated in-place).
264pub fn register_discovery_commands(cli: Command, _registry: Arc<dyn RegistryProvider>) -> Command {
265    cli.subcommand(list_command())
266        .subcommand(describe_command())
267}
268
269// ---------------------------------------------------------------------------
270// list_command / describe_command builders
271// ---------------------------------------------------------------------------
272
273fn 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
375// ---------------------------------------------------------------------------
376// ApCoreRegistryProvider — wraps apcore::Registry for discovery commands
377// ---------------------------------------------------------------------------
378
379/// Adapter that implements `RegistryProvider` for the real `apcore::Registry`.
380///
381/// Tracks discovered module names separately because `Registry::discover()`
382/// stores descriptors but not module implementations, so `Registry::list()`
383/// (which iterates over the modules map) would miss them.
384pub struct ApCoreRegistryProvider {
385    registry: apcore::Registry,
386    discovered_names: Vec<String>,
387    descriptions: std::collections::HashMap<String, String>,
388}
389
390impl ApCoreRegistryProvider {
391    /// Create a new adapter from a real apcore::Registry.
392    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    /// Record names of modules found via discovery so they appear in `list()`.
401    pub fn set_discovered_names(&mut self, names: Vec<String>) {
402        self.discovered_names = names;
403    }
404
405    /// Store module descriptions loaded from module.json files.
406    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                // Inject description from discovery metadata if available,
433                // since ModuleDescriptor does not carry a description field.
434                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// ---------------------------------------------------------------------------
452// MockRegistry — gated behind cfg(test) or the test-support feature
453// ---------------------------------------------------------------------------
454
455/// Test helper: in-memory registry backed by a Vec of JSON module descriptors.
456#[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// ---------------------------------------------------------------------------
492// mock_module helper — gated behind cfg(test) or the test-support feature
493// ---------------------------------------------------------------------------
494
495/// Test helper: build a minimal module descriptor JSON value.
496#[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// ---------------------------------------------------------------------------
507// Unit tests
508// ---------------------------------------------------------------------------
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    // --- validate_tag ---
515
516    #[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    // --- RegistryProvider / MockRegistry ---
557
558    #[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    // --- cmd_list ---
584
585    #[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(&registry, &[], 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(&registry, &[], 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(&registry, &[], 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(&registry, &["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        // Only math.add has BOTH "math" AND "core".
631        let output = cmd_list(&registry, &["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(&registry, &["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(&registry, &["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(&registry, &["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(&registry, &[], 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(&registry, &[], 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    // --- cmd_describe ---
688
689    #[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(&registry, "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(&registry, "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(&registry, "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(&registry, "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        // Module without output_schema: section must be absent from table output.
739        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            // note: no output_schema key
745        })]);
746        let output = cmd_describe(&registry, "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(&registry, "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(&registry, "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        // Module with no input_schema, output_schema, annotations.
782        let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
783        let output = cmd_describe(&registry, "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(&registry, "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(&registry, "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    // --- register_discovery_commands ---
824
825    #[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        // Verify module_id positional arg is present.
861        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}