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// module_has_all_tags helper
75// ---------------------------------------------------------------------------
76
77fn 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// ---------------------------------------------------------------------------
87// cmd_list
88// ---------------------------------------------------------------------------
89
90/// Options for the enhanced list command (FE-11 F7).
91#[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
103/// Execute the `list` subcommand logic.
104///
105/// Returns `Ok(String)` with the formatted output on success.
106/// Returns `Err(DiscoveryError)` on invalid tag format.
107///
108/// Exit code mapping for the caller: `DiscoveryError::InvalidTag` -> exit 2.
109///
110/// **Design note (audit D9):** This is a thin convenience wrapper over
111/// [`cmd_list_enhanced`] for the common case of "tags + format only". Audit
112/// D9 flagged it as a parallel implementation, but the cure (migrating
113/// 16+ test sites to construct full `ListOptions` literals) is worse than
114/// the disease (one extra 1-line wrapper). Retained intentionally.
115pub 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
130/// Enhanced list with full filter/sort options (FE-11 F7).
131pub fn cmd_list_enhanced(
132    registry: &dyn RegistryProvider,
133    opts: &ListOptions<'_>,
134) -> Result<String, DiscoveryError> {
135    // Validate all tag formats before filtering.
136    for tag in opts.tags {
137        if !validate_tag(tag) {
138            return Err(DiscoveryError::InvalidTag(tag.to_string()));
139        }
140    }
141
142    // Collect all module definitions.
143    let mut modules: Vec<Value> = registry
144        .list()
145        .into_iter()
146        .filter_map(|id| registry.get_definition(&id))
147        .collect();
148
149    // Apply AND tag filter if any tags were specified.
150    if !opts.tags.is_empty() {
151        modules.retain(|m| module_has_all_tags(m, opts.tags));
152    }
153
154    // F7: Search filter (case-insensitive substring on id + description).
155    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    // F7: Status filter.
169    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        _ => {} // "all": no filter
177    }
178
179    // F7: Deprecated filter (excluded by default).
180    if !opts.deprecated {
181        modules.retain(|m| m.get("deprecated").and_then(|v| v.as_bool()) != Some(true));
182    }
183
184    // F7: Annotation filter (AND logic).
185    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    // F7: Sort. The clap value_parser accepts ["id", "calls", "errors",
201    // "latency"] for cross-SDK parity (D11-006). Usage-based sorts require
202    // system.usage modules registered in the registry; they are not wired
203    // yet, so we emit a runtime warning and fall back to id, matching
204    // Python's logger.warning at apcore-cli-python/src/apcore_cli/discovery.py:206.
205    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    // F7: Reverse sort.
219    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
227// ---------------------------------------------------------------------------
228// cmd_describe
229// ---------------------------------------------------------------------------
230
231/// Execute the `describe` subcommand logic.
232///
233/// Returns `Ok(String)` with the formatted output on success.
234/// Returns `Err(DiscoveryError)` on invalid module ID or module not found.
235///
236/// Exit code mapping for the caller:
237/// - `DiscoveryError::InvalidModuleId` → exit 2
238/// - `DiscoveryError::ModuleNotFound`  → exit 44
239pub fn cmd_describe(
240    registry: &dyn RegistryProvider,
241    module_id: &str,
242    explicit_format: Option<&str>,
243) -> Result<String, DiscoveryError> {
244    // Validate module ID format.
245    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
257// ---------------------------------------------------------------------------
258// Per-subcommand registrars (FE-13)
259// ---------------------------------------------------------------------------
260//
261// The TS reference (`apcore-cli-typescript/src/discovery.ts`) splits
262// registration into `registerListCommand`, `registerDescribeCommand`,
263// `registerExecCommand`, `registerValidateCommand` so that the FE-13 built-in
264// command-group dispatcher can honor include/exclude filtering on a
265// per-subcommand basis.
266//
267// In Rust, the executor/registry are not baked into `clap::Command` — dispatch
268// flows through a separate path (`dispatch_module`), so these registrars take
269// only a `Command` and return a `Command`. They are intentionally pure
270// "add-subcommand-and-return" helpers with no internal state mutation, which
271// makes them safe to invoke on either the root command or the `apcli`
272// sub-group (or reused from standalone deprecation shims).
273
274/// Attach the `list` subcommand to the given command (typically the `apcli`
275/// group). Returns the command with the subcommand added.
276pub(crate) fn register_list_command(cli: Command) -> Command {
277    cli.subcommand(list_command())
278}
279
280/// Attach the `describe` subcommand to the given command. Returns the command
281/// with the subcommand added.
282pub(crate) fn register_describe_command(cli: Command) -> Command {
283    cli.subcommand(describe_command())
284}
285
286/// Attach the `exec` subcommand to the given command. Delegates to
287/// [`crate::cli::exec_command`] to avoid duplicating the builder.
288pub(crate) fn register_exec_command(cli: Command) -> Command {
289    cli.subcommand(crate::cli::exec_command())
290}
291
292// ---------------------------------------------------------------------------
293// register_discovery_commands (backward-compat wrapper)
294// ---------------------------------------------------------------------------
295
296/// Attach `list` and `describe` subcommands to the given root command.
297///
298/// **Retained for backward compatibility.** FE-13 integration should use the
299/// per-subcommand registrars ([`register_list_command`],
300/// [`register_describe_command`], [`register_exec_command`],
301/// [`crate::validate::register_validate_command`]) so that include/exclude filtering can be
302/// applied per subcommand. This wrapper preserves the pre-FE-13 call site
303/// shape (root-level `list` + `describe` attachment) for callers that have not
304/// yet migrated.
305///
306/// Returns the root command with the subcommands added. Follows the clap v4
307/// builder idiom (commands are consumed and returned, not mutated in-place).
308pub 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
313// ---------------------------------------------------------------------------
314// list_command / describe_command builders
315// ---------------------------------------------------------------------------
316
317fn 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                // Cross-SDK parity (D11-006): Python and TS accept the full
357                // apcore >= 0.19.0 ModuleAnnotations set including
358                // "paginated". Rust now matches.
359                .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                // Cross-SDK parity (D11-006): Python and TS accept
375                // [id, calls, errors, latency] and emit a runtime warning
376                // when usage data is unavailable for the non-id sorts. Rust
377                // now matches; cmd_list_enhanced emits the warning and falls
378                // back to id when usage modules are not registered.
379                .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
428// ---------------------------------------------------------------------------
429// ApCoreRegistryProvider — wraps apcore::Registry for discovery commands
430// ---------------------------------------------------------------------------
431
432/// Adapter that implements `RegistryProvider` for the real `apcore::Registry`.
433///
434/// Tracks discovered module names separately because `Registry::discover()`
435/// stores descriptors but not module implementations, so `Registry::list()`
436/// (which iterates over the modules map) would miss them.
437pub 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    /// Create a new adapter from an owned `apcore::Registry`.
445    ///
446    /// The registry is wrapped in an `Arc` internally so the same underlying
447    /// store can be shared without copying.
448    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    /// Record names of modules found via discovery so they appear in `list()`.
457    pub fn set_discovered_names(&mut self, names: Vec<String>) {
458        self.discovered_names = names;
459    }
460
461    /// Store module descriptions loaded from module.json files.
462    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                // Inject description from discovery metadata if available,
489                // since ModuleDescriptor does not carry a description field.
490                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// ---------------------------------------------------------------------------
508// MockRegistry — gated behind cfg(test) or the test-support feature
509// ---------------------------------------------------------------------------
510
511/// Test helper: in-memory registry backed by a Vec of JSON module descriptors.
512#[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// ---------------------------------------------------------------------------
548// mock_module helper — gated behind cfg(test) or the test-support feature
549// ---------------------------------------------------------------------------
550
551/// Test helper: build a minimal module descriptor JSON value.
552#[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// ---------------------------------------------------------------------------
563// Unit tests
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    // --- validate_tag ---
571
572    #[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    // --- RegistryProvider / MockRegistry ---
613
614    #[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    // --- cmd_list ---
640
641    #[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(&registry, &[], 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(&registry, &[], 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(&registry, &[], 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(&registry, &["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        // Only math.add has BOTH "math" AND "core".
687        let output = cmd_list(&registry, &["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(&registry, &["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(&registry, &["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(&registry, &["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(&registry, &[], 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(&registry, &[], 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    // --- cmd_describe ---
744
745    #[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(&registry, "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(&registry, "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(&registry, "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(&registry, "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        // Module without output_schema: section must be absent from table output.
795        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            // note: no output_schema key
801        })]);
802        let output = cmd_describe(&registry, "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(&registry, "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(&registry, "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        // Module with no input_schema, output_schema, annotations.
838        let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
839        let output = cmd_describe(&registry, "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(&registry, "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(&registry, "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    // --- register_discovery_commands ---
880
881    #[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        // Verify module_id positional arg is present.
917        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    // --- Per-subcommand registrars (FE-13) ---
929
930    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        // Attach only `list` to a fresh group; describe/exec/validate must be
1006        // absent. Proves registrars are composable without implicit coupling.
1007        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}