use clap::{Arg, ArgAction, Command};
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DiscoveryError {
#[error("module '{0}' not found")]
ModuleNotFound(String),
#[error("invalid module id: {0}")]
InvalidModuleId(String),
#[error("invalid tag format: '{0}'. Tags must match [a-z][a-z0-9_-]*.")]
InvalidTag(String),
}
pub trait RegistryProvider: Send + Sync {
fn list(&self) -> Vec<String>;
fn get_definition(&self, id: &str) -> Option<Value>;
fn get_module_descriptor(
&self,
id: &str,
) -> Option<apcore::registry::registry::ModuleDescriptor> {
self.get_definition(id)
.and_then(|v| serde_json::from_value(v).ok())
}
}
pub fn validate_tag(tag: &str) -> bool {
let mut chars = tag.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
fn module_has_all_tags(module: &Value, tags: &[&str]) -> bool {
let mod_tags: Vec<&str> = module
.get("tags")
.and_then(|t| t.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
tags.iter().all(|required| mod_tags.contains(required))
}
#[derive(Default)]
pub struct ListOptions<'a> {
pub tags: &'a [&'a str],
pub explicit_format: Option<&'a str>,
pub search: Option<&'a str>,
pub status: Option<&'a str>,
pub annotations: &'a [&'a str],
pub sort: Option<&'a str>,
pub reverse: bool,
pub deprecated: bool,
}
#[cfg(test)]
pub(crate) fn cmd_list(
registry: &dyn RegistryProvider,
tags: &[&str],
explicit_format: Option<&str>,
) -> Result<String, DiscoveryError> {
cmd_list_enhanced(
registry,
&ListOptions {
tags,
explicit_format,
..Default::default()
},
)
}
pub fn cmd_list_enhanced(
registry: &dyn RegistryProvider,
opts: &ListOptions<'_>,
) -> Result<String, DiscoveryError> {
for tag in opts.tags {
if !validate_tag(tag) {
return Err(DiscoveryError::InvalidTag(tag.to_string()));
}
}
let mut modules: Vec<Value> = registry
.list()
.into_iter()
.filter_map(|id| registry.get_definition(&id))
.collect();
if !opts.tags.is_empty() {
modules.retain(|m| module_has_all_tags(m, opts.tags));
}
if let Some(query) = opts.search {
let q = query.to_lowercase();
modules.retain(|m| {
let id = m
.get("module_id")
.or_else(|| m.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("");
let desc = m.get("description").and_then(|v| v.as_str()).unwrap_or("");
id.to_lowercase().contains(&q) || desc.to_lowercase().contains(&q)
});
}
match opts.status.unwrap_or("enabled") {
"enabled" => {
modules.retain(|m| m.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true));
}
"disabled" => {
modules.retain(|m| m.get("enabled").and_then(|v| v.as_bool()) == Some(false));
}
_ => {} }
if !opts.deprecated {
modules.retain(|m| m.get("deprecated").and_then(|v| v.as_bool()) != Some(true));
}
if !opts.annotations.is_empty() {
for ann_flag in opts.annotations {
let attr = match *ann_flag {
"requires-approval" => "requires_approval",
other => other,
};
modules.retain(|m| {
m.get("annotations")
.and_then(|a| a.get(attr))
.and_then(|v| v.as_bool())
== Some(true)
});
}
}
let requested_sort = opts.sort.unwrap_or("id");
if matches!(requested_sort, "calls" | "errors" | "latency") {
let used =
crate::system_usage::sort_modules_by_usage(&mut modules, requested_sort, !opts.reverse);
if !used {
eprintln!(
"note: no usage data available for --sort {}; sorted by id. \
Run some modules first to populate ~/.apcore-cli/audit.jsonl.",
requested_sort
);
}
} else {
modules.sort_by(|a, b| {
let aid = a.get("module_id").and_then(|v| v.as_str()).unwrap_or("");
let bid = b.get("module_id").and_then(|v| v.as_str()).unwrap_or("");
aid.cmp(bid)
});
if opts.reverse {
modules.reverse();
}
}
let fmt = crate::output::resolve_format(opts.explicit_format);
Ok(crate::output::format_module_list(&modules, fmt, opts.tags))
}
pub fn cmd_describe(
registry: &dyn RegistryProvider,
module_id: &str,
explicit_format: Option<&str>,
) -> Result<String, DiscoveryError> {
if crate::cli::validate_module_id(module_id).is_err() {
return Err(DiscoveryError::InvalidModuleId(module_id.to_string()));
}
let module = registry
.get_definition(module_id)
.ok_or_else(|| DiscoveryError::ModuleNotFound(module_id.to_string()))?;
let fmt = crate::output::resolve_format(explicit_format);
Ok(crate::output::format_module_detail(&module, fmt))
}
pub fn register_list_command(cli: Command) -> Command {
cli.subcommand(list_command())
}
pub fn register_describe_command(cli: Command) -> Command {
cli.subcommand(describe_command())
}
pub fn register_exec_command(cli: Command) -> Command {
cli.subcommand(crate::cli::exec_command())
}
fn list_command() -> Command {
Command::new("list")
.about("List available modules in the registry")
.arg(
Arg::new("tag")
.long("tag")
.action(ArgAction::Append)
.value_name("TAG")
.help("Filter modules by tag (AND logic). Repeatable."),
)
.arg(
Arg::new("format")
.long("format")
.value_parser(clap::builder::PossibleValuesParser::new([
"table", "json", "csv", "yaml", "jsonl", "markdown", "skill",
]))
.value_name("FORMAT")
.help("Output format. Default: table (TTY) or json (non-TTY)."),
)
.arg(
Arg::new("search")
.long("search")
.short('s')
.value_name("QUERY")
.help("Filter by substring match on ID and description."),
)
.arg(
Arg::new("status")
.long("status")
.value_parser(["enabled", "disabled", "all"])
.default_value("enabled")
.value_name("STATUS")
.help("Filter by module status. Default: enabled."),
)
.arg(
Arg::new("annotation")
.long("annotation")
.short('a')
.action(ArgAction::Append)
.value_parser([
"destructive",
"requires-approval",
"readonly",
"streaming",
"cacheable",
"idempotent",
"paginated",
])
.value_name("ANN")
.help("Filter by annotation flag (AND logic). Repeatable."),
)
.arg(
Arg::new("sort")
.long("sort")
.value_parser(["id", "calls", "errors", "latency"])
.default_value("id")
.value_name("FIELD")
.help("Sort order. Default: id. Non-id values require usage data; warns and falls back when unavailable."),
)
.arg(
Arg::new("reverse")
.long("reverse")
.action(ArgAction::SetTrue)
.help("Reverse sort order."),
)
.arg(
Arg::new("deprecated")
.long("deprecated")
.action(ArgAction::SetTrue)
.help("Include deprecated modules."),
)
.arg(
Arg::new("deps")
.long("deps")
.action(ArgAction::SetTrue)
.help("Show dependency count column."),
)
.arg(
Arg::new("flat")
.long("flat")
.action(ArgAction::SetTrue)
.help("Show flat list (no grouping)."),
)
}
fn describe_command() -> Command {
Command::new("describe")
.about("Show metadata, schema, and annotations for a module")
.arg(
Arg::new("module_id")
.required(true)
.value_name("MODULE_ID")
.help("Canonical module identifier (e.g. math.add)"),
)
.arg(
Arg::new("format")
.long("format")
.value_parser(clap::builder::PossibleValuesParser::new([
"table", "json", "csv", "yaml", "jsonl", "markdown", "skill",
]))
.value_name("FORMAT")
.help("Output format. Default: table (TTY) or json (non-TTY)."),
)
}
pub struct ApCoreRegistryProvider {
registry: std::sync::Arc<apcore::Registry>,
discovered_names: Vec<String>,
descriptions: std::collections::HashMap<String, String>,
}
impl ApCoreRegistryProvider {
pub fn new(registry: apcore::Registry) -> Self {
Self {
registry: std::sync::Arc::new(registry),
discovered_names: Vec::new(),
descriptions: std::collections::HashMap::new(),
}
}
pub fn set_discovered_names(&mut self, names: Vec<String>) {
self.discovered_names = names;
}
pub fn set_descriptions(&mut self, descriptions: std::collections::HashMap<String, String>) {
self.descriptions = descriptions;
}
}
impl RegistryProvider for ApCoreRegistryProvider {
fn list(&self) -> Vec<String> {
let mut ids: Vec<String> = self
.registry
.list(None, None)
.iter()
.map(|s| s.to_string())
.collect();
for name in &self.discovered_names {
if !ids.contains(name) {
ids.push(name.clone());
}
}
ids
}
fn get_definition(&self, id: &str) -> Option<Value> {
self.registry
.get_definition(id)
.and_then(|d| serde_json::to_value(d).ok())
.map(|mut v| {
if let Some(desc) = self.descriptions.get(id) {
if let Some(obj) = v.as_object_mut() {
obj.insert("description".to_string(), Value::String(desc.clone()));
}
}
v
})
}
fn get_module_descriptor(
&self,
id: &str,
) -> Option<apcore::registry::registry::ModuleDescriptor> {
self.registry.get_definition(id)
}
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub struct MockRegistry {
modules: Vec<Value>,
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
impl MockRegistry {
pub fn new(modules: Vec<Value>) -> Self {
Self { modules }
}
}
#[cfg(any(test, feature = "test-support"))]
impl RegistryProvider for MockRegistry {
fn list(&self) -> Vec<String> {
self.modules
.iter()
.filter_map(|m| {
m.get("module_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect()
}
fn get_definition(&self, id: &str) -> Option<Value> {
self.modules
.iter()
.find(|m| m.get("module_id").and_then(|v| v.as_str()) == Some(id))
.cloned()
}
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn mock_module(id: &str, description: &str, tags: &[&str]) -> Value {
serde_json::json!({
"module_id": id,
"description": description,
"tags": tags,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_tag_valid_simple() {
assert!(validate_tag("math"), "single lowercase word must be valid");
}
#[test]
fn test_validate_tag_valid_with_digits_and_dash() {
assert!(validate_tag("ml-v2"), "digits and dash must be valid");
}
#[test]
fn test_validate_tag_valid_with_underscore() {
assert!(validate_tag("core_util"), "underscore must be valid");
}
#[test]
fn test_validate_tag_invalid_uppercase() {
assert!(!validate_tag("Math"), "uppercase start must be invalid");
}
#[test]
fn test_validate_tag_invalid_starts_with_digit() {
assert!(!validate_tag("1tag"), "digit start must be invalid");
}
#[test]
fn test_validate_tag_invalid_special_chars() {
assert!(!validate_tag("invalid!"), "special chars must be invalid");
}
#[test]
fn test_validate_tag_invalid_empty() {
assert!(!validate_tag(""), "empty string must be invalid");
}
#[test]
fn test_validate_tag_invalid_space() {
assert!(!validate_tag("has space"), "space must be invalid");
}
#[test]
fn test_mock_registry_list_returns_ids() {
let registry = MockRegistry::new(vec![
mock_module("math.add", "Add numbers", &["math", "core"]),
mock_module("text.upper", "Uppercase text", &["text"]),
]);
let ids = registry.list();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"math.add".to_string()));
}
#[test]
fn test_mock_registry_get_definition_found() {
let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
let def = registry.get_definition("math.add");
assert!(def.is_some());
assert_eq!(def.unwrap()["module_id"], "math.add");
}
#[test]
fn test_mock_registry_get_definition_not_found() {
let registry = MockRegistry::new(vec![]);
assert!(registry.get_definition("non.existent").is_none());
}
#[test]
fn test_cmd_list_all_modules_no_filter() {
let registry = MockRegistry::new(vec![
mock_module("math.add", "Add numbers", &["math", "core"]),
mock_module("text.upper", "Uppercase text", &["text"]),
]);
let output = cmd_list(®istry, &[], Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
}
#[test]
fn test_cmd_list_empty_registry_table() {
let registry = MockRegistry::new(vec![]);
let output = cmd_list(®istry, &[], Some("table")).unwrap();
assert_eq!(output.trim(), "No modules found.");
}
#[test]
fn test_cmd_list_empty_registry_json() {
let registry = MockRegistry::new(vec![]);
let output = cmd_list(®istry, &[], Some("json")).unwrap();
assert_eq!(output.trim(), "[]");
}
#[test]
fn test_cmd_list_tag_filter_single_match() {
let registry = MockRegistry::new(vec![
mock_module("math.add", "Add numbers", &["math", "core"]),
mock_module("text.upper", "Uppercase text", &["text"]),
]);
let output = cmd_list(®istry, &["math"], Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "math.add");
}
#[test]
fn test_cmd_list_tag_filter_and_semantics() {
let registry = MockRegistry::new(vec![
mock_module("math.add", "Add numbers", &["math", "core"]),
mock_module("math.mul", "Multiply", &["math"]),
]);
let output = cmd_list(®istry, &["math", "core"], Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "math.add");
}
#[test]
fn test_cmd_list_tag_filter_no_match_table() {
let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
let output = cmd_list(®istry, &["nonexistent"], Some("table")).unwrap();
assert!(output.contains("No modules found matching tags:"));
assert!(output.contains("nonexistent"));
}
#[test]
fn test_cmd_list_tag_filter_no_match_json() {
let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
let output = cmd_list(®istry, &["nonexistent"], Some("json")).unwrap();
assert_eq!(output.trim(), "[]");
}
#[test]
fn test_cmd_list_invalid_tag_format_returns_error() {
let registry = MockRegistry::new(vec![]);
let result = cmd_list(®istry, &["INVALID!"], Some("json"));
assert!(result.is_err());
match result.unwrap_err() {
DiscoveryError::InvalidTag(tag) => assert_eq!(tag, "INVALID!"),
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn test_cmd_list_description_truncated_in_table() {
let long_desc = "x".repeat(100);
let registry = MockRegistry::new(vec![mock_module("a.b", &long_desc, &[])]);
let output = cmd_list(®istry, &[], Some("table")).unwrap();
assert!(output.contains("..."), "long description must be truncated");
assert!(
!output.contains(&"x".repeat(100)),
"full description must not appear"
);
}
#[test]
fn test_cmd_list_json_contains_id_description_tags() {
let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &["x", "y"])]);
let output = cmd_list(®istry, &[], Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let entry = &parsed[0];
assert!(entry.get("id").is_some());
assert!(entry.get("description").is_some());
assert!(entry.get("tags").is_some());
}
#[test]
fn test_cmd_describe_valid_module_json() {
let registry = MockRegistry::new(vec![mock_module(
"math.add",
"Add two numbers",
&["math", "core"],
)]);
let output = cmd_describe(®istry, "math.add", Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["id"], "math.add");
assert_eq!(parsed["description"], "Add two numbers");
}
#[test]
fn test_cmd_describe_valid_module_table() {
let registry =
MockRegistry::new(vec![mock_module("math.add", "Add two numbers", &["math"])]);
let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
assert!(output.contains("math.add"), "table must contain module id");
assert!(
output.contains("Add two numbers"),
"table must contain description"
);
}
#[test]
fn test_cmd_describe_not_found_returns_error() {
let registry = MockRegistry::new(vec![]);
let result = cmd_describe(®istry, "non.existent", Some("json"));
assert!(result.is_err());
match result.unwrap_err() {
DiscoveryError::ModuleNotFound(id) => assert_eq!(id, "non.existent"),
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn test_cmd_describe_invalid_id_returns_error() {
let registry = MockRegistry::new(vec![]);
let result = cmd_describe(®istry, "INVALID!ID", Some("json"));
assert!(result.is_err());
match result.unwrap_err() {
DiscoveryError::InvalidModuleId(_) => {}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn test_cmd_describe_no_output_schema_table_omits_section() {
let registry = MockRegistry::new(vec![serde_json::json!({
"module_id": "math.add",
"description": "Add numbers",
"input_schema": {"type": "object"},
"tags": ["math"]
})]);
let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
assert!(
!output.contains("Output Schema:"),
"output_schema section must be absent"
);
}
#[test]
fn test_cmd_describe_no_annotations_table_omits_section() {
let registry = MockRegistry::new(vec![mock_module("math.add", "Add numbers", &["math"])]);
let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
assert!(
!output.contains("Annotations:"),
"annotations section must be absent"
);
}
#[test]
fn test_cmd_describe_with_annotations_table_shows_section() {
let registry = MockRegistry::new(vec![serde_json::json!({
"module_id": "math.add",
"description": "Add numbers",
"annotations": {"readonly": true},
"tags": []
})]);
let output = cmd_describe(®istry, "math.add", Some("table")).unwrap();
assert!(
output.contains("Annotations:"),
"annotations section must be present"
);
assert!(output.contains("readonly"), "annotation key must appear");
}
#[test]
fn test_cmd_describe_json_omits_null_fields() {
let registry = MockRegistry::new(vec![mock_module("a.b", "Desc", &[])]);
let output = cmd_describe(®istry, "a.b", Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(parsed.get("input_schema").is_none());
assert!(parsed.get("output_schema").is_none());
assert!(parsed.get("annotations").is_none());
}
#[test]
fn test_cmd_describe_json_includes_all_fields() {
let registry = MockRegistry::new(vec![serde_json::json!({
"module_id": "math.add",
"description": "Add two numbers",
"input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
"output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
"annotations": {"readonly": false},
"tags": ["math", "core"]
})]);
let output = cmd_describe(®istry, "math.add", Some("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(parsed.get("input_schema").is_some());
assert!(parsed.get("output_schema").is_some());
assert!(parsed.get("annotations").is_some());
assert!(parsed.get("tags").is_some());
}
#[test]
fn test_cmd_describe_with_x_fields_table_shows_extension_section() {
let registry = MockRegistry::new(vec![serde_json::json!({
"module_id": "a.b",
"description": "Desc",
"x-custom": "custom-value",
"tags": []
})]);
let output = cmd_describe(®istry, "a.b", Some("table")).unwrap();
assert!(
output.contains("Extension Metadata:") || output.contains("x-custom"),
"x-fields must appear in table output"
);
}
#[test]
fn test_register_list_command_adds_list() {
let root = Command::new("apcore-cli");
let cmd = register_list_command(root);
let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
assert!(
names.contains(&"list"),
"must have 'list' subcommand, got {names:?}"
);
}
#[test]
fn test_register_describe_command_adds_describe() {
let root = Command::new("apcore-cli");
let cmd = register_describe_command(root);
let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
assert!(
names.contains(&"describe"),
"must have 'describe' subcommand, got {names:?}"
);
}
#[test]
fn test_list_command_with_tag_filter() {
let cmd = list_command();
let arg_names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
assert!(arg_names.contains(&"tag"), "list must have --tag flag");
}
#[test]
fn test_describe_command_module_not_found() {
let cmd = describe_command();
let positionals: Vec<&str> = cmd
.get_positionals()
.filter_map(|a| a.get_id().as_str().into())
.collect();
assert!(
positionals.contains(&"module_id"),
"describe must have module_id positional, got {positionals:?}"
);
}
fn find_subcommand<'a>(cmd: &'a Command, name: &str) -> Option<&'a Command> {
cmd.get_subcommands().find(|c| c.get_name() == name)
}
#[test]
fn test_register_list_command_attaches_list() {
let root = Command::new("apcli");
let cmd = register_list_command(root);
let list = find_subcommand(&cmd, "list").expect("'list' subcommand must be attached");
let long_flags: Vec<&str> = list.get_opts().filter_map(|a| a.get_long()).collect();
assert!(
long_flags.contains(&"tag"),
"'list' must expose --tag flag, got {long_flags:?}"
);
}
#[test]
fn test_register_describe_command_attaches_describe() {
let root = Command::new("apcli");
let cmd = register_describe_command(root);
let describe =
find_subcommand(&cmd, "describe").expect("'describe' subcommand must be attached");
let positionals: Vec<&str> = describe
.get_positionals()
.map(|a| a.get_id().as_str())
.collect();
assert!(
positionals.contains(&"module_id"),
"'describe' must require module_id positional, got {positionals:?}"
);
let module_id_arg = describe
.get_arguments()
.find(|a| a.get_id().as_str() == "module_id")
.expect("module_id arg must exist");
assert!(
module_id_arg.is_required_set(),
"'describe' module_id positional must be required"
);
}
#[test]
fn test_register_exec_command_attaches_exec() {
let root = Command::new("apcli");
let cmd = register_exec_command(root);
let exec = find_subcommand(&cmd, "exec").expect("'exec' subcommand must be attached");
let positionals: Vec<&str> = exec
.get_positionals()
.map(|a| a.get_id().as_str())
.collect();
assert!(
positionals.contains(&"module_id"),
"'exec' must require module_id positional, got {positionals:?}"
);
let module_id_arg = exec
.get_arguments()
.find(|a| a.get_id().as_str() == "module_id")
.expect("module_id arg must exist");
assert!(
module_id_arg.is_required_set(),
"'exec' module_id positional must be required"
);
}
#[test]
fn test_register_validate_command_attaches_validate() {
let root = Command::new("apcli");
let cmd = crate::validate::register_validate_command(root);
assert!(
find_subcommand(&cmd, "validate").is_some(),
"'validate' subcommand must be attached"
);
}
#[test]
fn test_per_subcommand_registrars_can_be_called_independently() {
let root = Command::new("apcli");
let cmd = register_list_command(root);
let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
assert!(
names.contains(&"list"),
"'list' must be present, got {names:?}"
);
assert!(
!names.contains(&"describe"),
"'describe' must NOT be present when only list was registered, got {names:?}"
);
assert!(
!names.contains(&"exec"),
"'exec' must NOT be present when only list was registered, got {names:?}"
);
assert!(
!names.contains(&"validate"),
"'validate' must NOT be present when only list was registered, got {names:?}"
);
}
}