1use std::sync::Arc;
5
6use clap::{Arg, ArgAction, Command};
7use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
16pub enum DiscoveryError {
17 #[error("module '{0}' not found")]
18 ModuleNotFound(String),
19
20 #[error("invalid module id: {0}")]
21 InvalidModuleId(String),
22
23 #[error("invalid tag format: '{0}'. Tags must match [a-z][a-z0-9_-]*.")]
24 InvalidTag(String),
25}
26
27pub trait RegistryProvider: Send + Sync {
38 fn list(&self) -> Vec<String>;
40
41 fn get_definition(&self, id: &str) -> Option<Value>;
43
44 fn get_module_descriptor(
49 &self,
50 id: &str,
51 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
52 self.get_definition(id)
53 .and_then(|v| serde_json::from_value(v).ok())
54 }
55}
56
57pub fn validate_tag(tag: &str) -> bool {
65 let mut chars = tag.chars();
66 match chars.next() {
67 Some(c) if c.is_ascii_lowercase() => {}
68 _ => return false,
69 }
70 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
71}
72
73fn validate_module_id_discovery(id: &str) -> bool {
78 crate::cli::validate_module_id(id).is_ok()
80}
81
82fn module_has_all_tags(module: &Value, tags: &[&str]) -> bool {
87 let mod_tags: Vec<&str> = module
88 .get("tags")
89 .and_then(|t| t.as_array())
90 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
91 .unwrap_or_default();
92 tags.iter().all(|required| mod_tags.contains(required))
93}
94
95pub fn cmd_list(
106 registry: &dyn RegistryProvider,
107 tags: &[&str],
108 explicit_format: Option<&str>,
109) -> Result<String, DiscoveryError> {
110 for tag in tags {
112 if !validate_tag(tag) {
113 return Err(DiscoveryError::InvalidTag(tag.to_string()));
114 }
115 }
116
117 let mut modules: Vec<Value> = registry
119 .list()
120 .into_iter()
121 .filter_map(|id| registry.get_definition(&id))
122 .collect();
123
124 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
133pub fn cmd_describe(
146 registry: &dyn RegistryProvider,
147 module_id: &str,
148 explicit_format: Option<&str>,
149) -> Result<String, DiscoveryError> {
150 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
163pub fn register_discovery_commands(cli: Command, _registry: Arc<dyn RegistryProvider>) -> Command {
172 cli.subcommand(list_command())
173 .subcommand(describe_command())
174}
175
176fn 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
217pub struct ApCoreRegistryProvider {
227 registry: apcore::Registry,
228 discovered_names: Vec<String>,
229 descriptions: std::collections::HashMap<String, String>,
230}
231
232impl ApCoreRegistryProvider {
233 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 pub fn set_discovered_names(&mut self, names: Vec<String>) {
244 self.discovered_names = names;
245 }
246
247 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 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#[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#[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#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[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 #[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 #[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(®istry, &[], 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(®istry, &[], 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(®istry, &[], 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(®istry, &["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 let output = cmd_list(®istry, &["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(®istry, &["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(®istry, &["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(®istry, &["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(®istry, &[], 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(®istry, &[], 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 #[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(®istry, "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(®istry, "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(®istry, "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(®istry, "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 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 })]);
588 let output = cmd_describe(®istry, "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(®istry, "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(®istry, "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 let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
625 let output = cmd_describe(®istry, "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(®istry, "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(®istry, "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 #[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 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}