1use std::collections::HashMap;
6use std::io::Read;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10
11use serde_json::Value;
12use thiserror::Error;
13
14use crate::security::AuditLogger;
15
16pub trait ModuleExecutor: Send + Sync {}
26
27pub struct ApCoreExecutorAdapter(pub apcore::Executor);
29
30impl ModuleExecutor for ApCoreExecutorAdapter {}
31
32#[derive(Debug, Error)]
38pub enum CliError {
39 #[error("invalid module id: {0}")]
40 InvalidModuleId(String),
41
42 #[error("reserved module id: '{0}' conflicts with a built-in command name")]
43 ReservedModuleId(String),
44
45 #[error("stdin read error: {0}")]
46 StdinRead(String),
47
48 #[error("json parse error: {0}")]
49 JsonParse(String),
50
51 #[error("input too large (limit {limit} bytes, got {actual} bytes)")]
52 InputTooLarge { limit: usize, actual: usize },
53
54 #[error("expected JSON object, got a different type")]
55 NotAnObject,
56}
57
58static VERBOSE_HELP: AtomicBool = AtomicBool::new(false);
64
65pub fn set_verbose_help(verbose: bool) {
68 VERBOSE_HELP.store(verbose, Ordering::Relaxed);
69}
70
71pub fn is_verbose_help() -> bool {
73 VERBOSE_HELP.load(Ordering::Relaxed)
74}
75
76static DOCS_URL: Mutex<Option<String>> = Mutex::new(None);
82
83pub fn set_docs_url(url: Option<String>) {
92 if let Ok(mut guard) = DOCS_URL.lock() {
93 *guard = url;
94 }
95}
96
97pub fn get_docs_url() -> Option<String> {
99 match DOCS_URL.lock() {
100 Ok(guard) => guard.clone(),
101 Err(_) => None,
102 }
103}
104
105static AUDIT_LOGGER: Mutex<Option<AuditLogger>> = Mutex::new(None);
110
111static EXECUTABLES: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
116
117pub fn set_executables(map: HashMap<String, PathBuf>) {
121 let _ = EXECUTABLES.set(map);
122}
123
124pub fn set_audit_logger(audit_logger: Option<AuditLogger>) {
129 match AUDIT_LOGGER.lock() {
130 Ok(mut guard) => {
131 *guard = audit_logger;
132 }
133 Err(_poisoned) => {
134 tracing::warn!("AUDIT_LOGGER mutex poisoned — audit logger not updated");
135 }
136 }
137}
138
139pub fn add_dispatch_flags(cmd: clap::Command) -> clap::Command {
147 use clap::{Arg, ArgAction};
148 let hide = !is_verbose_help();
149 cmd.arg(
150 Arg::new("input")
151 .long("input")
152 .value_name("SOURCE")
153 .help(
154 "Read JSON input from a file path, \
155 or use '-' to read from stdin pipe",
156 )
157 .hide(hide),
158 )
159 .arg(
160 Arg::new("yes")
161 .long("yes")
162 .short('y')
163 .action(ArgAction::SetTrue)
164 .help(
165 "Skip interactive approval prompts \
166 (for scripts and CI)",
167 )
168 .hide(hide),
169 )
170 .arg(
171 Arg::new("large-input")
172 .long("large-input")
173 .action(ArgAction::SetTrue)
174 .help(
175 "Allow stdin input larger than 10MB \
176 (default limit protects against \
177 accidental pipes)",
178 )
179 .hide(hide),
180 )
181 .arg(
182 Arg::new("format")
183 .long("format")
184 .value_parser(["table", "json"])
185 .help(
186 "Set output format: 'json' for \
187 machine-readable, 'table' for \
188 human-readable",
189 )
190 .hide(hide),
191 )
192 .arg(
193 Arg::new("sandbox")
195 .long("sandbox")
196 .action(ArgAction::SetTrue)
197 .help(
198 "Run module in an isolated subprocess \
199 with restricted filesystem and env \
200 access",
201 )
202 .hide(true),
203 )
204}
205
206pub fn exec_command() -> clap::Command {
210 use clap::{Arg, Command};
211
212 let cmd = Command::new("exec").about("Execute an apcore module").arg(
213 Arg::new("module_id")
214 .required(true)
215 .value_name("MODULE_ID")
216 .help("Fully-qualified module ID to execute"),
217 );
218 add_dispatch_flags(cmd)
219}
220
221pub const BUILTIN_COMMANDS: &[&str] = &["completion", "describe", "exec", "init", "list", "man"];
227
228pub struct LazyModuleGroup {
234 registry: Arc<dyn crate::discovery::RegistryProvider>,
235 #[allow(dead_code)]
236 executor: Arc<dyn ModuleExecutor>,
237 module_cache: HashMap<String, bool>,
240 #[cfg(test)]
242 pub registry_lookup_count: usize,
243}
244
245impl LazyModuleGroup {
246 pub fn new(
252 registry: Arc<dyn crate::discovery::RegistryProvider>,
253 executor: Arc<dyn ModuleExecutor>,
254 ) -> Self {
255 Self {
256 registry,
257 executor,
258 module_cache: HashMap::new(),
259 #[cfg(test)]
260 registry_lookup_count: 0,
261 }
262 }
263
264 pub fn list_commands(&self) -> Vec<String> {
266 let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
267 names.extend(self.registry.list());
268 names.sort_unstable();
270 names.dedup();
271 names
272 }
273
274 pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
279 if BUILTIN_COMMANDS.contains(&name) {
280 return Some(clap::Command::new(name.to_string()));
281 }
282 if self.module_cache.contains_key(name) {
284 return Some(clap::Command::new(name.to_string()));
285 }
286 #[cfg(test)]
288 {
289 self.registry_lookup_count += 1;
290 }
291 let _descriptor = self.registry.get_module_descriptor(name)?;
292 let cmd = clap::Command::new(name.to_string());
293 self.module_cache.insert(name.to_string(), true);
294 tracing::debug!("Loaded module command: {name}");
295 Some(cmd)
296 }
297
298 #[cfg(test)]
301 pub fn registry_lookup_count(&self) -> usize {
302 self.registry_lookup_count
303 }
304}
305
306pub struct GroupedModuleGroup {
316 registry: Arc<dyn crate::discovery::RegistryProvider>,
317 #[allow(dead_code)]
318 executor: Arc<dyn ModuleExecutor>,
319 #[allow(dead_code)]
320 help_text_max_length: usize,
321 group_map: HashMap<String, HashMap<String, (String, Value)>>,
322 top_level_modules: HashMap<String, (String, Value)>,
323 alias_map: HashMap<String, String>,
324 descriptor_cache: HashMap<String, Value>,
325 group_map_built: bool,
326}
327
328impl GroupedModuleGroup {
329 pub fn new(
331 registry: Arc<dyn crate::discovery::RegistryProvider>,
332 executor: Arc<dyn ModuleExecutor>,
333 help_text_max_length: usize,
334 ) -> Self {
335 Self {
336 registry,
337 executor,
338 help_text_max_length,
339 group_map: HashMap::new(),
340 top_level_modules: HashMap::new(),
341 alias_map: HashMap::new(),
342 descriptor_cache: HashMap::new(),
343 group_map_built: false,
344 }
345 }
346
347 pub fn resolve_group(module_id: &str, descriptor: &Value) -> (Option<String>, String) {
352 let display = crate::display_helpers::get_display(descriptor);
353 let cli = display.get("cli").unwrap_or(&Value::Null);
354
355 if let Some(group_val) = cli.get("group") {
357 if let Some(g) = group_val.as_str() {
358 if g.is_empty() {
359 let alias = cli
361 .get("alias")
362 .and_then(|v| v.as_str())
363 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
364 .unwrap_or(module_id);
365 return (None, alias.to_string());
366 }
367 let alias = cli
369 .get("alias")
370 .and_then(|v| v.as_str())
371 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
372 .unwrap_or(module_id);
373 return (Some(g.to_string()), alias.to_string());
374 }
375 }
376
377 let alias = cli
379 .get("alias")
380 .and_then(|v| v.as_str())
381 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
382 .unwrap_or(module_id);
383
384 if let Some(dot_pos) = alias.find('.') {
386 let group = &alias[..dot_pos];
387 let cmd = &alias[dot_pos + 1..];
388 return (Some(group.to_string()), cmd.to_string());
389 }
390
391 (None, alias.to_string())
393 }
394
395 pub fn build_group_map(&mut self) {
397 if self.group_map_built {
398 return;
399 }
400 self.group_map_built = true;
401
402 let module_ids = self.registry.list();
403 for mid in &module_ids {
404 let descriptor = match self.registry.get_definition(mid) {
405 Some(d) => d,
406 None => continue,
407 };
408
409 let (group, cmd_name) = Self::resolve_group(mid, &descriptor);
410 self.alias_map.insert(cmd_name.clone(), mid.clone());
411 self.descriptor_cache
412 .insert(mid.clone(), descriptor.clone());
413
414 match group {
415 Some(g) if is_valid_group_name(&g) => {
416 let entry = self.group_map.entry(g).or_default();
417 entry.insert(cmd_name, (mid.clone(), descriptor));
418 }
419 Some(g) => {
420 tracing::warn!(
421 "Module '{}': group name '{}' is not shell-safe \
422 -- treating as top-level.",
423 mid,
424 g,
425 );
426 self.top_level_modules
427 .insert(cmd_name, (mid.clone(), descriptor));
428 }
429 None => {
430 self.top_level_modules
431 .insert(cmd_name, (mid.clone(), descriptor));
432 }
433 }
434 }
435 }
436
437 pub fn list_commands(&mut self) -> Vec<String> {
440 self.build_group_map();
441 let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
442 for group_name in self.group_map.keys() {
443 names.push(group_name.clone());
444 }
445 for cmd_name in self.top_level_modules.keys() {
446 names.push(cmd_name.clone());
447 }
448 names.sort_unstable();
449 names.dedup();
450 names
451 }
452
453 pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
456 self.build_group_map();
457
458 if BUILTIN_COMMANDS.contains(&name) {
459 return Some(clap::Command::new(name.to_string()));
460 }
461
462 if let Some(members) = self.group_map.get(name) {
464 let mut group_cmd = clap::Command::new(name.to_string());
465 for (cmd_name, (_mid, _desc)) in members {
466 group_cmd = group_cmd.subcommand(clap::Command::new(cmd_name.clone()));
467 }
468 return Some(group_cmd);
469 }
470
471 if self.top_level_modules.contains_key(name) {
473 return Some(clap::Command::new(name.to_string()));
474 }
475
476 None
477 }
478
479 #[cfg(test)]
481 pub fn help_text_max_length(&self) -> usize {
482 self.help_text_max_length
483 }
484}
485
486fn is_valid_group_name(s: &str) -> bool {
488 if s.is_empty() {
489 return false;
490 }
491 let mut chars = s.chars();
492 match chars.next() {
493 Some(c) if c.is_ascii_lowercase() => {}
494 _ => return false,
495 }
496 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
497}
498
499const RESERVED_FLAG_NAMES: &[&str] = &[
507 "input",
508 "yes",
509 "large-input",
510 "format",
511 "sandbox",
512 "verbose",
513];
514
515pub fn build_module_command(
532 module_def: &apcore::registry::registry::ModuleDescriptor,
533 executor: Arc<dyn ModuleExecutor>,
534) -> Result<clap::Command, CliError> {
535 build_module_command_with_limit(
536 module_def,
537 executor,
538 crate::schema_parser::HELP_TEXT_MAX_LEN,
539 )
540}
541
542pub fn build_module_command_with_limit(
545 module_def: &apcore::registry::registry::ModuleDescriptor,
546 executor: Arc<dyn ModuleExecutor>,
547 help_text_max_length: usize,
548) -> Result<clap::Command, CliError> {
549 let module_id = &module_def.name;
550
551 if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
553 return Err(CliError::ReservedModuleId(module_id.clone()));
554 }
555
556 let resolved_schema =
558 crate::ref_resolver::resolve_refs(&module_def.input_schema, 32, module_id)
559 .unwrap_or_else(|_| module_def.input_schema.clone());
560
561 let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
563 &resolved_schema,
564 help_text_max_length,
565 )
566 .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
567
568 for arg in &schema_args.args {
570 if let Some(long) = arg.get_long() {
571 if RESERVED_FLAG_NAMES.contains(&long) {
572 return Err(CliError::ReservedModuleId(format!(
573 "module '{module_id}' schema property '{long}' conflicts \
574 with a reserved CLI option name"
575 )));
576 }
577 }
578 }
579
580 let _ = executor;
582
583 let hide = !is_verbose_help();
584
585 let mut footer_parts = Vec::new();
587 if hide {
588 footer_parts.push(
589 "Use --verbose to show all options \
590 (including built-in apcore options)."
591 .to_string(),
592 );
593 }
594 if let Some(url) = get_docs_url() {
595 footer_parts.push(format!("Docs: {url}/commands/{module_id}"));
596 }
597 let footer = footer_parts.join("\n");
598
599 let mut cmd = clap::Command::new(module_id.clone())
600 .after_help(footer)
601 .arg(
603 clap::Arg::new("input")
604 .long("input")
605 .value_name("SOURCE")
606 .help(
607 "Read JSON input from a file path, \
608 or use '-' to read from stdin pipe.",
609 )
610 .hide(hide),
611 )
612 .arg(
613 clap::Arg::new("yes")
614 .long("yes")
615 .short('y')
616 .action(clap::ArgAction::SetTrue)
617 .help(
618 "Skip interactive approval prompts \
619 (for scripts and CI).",
620 )
621 .hide(hide),
622 )
623 .arg(
624 clap::Arg::new("large-input")
625 .long("large-input")
626 .action(clap::ArgAction::SetTrue)
627 .help(
628 "Allow stdin input larger than 10MB \
629 (default limit protects against \
630 accidental pipes).",
631 )
632 .hide(hide),
633 )
634 .arg(
635 clap::Arg::new("format")
636 .long("format")
637 .value_parser(["json", "table"])
638 .help(
639 "Set output format: 'json' for \
640 machine-readable, 'table' for \
641 human-readable.",
642 )
643 .hide(hide),
644 )
645 .arg(
647 clap::Arg::new("sandbox")
648 .long("sandbox")
649 .action(clap::ArgAction::SetTrue)
650 .help(
651 "Run module in an isolated subprocess \
652 with restricted filesystem and env \
653 access.",
654 )
655 .hide(true),
656 );
657
658 for arg in schema_args.args {
660 cmd = cmd.arg(arg);
661 }
662
663 Ok(cmd)
664}
665
666const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; pub fn collect_input_from_reader<R: Read>(
683 stdin_flag: Option<&str>,
684 cli_kwargs: HashMap<String, Value>,
685 large_input: bool,
686 mut reader: R,
687) -> Result<HashMap<String, Value>, CliError> {
688 let cli_non_null: HashMap<String, Value> = cli_kwargs
690 .into_iter()
691 .filter(|(_, v)| !v.is_null())
692 .collect();
693
694 if stdin_flag != Some("-") {
695 return Ok(cli_non_null);
696 }
697
698 let mut buf = Vec::new();
699 reader
700 .read_to_end(&mut buf)
701 .map_err(|e| CliError::StdinRead(e.to_string()))?;
702
703 if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
704 return Err(CliError::InputTooLarge {
705 limit: STDIN_SIZE_LIMIT_BYTES,
706 actual: buf.len(),
707 });
708 }
709
710 if buf.is_empty() {
711 return Ok(cli_non_null);
712 }
713
714 let stdin_value: Value =
715 serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
716
717 let stdin_map = match stdin_value {
718 Value::Object(m) => m,
719 _ => return Err(CliError::NotAnObject),
720 };
721
722 let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
724 merged.extend(cli_non_null);
725 Ok(merged)
726}
727
728pub fn collect_input(
743 stdin_flag: Option<&str>,
744 cli_kwargs: HashMap<String, Value>,
745 large_input: bool,
746) -> Result<HashMap<String, Value>, CliError> {
747 collect_input_from_reader(stdin_flag, cli_kwargs, large_input, std::io::stdin())
748}
749
750const MODULE_ID_MAX_LEN: usize = 128;
755
756pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
767 if module_id.len() > MODULE_ID_MAX_LEN {
768 return Err(CliError::InvalidModuleId(format!(
769 "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
770 )));
771 }
772 if !is_valid_module_id(module_id) {
773 return Err(CliError::InvalidModuleId(format!(
774 "Invalid module ID format: '{module_id}'."
775 )));
776 }
777 Ok(())
778}
779
780#[inline]
784fn is_valid_module_id(s: &str) -> bool {
785 if s.is_empty() {
786 return false;
787 }
788 for segment in s.split('.') {
790 if segment.is_empty() {
791 return false;
793 }
794 let mut chars = segment.chars();
795 match chars.next() {
797 Some(c) if c.is_ascii_lowercase() => {}
798 _ => return false,
799 }
800 for c in chars {
802 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
803 return false;
804 }
805 }
806 }
807 true
808}
809
810pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
825 use crate::{
826 EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_BIND_ERROR, EXIT_CONFIG_MOUNT_ERROR,
827 EXIT_CONFIG_NAMESPACE_RESERVED, EXIT_CONFIG_NOT_FOUND, EXIT_ERROR_FORMATTER_DUPLICATE,
828 EXIT_MODULE_EXECUTE_ERROR, EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF,
829 EXIT_SCHEMA_VALIDATION_ERROR,
830 };
831 match error_code {
832 "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
833 "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
834 "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
835 "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
836 "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
837 "ACL_DENIED" => EXIT_ACL_DENIED,
838 "CONFIG_NAMESPACE_RESERVED"
840 | "CONFIG_NAMESPACE_DUPLICATE"
841 | "CONFIG_ENV_PREFIX_CONFLICT" => EXIT_CONFIG_NAMESPACE_RESERVED,
842 "CONFIG_MOUNT_ERROR" => EXIT_CONFIG_MOUNT_ERROR,
843 "CONFIG_BIND_ERROR" => EXIT_CONFIG_BIND_ERROR,
844 "ERROR_FORMATTER_DUPLICATE" => EXIT_ERROR_FORMATTER_DUPLICATE,
845 _ => EXIT_MODULE_EXECUTE_ERROR,
846 }
847}
848
849pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
855 let code_str = serde_json::to_value(err.code)
857 .ok()
858 .and_then(|v| v.as_str().map(|s| s.to_string()))
859 .unwrap_or_default();
860 map_apcore_error_to_exit_code(&code_str)
861}
862
863pub(crate) fn validate_against_schema(
876 input: &HashMap<String, Value>,
877 schema: &Value,
878) -> Result<(), String> {
879 let required = match schema.get("required") {
881 Some(Value::Array(arr)) => arr,
882 _ => return Ok(()),
883 };
884 for req in required {
885 if let Some(field_name) = req.as_str() {
886 if !input.contains_key(field_name) {
887 return Err(format!("required field '{}' is missing", field_name));
888 }
889 }
890 }
891 Ok(())
892}
893
894pub fn reconcile_bool_pairs(
905 matches: &clap::ArgMatches,
906 bool_pairs: &[crate::schema_parser::BoolFlagPair],
907) -> HashMap<String, Value> {
908 let mut result = HashMap::new();
909 for pair in bool_pairs {
910 let pos_set = matches
913 .try_get_one::<bool>(&pair.prop_name)
914 .ok()
915 .flatten()
916 .copied()
917 .unwrap_or(false);
918 let neg_id = format!("no-{}", pair.prop_name);
919 let neg_set = matches
920 .try_get_one::<bool>(&neg_id)
921 .ok()
922 .flatten()
923 .copied()
924 .unwrap_or(false);
925 let val = if pos_set {
926 true
927 } else if neg_set {
928 false
929 } else {
930 pair.default_val
931 };
932 result.insert(pair.prop_name.clone(), Value::Bool(val));
933 }
934 result
935}
936
937fn extract_cli_kwargs(
942 matches: &clap::ArgMatches,
943 module_def: &apcore::registry::registry::ModuleDescriptor,
944) -> HashMap<String, Value> {
945 use crate::schema_parser::schema_to_clap_args;
946
947 let schema_args = match schema_to_clap_args(&module_def.input_schema) {
948 Ok(sa) => sa,
949 Err(_) => return HashMap::new(),
950 };
951
952 let mut kwargs: HashMap<String, Value> = HashMap::new();
953
954 for arg in &schema_args.args {
956 let id = arg.get_id().as_str().to_string();
957 if id.starts_with("no-") {
959 continue;
960 }
961 if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
964 kwargs.insert(id, Value::String(val.clone()));
965 } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
966 kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
967 } else {
968 kwargs.insert(id, Value::Null);
969 }
970 }
971
972 let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
974 kwargs.extend(bool_vals);
975
976 crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
978}
979
980async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
985 use tokio::io::AsyncWriteExt;
986
987 let mut child = tokio::process::Command::new(executable)
988 .stdin(std::process::Stdio::piped())
989 .stdout(std::process::Stdio::piped())
990 .stderr(std::process::Stdio::piped())
991 .spawn()
992 .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
993
994 if let Some(mut stdin) = child.stdin.take() {
996 let payload =
997 serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
998 stdin
999 .write_all(&payload)
1000 .await
1001 .map_err(|e| format!("failed to write to stdin: {e}"))?;
1002 drop(stdin);
1003 }
1004
1005 let output = child
1006 .wait_with_output()
1007 .await
1008 .map_err(|e| format!("failed to read output: {e}"))?;
1009
1010 if !output.status.success() {
1011 let code = output.status.code().unwrap_or(1);
1012 let stderr_hint = String::from_utf8_lossy(&output.stderr);
1013 return Err(format!(
1014 "script exited with code {code}{}",
1015 if stderr_hint.is_empty() {
1016 String::new()
1017 } else {
1018 format!(": {}", stderr_hint.trim())
1019 }
1020 ));
1021 }
1022
1023 serde_json::from_slice(&output.stdout)
1024 .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1025}
1026
1027pub async fn dispatch_module(
1032 module_id: &str,
1033 matches: &clap::ArgMatches,
1034 registry: &Arc<dyn crate::discovery::RegistryProvider>,
1035 _executor: &Arc<dyn ModuleExecutor + 'static>,
1036 apcore_executor: &apcore::Executor,
1037) -> ! {
1038 use crate::{
1039 EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1040 EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1041 };
1042
1043 if let Err(e) = validate_module_id(module_id) {
1045 eprintln!("Error: Invalid module ID format: '{module_id}'.");
1046 let _ = e;
1047 std::process::exit(EXIT_INVALID_INPUT);
1048 }
1049
1050 let module_def = match registry.get_module_descriptor(module_id) {
1052 Some(def) => def,
1053 None => {
1054 eprintln!("Error: Module '{module_id}' not found in registry.");
1055 std::process::exit(EXIT_MODULE_NOT_FOUND);
1056 }
1057 };
1058
1059 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1061 let auto_approve = matches.get_flag("yes");
1062 let large_input = matches.get_flag("large-input");
1063 let format_flag = matches.get_one::<String>("format").cloned();
1064
1065 let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1067
1068 let merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1070 Ok(m) => m,
1071 Err(CliError::InputTooLarge { .. }) => {
1072 eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1073 std::process::exit(EXIT_INVALID_INPUT);
1074 }
1075 Err(CliError::JsonParse(detail)) => {
1076 eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1077 std::process::exit(EXIT_INVALID_INPUT);
1078 }
1079 Err(CliError::NotAnObject) => {
1080 eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1081 std::process::exit(EXIT_INVALID_INPUT);
1082 }
1083 Err(e) => {
1084 eprintln!("Error: {e}");
1085 std::process::exit(EXIT_INVALID_INPUT);
1086 }
1087 };
1088
1089 if let Some(schema) = module_def.input_schema.as_object() {
1091 if schema.contains_key("properties") {
1092 if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1093 eprintln!("Error: Validation failed: {detail}.");
1094 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1095 }
1096 }
1097 }
1098
1099 let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1101 if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
1102 eprintln!("Error: {e}");
1103 std::process::exit(EXIT_APPROVAL_DENIED);
1104 }
1105
1106 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1108
1109 let use_sandbox = matches.get_flag("sandbox");
1111
1112 let script_executable = EXECUTABLES
1114 .get()
1115 .and_then(|map| map.get(module_id))
1116 .cloned();
1117
1118 let start = std::time::Instant::now();
1120
1121 let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
1124 tokio::select! {
1126 res = execute_script(&exec_path, &input_value) => {
1127 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e))
1128 }
1129 _ = tokio::signal::ctrl_c() => {
1130 eprintln!("Execution cancelled.");
1131 std::process::exit(EXIT_SIGINT);
1132 }
1133 }
1134 } else if use_sandbox {
1135 let sandbox = crate::security::Sandbox::new(true, 0);
1136 tokio::select! {
1137 res = sandbox.execute(module_id, input_value.clone()) => {
1138 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string()))
1139 }
1140 _ = tokio::signal::ctrl_c() => {
1141 eprintln!("Execution cancelled.");
1142 std::process::exit(EXIT_SIGINT);
1143 }
1144 }
1145 } else {
1146 tokio::select! {
1148 res = apcore_executor.call(module_id, input_value.clone(), None, None) => {
1149 res.map_err(|e| {
1150 let code = map_module_error_to_exit_code(&e);
1151 (code, e.to_string())
1152 })
1153 }
1154 _ = tokio::signal::ctrl_c() => {
1155 eprintln!("Execution cancelled.");
1156 std::process::exit(EXIT_SIGINT);
1157 }
1158 }
1159 };
1160
1161 let duration_ms = start.elapsed().as_millis() as u64;
1162
1163 match result {
1164 Ok(output) => {
1165 if let Ok(guard) = AUDIT_LOGGER.lock() {
1167 if let Some(logger) = guard.as_ref() {
1168 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1169 }
1170 }
1171 let fmt = crate::output::resolve_format(format_flag.as_deref());
1173 println!("{}", crate::output::format_exec_result(&output, fmt));
1174 std::process::exit(EXIT_SUCCESS);
1175 }
1176 Err((exit_code, msg)) => {
1177 if let Ok(guard) = AUDIT_LOGGER.lock() {
1179 if let Some(logger) = guard.as_ref() {
1180 logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1181 }
1182 }
1183 eprintln!("Error: Module '{module_id}' execution failed: {msg}.");
1184 std::process::exit(exit_code);
1185 }
1186 }
1187}
1188
1189#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 #[test]
1198 fn test_validate_module_id_valid() {
1199 for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1201 let result = validate_module_id(id);
1202 assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1203 }
1204 }
1205
1206 #[test]
1207 fn test_validate_module_id_too_long() {
1208 let long_id = "a".repeat(129);
1209 assert!(validate_module_id(&long_id).is_err());
1210 }
1211
1212 #[test]
1213 fn test_validate_module_id_invalid_format() {
1214 for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1215 assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1216 }
1217 }
1218
1219 #[test]
1220 fn test_validate_module_id_max_length() {
1221 let max_id = "a".repeat(128);
1222 assert!(validate_module_id(&max_id).is_ok());
1223 }
1224
1225 #[test]
1228 fn test_collect_input_no_stdin_drops_null_values() {
1229 use serde_json::json;
1230 let mut kwargs = HashMap::new();
1231 kwargs.insert("a".to_string(), json!(5));
1232 kwargs.insert("b".to_string(), Value::Null);
1233
1234 let result = collect_input(None, kwargs, false).unwrap();
1235 assert_eq!(result.get("a"), Some(&json!(5)));
1236 assert!(!result.contains_key("b"), "Null values must be dropped");
1237 }
1238
1239 #[test]
1240 fn test_collect_input_stdin_valid_json() {
1241 use serde_json::json;
1242 use std::io::Cursor;
1243 let stdin_bytes = b"{\"x\": 42}";
1244 let reader = Cursor::new(stdin_bytes.to_vec());
1245 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1246 assert_eq!(result.get("x"), Some(&json!(42)));
1247 }
1248
1249 #[test]
1250 fn test_collect_input_cli_overrides_stdin() {
1251 use serde_json::json;
1252 use std::io::Cursor;
1253 let stdin_bytes = b"{\"a\": 5}";
1254 let reader = Cursor::new(stdin_bytes.to_vec());
1255 let mut kwargs = HashMap::new();
1256 kwargs.insert("a".to_string(), json!(99));
1257 let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1258 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1259 }
1260
1261 #[test]
1262 fn test_collect_input_oversized_stdin_rejected() {
1263 use std::io::Cursor;
1264 let big = vec![b' '; 10 * 1024 * 1024 + 1];
1265 let reader = Cursor::new(big);
1266 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1267 assert!(matches!(err, CliError::InputTooLarge { .. }));
1268 }
1269
1270 #[test]
1271 fn test_collect_input_large_input_allowed() {
1272 use std::io::Cursor;
1273 let mut payload = b"{\"k\": \"".to_vec();
1274 payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1275 payload.extend(b"\"}");
1276 let reader = Cursor::new(payload);
1277 let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1278 assert!(
1279 result.is_ok(),
1280 "large_input=true must accept oversized payload"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_collect_input_invalid_json_returns_error() {
1286 use std::io::Cursor;
1287 let reader = Cursor::new(b"not json at all".to_vec());
1288 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1289 assert!(matches!(err, CliError::JsonParse(_)));
1290 }
1291
1292 #[test]
1293 fn test_collect_input_non_object_json_returns_error() {
1294 use std::io::Cursor;
1295 let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1296 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1297 assert!(matches!(err, CliError::NotAnObject));
1298 }
1299
1300 #[test]
1301 fn test_collect_input_empty_stdin_returns_empty_map() {
1302 use std::io::Cursor;
1303 let reader = Cursor::new(b"".to_vec());
1304 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1305 assert!(result.is_empty());
1306 }
1307
1308 #[test]
1309 fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1310 use serde_json::json;
1311 let mut kwargs = HashMap::new();
1312 kwargs.insert("foo".to_string(), json!("bar"));
1313 let result = collect_input(None, kwargs.clone(), false).unwrap();
1314 assert_eq!(result.get("foo"), Some(&json!("bar")));
1315 }
1316
1317 fn make_module_descriptor(
1325 name: &str,
1326 _description: &str,
1327 schema: Option<serde_json::Value>,
1328 ) -> apcore::registry::registry::ModuleDescriptor {
1329 apcore::registry::registry::ModuleDescriptor {
1330 name: name.to_string(),
1331 annotations: apcore::module::ModuleAnnotations::default(),
1332 input_schema: schema.unwrap_or(serde_json::Value::Null),
1333 output_schema: serde_json::Value::Object(Default::default()),
1334 enabled: true,
1335 tags: vec![],
1336 dependencies: vec![],
1337 }
1338 }
1339
1340 #[test]
1341 fn test_build_module_command_name_is_set() {
1342 let module = make_module_descriptor("math.add", "Add two numbers", None);
1343 let executor = mock_executor();
1344 let cmd = build_module_command(&module, executor).unwrap();
1345 assert_eq!(cmd.get_name(), "math.add");
1346 }
1347
1348 #[test]
1349 fn test_build_module_command_has_input_flag() {
1350 let module = make_module_descriptor("a.b", "desc", None);
1351 let executor = mock_executor();
1352 let cmd = build_module_command(&module, executor).unwrap();
1353 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1354 assert!(names.contains(&"input"), "must have --input flag");
1355 }
1356
1357 #[test]
1358 fn test_build_module_command_has_yes_flag() {
1359 let module = make_module_descriptor("a.b", "desc", None);
1360 let executor = mock_executor();
1361 let cmd = build_module_command(&module, executor).unwrap();
1362 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1363 assert!(names.contains(&"yes"), "must have --yes flag");
1364 }
1365
1366 #[test]
1367 fn test_build_module_command_has_large_input_flag() {
1368 let module = make_module_descriptor("a.b", "desc", None);
1369 let executor = mock_executor();
1370 let cmd = build_module_command(&module, executor).unwrap();
1371 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1372 assert!(
1373 names.contains(&"large-input"),
1374 "must have --large-input flag"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_build_module_command_has_format_flag() {
1380 let module = make_module_descriptor("a.b", "desc", None);
1381 let executor = mock_executor();
1382 let cmd = build_module_command(&module, executor).unwrap();
1383 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1384 assert!(names.contains(&"format"), "must have --format flag");
1385 }
1386
1387 #[test]
1388 fn test_build_module_command_has_sandbox_flag() {
1389 let module = make_module_descriptor("a.b", "desc", None);
1390 let executor = mock_executor();
1391 let cmd = build_module_command(&module, executor).unwrap();
1392 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1393 assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1394 }
1395
1396 #[test]
1397 fn test_build_module_command_reserved_name_returns_error() {
1398 for reserved in BUILTIN_COMMANDS {
1399 let module = make_module_descriptor(reserved, "desc", None);
1400 let executor = mock_executor();
1401 let result = build_module_command(&module, executor);
1402 assert!(
1403 matches!(result, Err(CliError::ReservedModuleId(_))),
1404 "expected ReservedModuleId for '{reserved}', got {result:?}"
1405 );
1406 }
1407 }
1408
1409 #[test]
1410 fn test_build_module_command_yes_has_short_flag() {
1411 let module = make_module_descriptor("a.b", "desc", None);
1412 let executor = mock_executor();
1413 let cmd = build_module_command(&module, executor).unwrap();
1414 let has_short_y = cmd
1415 .get_opts()
1416 .filter(|a| a.get_long() == Some("yes"))
1417 .any(|a| a.get_short() == Some('y'));
1418 assert!(has_short_y, "--yes must have short flag -y");
1419 }
1420
1421 struct CliMockRegistry {
1427 modules: Vec<String>,
1428 }
1429
1430 impl crate::discovery::RegistryProvider for CliMockRegistry {
1431 fn list(&self) -> Vec<String> {
1432 self.modules.clone()
1433 }
1434
1435 fn get_definition(&self, name: &str) -> Option<Value> {
1436 if self.modules.iter().any(|m| m == name) {
1437 Some(serde_json::json!({
1438 "module_id": name,
1439 "name": name,
1440 "input_schema": {},
1441 "output_schema": {},
1442 "enabled": true,
1443 "tags": [],
1444 "dependencies": [],
1445 }))
1446 } else {
1447 None
1448 }
1449 }
1450
1451 fn get_module_descriptor(
1452 &self,
1453 name: &str,
1454 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1455 if self.modules.iter().any(|m| m == name) {
1456 Some(apcore::registry::registry::ModuleDescriptor {
1457 name: name.to_string(),
1458 annotations: apcore::module::ModuleAnnotations::default(),
1459 input_schema: serde_json::Value::Object(Default::default()),
1460 output_schema: serde_json::Value::Object(Default::default()),
1461 enabled: true,
1462 tags: vec![],
1463 dependencies: vec![],
1464 })
1465 } else {
1466 None
1467 }
1468 }
1469 }
1470
1471 struct EmptyRegistry;
1473
1474 impl crate::discovery::RegistryProvider for EmptyRegistry {
1475 fn list(&self) -> Vec<String> {
1476 vec![]
1477 }
1478
1479 fn get_definition(&self, _name: &str) -> Option<Value> {
1480 None
1481 }
1482 }
1483
1484 struct MockExecutor;
1486
1487 impl ModuleExecutor for MockExecutor {}
1488
1489 fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1490 Arc::new(CliMockRegistry {
1491 modules: modules.iter().map(|s| s.to_string()).collect(),
1492 })
1493 }
1494
1495 fn mock_executor() -> Arc<dyn ModuleExecutor> {
1496 Arc::new(MockExecutor)
1497 }
1498
1499 #[test]
1500 fn test_lazy_module_group_list_commands_empty_registry() {
1501 let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1502 let cmds = group.list_commands();
1503 for builtin in ["exec", "list", "describe", "completion", "init", "man"] {
1504 assert!(
1505 cmds.contains(&builtin.to_string()),
1506 "missing builtin: {builtin}"
1507 );
1508 }
1509 let mut sorted = cmds.clone();
1511 sorted.sort();
1512 assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1513 }
1514
1515 #[test]
1516 fn test_lazy_module_group_list_commands_includes_modules() {
1517 let group = LazyModuleGroup::new(
1518 mock_registry(vec!["math.add", "text.summarize"]),
1519 mock_executor(),
1520 );
1521 let cmds = group.list_commands();
1522 assert!(cmds.contains(&"math.add".to_string()));
1523 assert!(cmds.contains(&"text.summarize".to_string()));
1524 }
1525
1526 #[test]
1527 fn test_lazy_module_group_list_commands_registry_error() {
1528 let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1529 let cmds = group.list_commands();
1530 assert!(!cmds.is_empty());
1532 assert!(cmds.contains(&"list".to_string()));
1533 }
1534
1535 #[test]
1536 fn test_lazy_module_group_get_command_builtin() {
1537 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1538 let cmd = group.get_command("list");
1539 assert!(cmd.is_some(), "get_command('list') must return Some");
1540 }
1541
1542 #[test]
1543 fn test_lazy_module_group_get_command_not_found() {
1544 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1545 let cmd = group.get_command("nonexistent.module");
1546 assert!(cmd.is_none());
1547 }
1548
1549 #[test]
1550 fn test_lazy_module_group_get_command_caches_module() {
1551 let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1552 let cmd1 = group.get_command("math.add");
1554 assert!(cmd1.is_some());
1555 let cmd2 = group.get_command("math.add");
1557 assert!(cmd2.is_some());
1558 assert_eq!(
1559 group.registry_lookup_count(),
1560 1,
1561 "cached after first lookup"
1562 );
1563 }
1564
1565 #[test]
1566 fn test_lazy_module_group_builtin_commands_sorted() {
1567 let mut sorted = BUILTIN_COMMANDS.to_vec();
1569 sorted.sort_unstable();
1570 assert_eq!(
1571 BUILTIN_COMMANDS,
1572 sorted.as_slice(),
1573 "BUILTIN_COMMANDS must be sorted"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_lazy_module_group_list_deduplicates_builtins() {
1579 let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1582 let cmds = group.list_commands();
1583 let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1584 assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1585 }
1586
1587 #[test]
1592 fn test_map_error_module_not_found_is_44() {
1593 assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1594 }
1595
1596 #[test]
1597 fn test_map_error_module_load_error_is_44() {
1598 assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1599 }
1600
1601 #[test]
1602 fn test_map_error_module_disabled_is_44() {
1603 assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1604 }
1605
1606 #[test]
1607 fn test_map_error_schema_validation_error_is_45() {
1608 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1609 }
1610
1611 #[test]
1612 fn test_map_error_approval_denied_is_46() {
1613 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1614 }
1615
1616 #[test]
1617 fn test_map_error_approval_timeout_is_46() {
1618 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1619 }
1620
1621 #[test]
1622 fn test_map_error_approval_pending_is_46() {
1623 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1624 }
1625
1626 #[test]
1627 fn test_map_error_config_not_found_is_47() {
1628 assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1629 }
1630
1631 #[test]
1632 fn test_map_error_config_invalid_is_47() {
1633 assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1634 }
1635
1636 #[test]
1637 fn test_map_error_schema_circular_ref_is_48() {
1638 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1639 }
1640
1641 #[test]
1642 fn test_map_error_acl_denied_is_77() {
1643 assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1644 }
1645
1646 #[test]
1647 fn test_map_error_module_execute_error_is_1() {
1648 assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1649 }
1650
1651 #[test]
1652 fn test_map_error_module_timeout_is_1() {
1653 assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1654 }
1655
1656 #[test]
1657 fn test_map_error_unknown_is_1() {
1658 assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1659 }
1660
1661 #[test]
1662 fn test_map_error_empty_string_is_1() {
1663 assert_eq!(map_apcore_error_to_exit_code(""), 1);
1664 }
1665
1666 #[test]
1671 fn test_set_audit_logger_none_clears_logger() {
1672 set_audit_logger(None);
1674 let guard = AUDIT_LOGGER.lock().unwrap();
1675 assert!(guard.is_none(), "setting None must clear the audit logger");
1676 }
1677
1678 #[test]
1679 fn test_set_audit_logger_some_stores_logger() {
1680 use crate::security::AuditLogger;
1681 set_audit_logger(Some(AuditLogger::new(None)));
1682 let guard = AUDIT_LOGGER.lock().unwrap();
1683 assert!(guard.is_some(), "setting Some must store the audit logger");
1684 drop(guard);
1686 set_audit_logger(None);
1687 }
1688
1689 #[test]
1694 fn test_validate_against_schema_passes_with_no_properties() {
1695 let schema = serde_json::json!({});
1696 let input = std::collections::HashMap::new();
1697 let result = validate_against_schema(&input, &schema);
1699 assert!(result.is_ok(), "empty schema must pass: {result:?}");
1700 }
1701
1702 #[test]
1703 fn test_validate_against_schema_required_field_missing_fails() {
1704 let schema = serde_json::json!({
1705 "properties": {
1706 "a": {"type": "integer"}
1707 },
1708 "required": ["a"]
1709 });
1710 let input: std::collections::HashMap<String, serde_json::Value> =
1711 std::collections::HashMap::new();
1712 let result = validate_against_schema(&input, &schema);
1713 assert!(result.is_err(), "missing required field must fail");
1714 }
1715
1716 #[test]
1717 fn test_validate_against_schema_required_field_present_passes() {
1718 let schema = serde_json::json!({
1719 "properties": {
1720 "a": {"type": "integer"}
1721 },
1722 "required": ["a"]
1723 });
1724 let mut input = std::collections::HashMap::new();
1725 input.insert("a".to_string(), serde_json::json!(42));
1726 let result = validate_against_schema(&input, &schema);
1727 assert!(
1728 result.is_ok(),
1729 "present required field must pass: {result:?}"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_validate_against_schema_no_required_any_input_passes() {
1735 let schema = serde_json::json!({
1736 "properties": {
1737 "x": {"type": "string"}
1738 }
1739 });
1740 let input: std::collections::HashMap<String, serde_json::Value> =
1741 std::collections::HashMap::new();
1742 let result = validate_against_schema(&input, &schema);
1743 assert!(result.is_ok(), "no required fields: empty input must pass");
1744 }
1745
1746 #[test]
1751 fn test_resolve_group_explicit_group() {
1752 let desc = serde_json::json!({
1753 "module_id": "my.thing",
1754 "metadata": {
1755 "display": {
1756 "cli": {
1757 "group": "tools",
1758 "alias": "thing"
1759 }
1760 }
1761 }
1762 });
1763 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1764 assert_eq!(group, Some("tools".to_string()));
1765 assert_eq!(cmd, "thing");
1766 }
1767
1768 #[test]
1769 fn test_resolve_group_explicit_empty_is_top_level() {
1770 let desc = serde_json::json!({
1771 "module_id": "my.thing",
1772 "metadata": {
1773 "display": {
1774 "cli": {
1775 "group": "",
1776 "alias": "thing"
1777 }
1778 }
1779 }
1780 });
1781 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1782 assert!(group.is_none());
1783 assert_eq!(cmd, "thing");
1784 }
1785
1786 #[test]
1787 fn test_resolve_group_dotted_alias() {
1788 let desc = serde_json::json!({
1789 "module_id": "math.add",
1790 "metadata": {
1791 "display": {
1792 "cli": { "alias": "math.add" }
1793 }
1794 }
1795 });
1796 let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
1797 assert_eq!(group, Some("math".to_string()));
1798 assert_eq!(cmd, "add");
1799 }
1800
1801 #[test]
1802 fn test_resolve_group_no_dot_is_top_level() {
1803 let desc = serde_json::json!({"module_id": "greet"});
1804 let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
1805 assert!(group.is_none());
1806 assert_eq!(cmd, "greet");
1807 }
1808
1809 #[test]
1810 fn test_resolve_group_dotted_module_id_default() {
1811 let desc = serde_json::json!({"module_id": "text.upper"});
1813 let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
1814 assert_eq!(group, Some("text".to_string()));
1815 assert_eq!(cmd, "upper");
1816 }
1817
1818 #[test]
1819 fn test_resolve_group_invalid_group_name_top_level() {
1820 let desc = serde_json::json!({
1824 "module_id": "x",
1825 "metadata": {
1826 "display": {
1827 "cli": { "group": "123Invalid" }
1828 }
1829 }
1830 });
1831 let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
1832 assert_eq!(group, Some("123Invalid".to_string()));
1833 }
1834
1835 #[test]
1836 fn test_grouped_module_group_list_commands_includes_groups() {
1837 let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
1838 let executor = mock_executor();
1839 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1840 let cmds = gmg.list_commands();
1841 assert!(
1842 cmds.contains(&"math".to_string()),
1843 "must contain group 'math'"
1844 );
1845 assert!(
1847 cmds.contains(&"greet".to_string()),
1848 "must contain top-level 'greet'"
1849 );
1850 }
1851
1852 #[test]
1853 fn test_grouped_module_group_get_command_group() {
1854 let registry = mock_registry(vec!["math.add", "math.mul"]);
1855 let executor = mock_executor();
1856 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1857 let cmd = gmg.get_command("math");
1858 assert!(cmd.is_some(), "must find group 'math'");
1859 let group_cmd = cmd.unwrap();
1860 let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
1861 assert!(subs.contains(&"add"));
1862 assert!(subs.contains(&"mul"));
1863 }
1864
1865 #[test]
1866 fn test_grouped_module_group_get_command_top_level() {
1867 let registry = mock_registry(vec!["greet"]);
1868 let executor = mock_executor();
1869 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1870 let cmd = gmg.get_command("greet");
1871 assert!(cmd.is_some(), "must find top-level 'greet'");
1872 }
1873
1874 #[test]
1875 fn test_grouped_module_group_get_command_not_found() {
1876 let registry = mock_registry(vec![]);
1877 let executor = mock_executor();
1878 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1879 assert!(gmg.get_command("nonexistent").is_none());
1880 }
1881
1882 #[test]
1883 fn test_grouped_module_group_help_max_length() {
1884 let registry = mock_registry(vec![]);
1885 let executor = mock_executor();
1886 let gmg = GroupedModuleGroup::new(registry, executor, 42);
1887 assert_eq!(gmg.help_text_max_length(), 42);
1888 }
1889
1890 #[test]
1891 fn test_is_valid_group_name_valid() {
1892 assert!(is_valid_group_name("math"));
1893 assert!(is_valid_group_name("my-group"));
1894 assert!(is_valid_group_name("g1"));
1895 assert!(is_valid_group_name("a_b"));
1896 }
1897
1898 #[test]
1899 fn test_is_valid_group_name_invalid() {
1900 assert!(!is_valid_group_name(""));
1901 assert!(!is_valid_group_name("1abc"));
1902 assert!(!is_valid_group_name("ABC"));
1903 assert!(!is_valid_group_name("a b"));
1904 }
1905}