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_NOT_FOUND, EXIT_MODULE_EXECUTE_ERROR,
827 EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR,
828 };
829 match error_code {
830 "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
831 "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
832 "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
833 "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
834 "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
835 "ACL_DENIED" => EXIT_ACL_DENIED,
836 _ => EXIT_MODULE_EXECUTE_ERROR,
837 }
838}
839
840pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
846 let code_str = serde_json::to_value(err.code)
848 .ok()
849 .and_then(|v| v.as_str().map(|s| s.to_string()))
850 .unwrap_or_default();
851 map_apcore_error_to_exit_code(&code_str)
852}
853
854pub(crate) fn validate_against_schema(
867 input: &HashMap<String, Value>,
868 schema: &Value,
869) -> Result<(), String> {
870 let required = match schema.get("required") {
872 Some(Value::Array(arr)) => arr,
873 _ => return Ok(()),
874 };
875 for req in required {
876 if let Some(field_name) = req.as_str() {
877 if !input.contains_key(field_name) {
878 return Err(format!("required field '{}' is missing", field_name));
879 }
880 }
881 }
882 Ok(())
883}
884
885pub fn reconcile_bool_pairs(
896 matches: &clap::ArgMatches,
897 bool_pairs: &[crate::schema_parser::BoolFlagPair],
898) -> HashMap<String, Value> {
899 let mut result = HashMap::new();
900 for pair in bool_pairs {
901 let pos_set = matches
904 .try_get_one::<bool>(&pair.prop_name)
905 .ok()
906 .flatten()
907 .copied()
908 .unwrap_or(false);
909 let neg_id = format!("no-{}", pair.prop_name);
910 let neg_set = matches
911 .try_get_one::<bool>(&neg_id)
912 .ok()
913 .flatten()
914 .copied()
915 .unwrap_or(false);
916 let val = if pos_set {
917 true
918 } else if neg_set {
919 false
920 } else {
921 pair.default_val
922 };
923 result.insert(pair.prop_name.clone(), Value::Bool(val));
924 }
925 result
926}
927
928fn extract_cli_kwargs(
933 matches: &clap::ArgMatches,
934 module_def: &apcore::registry::registry::ModuleDescriptor,
935) -> HashMap<String, Value> {
936 use crate::schema_parser::schema_to_clap_args;
937
938 let schema_args = match schema_to_clap_args(&module_def.input_schema) {
939 Ok(sa) => sa,
940 Err(_) => return HashMap::new(),
941 };
942
943 let mut kwargs: HashMap<String, Value> = HashMap::new();
944
945 for arg in &schema_args.args {
947 let id = arg.get_id().as_str().to_string();
948 if id.starts_with("no-") {
950 continue;
951 }
952 if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
955 kwargs.insert(id, Value::String(val.clone()));
956 } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
957 kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
958 } else {
959 kwargs.insert(id, Value::Null);
960 }
961 }
962
963 let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
965 kwargs.extend(bool_vals);
966
967 crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
969}
970
971async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
976 use tokio::io::AsyncWriteExt;
977
978 let mut child = tokio::process::Command::new(executable)
979 .stdin(std::process::Stdio::piped())
980 .stdout(std::process::Stdio::piped())
981 .stderr(std::process::Stdio::piped())
982 .spawn()
983 .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
984
985 if let Some(mut stdin) = child.stdin.take() {
987 let payload =
988 serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
989 stdin
990 .write_all(&payload)
991 .await
992 .map_err(|e| format!("failed to write to stdin: {e}"))?;
993 drop(stdin);
994 }
995
996 let output = child
997 .wait_with_output()
998 .await
999 .map_err(|e| format!("failed to read output: {e}"))?;
1000
1001 if !output.status.success() {
1002 let code = output.status.code().unwrap_or(1);
1003 let stderr_hint = String::from_utf8_lossy(&output.stderr);
1004 return Err(format!(
1005 "script exited with code {code}{}",
1006 if stderr_hint.is_empty() {
1007 String::new()
1008 } else {
1009 format!(": {}", stderr_hint.trim())
1010 }
1011 ));
1012 }
1013
1014 serde_json::from_slice(&output.stdout)
1015 .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1016}
1017
1018pub async fn dispatch_module(
1023 module_id: &str,
1024 matches: &clap::ArgMatches,
1025 registry: &Arc<dyn crate::discovery::RegistryProvider>,
1026 _executor: &Arc<dyn ModuleExecutor + 'static>,
1027 apcore_executor: &apcore::Executor,
1028) -> ! {
1029 use crate::{
1030 EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1031 EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1032 };
1033
1034 if let Err(e) = validate_module_id(module_id) {
1036 eprintln!("Error: Invalid module ID format: '{module_id}'.");
1037 let _ = e;
1038 std::process::exit(EXIT_INVALID_INPUT);
1039 }
1040
1041 let module_def = match registry.get_module_descriptor(module_id) {
1043 Some(def) => def,
1044 None => {
1045 eprintln!("Error: Module '{module_id}' not found in registry.");
1046 std::process::exit(EXIT_MODULE_NOT_FOUND);
1047 }
1048 };
1049
1050 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1052 let auto_approve = matches.get_flag("yes");
1053 let large_input = matches.get_flag("large-input");
1054 let format_flag = matches.get_one::<String>("format").cloned();
1055
1056 let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1058
1059 let merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1061 Ok(m) => m,
1062 Err(CliError::InputTooLarge { .. }) => {
1063 eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1064 std::process::exit(EXIT_INVALID_INPUT);
1065 }
1066 Err(CliError::JsonParse(detail)) => {
1067 eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1068 std::process::exit(EXIT_INVALID_INPUT);
1069 }
1070 Err(CliError::NotAnObject) => {
1071 eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1072 std::process::exit(EXIT_INVALID_INPUT);
1073 }
1074 Err(e) => {
1075 eprintln!("Error: {e}");
1076 std::process::exit(EXIT_INVALID_INPUT);
1077 }
1078 };
1079
1080 if let Some(schema) = module_def.input_schema.as_object() {
1082 if schema.contains_key("properties") {
1083 if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1084 eprintln!("Error: Validation failed: {detail}.");
1085 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1086 }
1087 }
1088 }
1089
1090 let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1092 if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
1093 eprintln!("Error: {e}");
1094 std::process::exit(EXIT_APPROVAL_DENIED);
1095 }
1096
1097 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1099
1100 let use_sandbox = matches.get_flag("sandbox");
1102
1103 let script_executable = EXECUTABLES
1105 .get()
1106 .and_then(|map| map.get(module_id))
1107 .cloned();
1108
1109 let start = std::time::Instant::now();
1111
1112 let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
1115 tokio::select! {
1117 res = execute_script(&exec_path, &input_value) => {
1118 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e))
1119 }
1120 _ = tokio::signal::ctrl_c() => {
1121 eprintln!("Execution cancelled.");
1122 std::process::exit(EXIT_SIGINT);
1123 }
1124 }
1125 } else if use_sandbox {
1126 let sandbox = crate::security::Sandbox::new(true, 0);
1127 tokio::select! {
1128 res = sandbox.execute(module_id, input_value.clone()) => {
1129 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string()))
1130 }
1131 _ = tokio::signal::ctrl_c() => {
1132 eprintln!("Execution cancelled.");
1133 std::process::exit(EXIT_SIGINT);
1134 }
1135 }
1136 } else {
1137 tokio::select! {
1139 res = apcore_executor.call(module_id, input_value.clone(), None, None) => {
1140 res.map_err(|e| {
1141 let code = map_module_error_to_exit_code(&e);
1142 (code, e.to_string())
1143 })
1144 }
1145 _ = tokio::signal::ctrl_c() => {
1146 eprintln!("Execution cancelled.");
1147 std::process::exit(EXIT_SIGINT);
1148 }
1149 }
1150 };
1151
1152 let duration_ms = start.elapsed().as_millis() as u64;
1153
1154 match result {
1155 Ok(output) => {
1156 if let Ok(guard) = AUDIT_LOGGER.lock() {
1158 if let Some(logger) = guard.as_ref() {
1159 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1160 }
1161 }
1162 let fmt = crate::output::resolve_format(format_flag.as_deref());
1164 println!("{}", crate::output::format_exec_result(&output, fmt));
1165 std::process::exit(EXIT_SUCCESS);
1166 }
1167 Err((exit_code, msg)) => {
1168 if let Ok(guard) = AUDIT_LOGGER.lock() {
1170 if let Some(logger) = guard.as_ref() {
1171 logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1172 }
1173 }
1174 eprintln!("Error: Module '{module_id}' execution failed: {msg}.");
1175 std::process::exit(exit_code);
1176 }
1177 }
1178}
1179
1180#[cfg(test)]
1185mod tests {
1186 use super::*;
1187
1188 #[test]
1189 fn test_validate_module_id_valid() {
1190 for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1192 let result = validate_module_id(id);
1193 assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1194 }
1195 }
1196
1197 #[test]
1198 fn test_validate_module_id_too_long() {
1199 let long_id = "a".repeat(129);
1200 assert!(validate_module_id(&long_id).is_err());
1201 }
1202
1203 #[test]
1204 fn test_validate_module_id_invalid_format() {
1205 for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1206 assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1207 }
1208 }
1209
1210 #[test]
1211 fn test_validate_module_id_max_length() {
1212 let max_id = "a".repeat(128);
1213 assert!(validate_module_id(&max_id).is_ok());
1214 }
1215
1216 #[test]
1219 fn test_collect_input_no_stdin_drops_null_values() {
1220 use serde_json::json;
1221 let mut kwargs = HashMap::new();
1222 kwargs.insert("a".to_string(), json!(5));
1223 kwargs.insert("b".to_string(), Value::Null);
1224
1225 let result = collect_input(None, kwargs, false).unwrap();
1226 assert_eq!(result.get("a"), Some(&json!(5)));
1227 assert!(!result.contains_key("b"), "Null values must be dropped");
1228 }
1229
1230 #[test]
1231 fn test_collect_input_stdin_valid_json() {
1232 use serde_json::json;
1233 use std::io::Cursor;
1234 let stdin_bytes = b"{\"x\": 42}";
1235 let reader = Cursor::new(stdin_bytes.to_vec());
1236 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1237 assert_eq!(result.get("x"), Some(&json!(42)));
1238 }
1239
1240 #[test]
1241 fn test_collect_input_cli_overrides_stdin() {
1242 use serde_json::json;
1243 use std::io::Cursor;
1244 let stdin_bytes = b"{\"a\": 5}";
1245 let reader = Cursor::new(stdin_bytes.to_vec());
1246 let mut kwargs = HashMap::new();
1247 kwargs.insert("a".to_string(), json!(99));
1248 let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1249 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1250 }
1251
1252 #[test]
1253 fn test_collect_input_oversized_stdin_rejected() {
1254 use std::io::Cursor;
1255 let big = vec![b' '; 10 * 1024 * 1024 + 1];
1256 let reader = Cursor::new(big);
1257 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1258 assert!(matches!(err, CliError::InputTooLarge { .. }));
1259 }
1260
1261 #[test]
1262 fn test_collect_input_large_input_allowed() {
1263 use std::io::Cursor;
1264 let mut payload = b"{\"k\": \"".to_vec();
1265 payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1266 payload.extend(b"\"}");
1267 let reader = Cursor::new(payload);
1268 let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1269 assert!(
1270 result.is_ok(),
1271 "large_input=true must accept oversized payload"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_collect_input_invalid_json_returns_error() {
1277 use std::io::Cursor;
1278 let reader = Cursor::new(b"not json at all".to_vec());
1279 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1280 assert!(matches!(err, CliError::JsonParse(_)));
1281 }
1282
1283 #[test]
1284 fn test_collect_input_non_object_json_returns_error() {
1285 use std::io::Cursor;
1286 let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1287 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1288 assert!(matches!(err, CliError::NotAnObject));
1289 }
1290
1291 #[test]
1292 fn test_collect_input_empty_stdin_returns_empty_map() {
1293 use std::io::Cursor;
1294 let reader = Cursor::new(b"".to_vec());
1295 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1296 assert!(result.is_empty());
1297 }
1298
1299 #[test]
1300 fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1301 use serde_json::json;
1302 let mut kwargs = HashMap::new();
1303 kwargs.insert("foo".to_string(), json!("bar"));
1304 let result = collect_input(None, kwargs.clone(), false).unwrap();
1305 assert_eq!(result.get("foo"), Some(&json!("bar")));
1306 }
1307
1308 fn make_module_descriptor(
1316 name: &str,
1317 _description: &str,
1318 schema: Option<serde_json::Value>,
1319 ) -> apcore::registry::registry::ModuleDescriptor {
1320 apcore::registry::registry::ModuleDescriptor {
1321 name: name.to_string(),
1322 annotations: apcore::module::ModuleAnnotations::default(),
1323 input_schema: schema.unwrap_or(serde_json::Value::Null),
1324 output_schema: serde_json::Value::Object(Default::default()),
1325 enabled: true,
1326 tags: vec![],
1327 dependencies: vec![],
1328 }
1329 }
1330
1331 #[test]
1332 fn test_build_module_command_name_is_set() {
1333 let module = make_module_descriptor("math.add", "Add two numbers", None);
1334 let executor = mock_executor();
1335 let cmd = build_module_command(&module, executor).unwrap();
1336 assert_eq!(cmd.get_name(), "math.add");
1337 }
1338
1339 #[test]
1340 fn test_build_module_command_has_input_flag() {
1341 let module = make_module_descriptor("a.b", "desc", None);
1342 let executor = mock_executor();
1343 let cmd = build_module_command(&module, executor).unwrap();
1344 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1345 assert!(names.contains(&"input"), "must have --input flag");
1346 }
1347
1348 #[test]
1349 fn test_build_module_command_has_yes_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(&"yes"), "must have --yes flag");
1355 }
1356
1357 #[test]
1358 fn test_build_module_command_has_large_input_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!(
1364 names.contains(&"large-input"),
1365 "must have --large-input flag"
1366 );
1367 }
1368
1369 #[test]
1370 fn test_build_module_command_has_format_flag() {
1371 let module = make_module_descriptor("a.b", "desc", None);
1372 let executor = mock_executor();
1373 let cmd = build_module_command(&module, executor).unwrap();
1374 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1375 assert!(names.contains(&"format"), "must have --format flag");
1376 }
1377
1378 #[test]
1379 fn test_build_module_command_has_sandbox_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(&"sandbox"), "must have --sandbox flag");
1385 }
1386
1387 #[test]
1388 fn test_build_module_command_reserved_name_returns_error() {
1389 for reserved in BUILTIN_COMMANDS {
1390 let module = make_module_descriptor(reserved, "desc", None);
1391 let executor = mock_executor();
1392 let result = build_module_command(&module, executor);
1393 assert!(
1394 matches!(result, Err(CliError::ReservedModuleId(_))),
1395 "expected ReservedModuleId for '{reserved}', got {result:?}"
1396 );
1397 }
1398 }
1399
1400 #[test]
1401 fn test_build_module_command_yes_has_short_flag() {
1402 let module = make_module_descriptor("a.b", "desc", None);
1403 let executor = mock_executor();
1404 let cmd = build_module_command(&module, executor).unwrap();
1405 let has_short_y = cmd
1406 .get_opts()
1407 .filter(|a| a.get_long() == Some("yes"))
1408 .any(|a| a.get_short() == Some('y'));
1409 assert!(has_short_y, "--yes must have short flag -y");
1410 }
1411
1412 struct CliMockRegistry {
1418 modules: Vec<String>,
1419 }
1420
1421 impl crate::discovery::RegistryProvider for CliMockRegistry {
1422 fn list(&self) -> Vec<String> {
1423 self.modules.clone()
1424 }
1425
1426 fn get_definition(&self, name: &str) -> Option<Value> {
1427 if self.modules.iter().any(|m| m == name) {
1428 Some(serde_json::json!({
1429 "module_id": name,
1430 "name": name,
1431 "input_schema": {},
1432 "output_schema": {},
1433 "enabled": true,
1434 "tags": [],
1435 "dependencies": [],
1436 }))
1437 } else {
1438 None
1439 }
1440 }
1441
1442 fn get_module_descriptor(
1443 &self,
1444 name: &str,
1445 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1446 if self.modules.iter().any(|m| m == name) {
1447 Some(apcore::registry::registry::ModuleDescriptor {
1448 name: name.to_string(),
1449 annotations: apcore::module::ModuleAnnotations::default(),
1450 input_schema: serde_json::Value::Object(Default::default()),
1451 output_schema: serde_json::Value::Object(Default::default()),
1452 enabled: true,
1453 tags: vec![],
1454 dependencies: vec![],
1455 })
1456 } else {
1457 None
1458 }
1459 }
1460 }
1461
1462 struct EmptyRegistry;
1464
1465 impl crate::discovery::RegistryProvider for EmptyRegistry {
1466 fn list(&self) -> Vec<String> {
1467 vec![]
1468 }
1469
1470 fn get_definition(&self, _name: &str) -> Option<Value> {
1471 None
1472 }
1473 }
1474
1475 struct MockExecutor;
1477
1478 impl ModuleExecutor for MockExecutor {}
1479
1480 fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1481 Arc::new(CliMockRegistry {
1482 modules: modules.iter().map(|s| s.to_string()).collect(),
1483 })
1484 }
1485
1486 fn mock_executor() -> Arc<dyn ModuleExecutor> {
1487 Arc::new(MockExecutor)
1488 }
1489
1490 #[test]
1491 fn test_lazy_module_group_list_commands_empty_registry() {
1492 let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1493 let cmds = group.list_commands();
1494 for builtin in ["exec", "list", "describe", "completion", "init", "man"] {
1495 assert!(
1496 cmds.contains(&builtin.to_string()),
1497 "missing builtin: {builtin}"
1498 );
1499 }
1500 let mut sorted = cmds.clone();
1502 sorted.sort();
1503 assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1504 }
1505
1506 #[test]
1507 fn test_lazy_module_group_list_commands_includes_modules() {
1508 let group = LazyModuleGroup::new(
1509 mock_registry(vec!["math.add", "text.summarize"]),
1510 mock_executor(),
1511 );
1512 let cmds = group.list_commands();
1513 assert!(cmds.contains(&"math.add".to_string()));
1514 assert!(cmds.contains(&"text.summarize".to_string()));
1515 }
1516
1517 #[test]
1518 fn test_lazy_module_group_list_commands_registry_error() {
1519 let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1520 let cmds = group.list_commands();
1521 assert!(!cmds.is_empty());
1523 assert!(cmds.contains(&"list".to_string()));
1524 }
1525
1526 #[test]
1527 fn test_lazy_module_group_get_command_builtin() {
1528 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1529 let cmd = group.get_command("list");
1530 assert!(cmd.is_some(), "get_command('list') must return Some");
1531 }
1532
1533 #[test]
1534 fn test_lazy_module_group_get_command_not_found() {
1535 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1536 let cmd = group.get_command("nonexistent.module");
1537 assert!(cmd.is_none());
1538 }
1539
1540 #[test]
1541 fn test_lazy_module_group_get_command_caches_module() {
1542 let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1543 let cmd1 = group.get_command("math.add");
1545 assert!(cmd1.is_some());
1546 let cmd2 = group.get_command("math.add");
1548 assert!(cmd2.is_some());
1549 assert_eq!(
1550 group.registry_lookup_count(),
1551 1,
1552 "cached after first lookup"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_lazy_module_group_builtin_commands_sorted() {
1558 let mut sorted = BUILTIN_COMMANDS.to_vec();
1560 sorted.sort_unstable();
1561 assert_eq!(
1562 BUILTIN_COMMANDS,
1563 sorted.as_slice(),
1564 "BUILTIN_COMMANDS must be sorted"
1565 );
1566 }
1567
1568 #[test]
1569 fn test_lazy_module_group_list_deduplicates_builtins() {
1570 let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1573 let cmds = group.list_commands();
1574 let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1575 assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1576 }
1577
1578 #[test]
1583 fn test_map_error_module_not_found_is_44() {
1584 assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1585 }
1586
1587 #[test]
1588 fn test_map_error_module_load_error_is_44() {
1589 assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1590 }
1591
1592 #[test]
1593 fn test_map_error_module_disabled_is_44() {
1594 assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1595 }
1596
1597 #[test]
1598 fn test_map_error_schema_validation_error_is_45() {
1599 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1600 }
1601
1602 #[test]
1603 fn test_map_error_approval_denied_is_46() {
1604 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1605 }
1606
1607 #[test]
1608 fn test_map_error_approval_timeout_is_46() {
1609 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1610 }
1611
1612 #[test]
1613 fn test_map_error_approval_pending_is_46() {
1614 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1615 }
1616
1617 #[test]
1618 fn test_map_error_config_not_found_is_47() {
1619 assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1620 }
1621
1622 #[test]
1623 fn test_map_error_config_invalid_is_47() {
1624 assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1625 }
1626
1627 #[test]
1628 fn test_map_error_schema_circular_ref_is_48() {
1629 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1630 }
1631
1632 #[test]
1633 fn test_map_error_acl_denied_is_77() {
1634 assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1635 }
1636
1637 #[test]
1638 fn test_map_error_module_execute_error_is_1() {
1639 assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1640 }
1641
1642 #[test]
1643 fn test_map_error_module_timeout_is_1() {
1644 assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1645 }
1646
1647 #[test]
1648 fn test_map_error_unknown_is_1() {
1649 assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1650 }
1651
1652 #[test]
1653 fn test_map_error_empty_string_is_1() {
1654 assert_eq!(map_apcore_error_to_exit_code(""), 1);
1655 }
1656
1657 #[test]
1662 fn test_set_audit_logger_none_clears_logger() {
1663 set_audit_logger(None);
1665 let guard = AUDIT_LOGGER.lock().unwrap();
1666 assert!(guard.is_none(), "setting None must clear the audit logger");
1667 }
1668
1669 #[test]
1670 fn test_set_audit_logger_some_stores_logger() {
1671 use crate::security::AuditLogger;
1672 set_audit_logger(Some(AuditLogger::new(None)));
1673 let guard = AUDIT_LOGGER.lock().unwrap();
1674 assert!(guard.is_some(), "setting Some must store the audit logger");
1675 drop(guard);
1677 set_audit_logger(None);
1678 }
1679
1680 #[test]
1685 fn test_validate_against_schema_passes_with_no_properties() {
1686 let schema = serde_json::json!({});
1687 let input = std::collections::HashMap::new();
1688 let result = validate_against_schema(&input, &schema);
1690 assert!(result.is_ok(), "empty schema must pass: {result:?}");
1691 }
1692
1693 #[test]
1694 fn test_validate_against_schema_required_field_missing_fails() {
1695 let schema = serde_json::json!({
1696 "properties": {
1697 "a": {"type": "integer"}
1698 },
1699 "required": ["a"]
1700 });
1701 let input: std::collections::HashMap<String, serde_json::Value> =
1702 std::collections::HashMap::new();
1703 let result = validate_against_schema(&input, &schema);
1704 assert!(result.is_err(), "missing required field must fail");
1705 }
1706
1707 #[test]
1708 fn test_validate_against_schema_required_field_present_passes() {
1709 let schema = serde_json::json!({
1710 "properties": {
1711 "a": {"type": "integer"}
1712 },
1713 "required": ["a"]
1714 });
1715 let mut input = std::collections::HashMap::new();
1716 input.insert("a".to_string(), serde_json::json!(42));
1717 let result = validate_against_schema(&input, &schema);
1718 assert!(
1719 result.is_ok(),
1720 "present required field must pass: {result:?}"
1721 );
1722 }
1723
1724 #[test]
1725 fn test_validate_against_schema_no_required_any_input_passes() {
1726 let schema = serde_json::json!({
1727 "properties": {
1728 "x": {"type": "string"}
1729 }
1730 });
1731 let input: std::collections::HashMap<String, serde_json::Value> =
1732 std::collections::HashMap::new();
1733 let result = validate_against_schema(&input, &schema);
1734 assert!(result.is_ok(), "no required fields: empty input must pass");
1735 }
1736
1737 #[test]
1742 fn test_resolve_group_explicit_group() {
1743 let desc = serde_json::json!({
1744 "module_id": "my.thing",
1745 "metadata": {
1746 "display": {
1747 "cli": {
1748 "group": "tools",
1749 "alias": "thing"
1750 }
1751 }
1752 }
1753 });
1754 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1755 assert_eq!(group, Some("tools".to_string()));
1756 assert_eq!(cmd, "thing");
1757 }
1758
1759 #[test]
1760 fn test_resolve_group_explicit_empty_is_top_level() {
1761 let desc = serde_json::json!({
1762 "module_id": "my.thing",
1763 "metadata": {
1764 "display": {
1765 "cli": {
1766 "group": "",
1767 "alias": "thing"
1768 }
1769 }
1770 }
1771 });
1772 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1773 assert!(group.is_none());
1774 assert_eq!(cmd, "thing");
1775 }
1776
1777 #[test]
1778 fn test_resolve_group_dotted_alias() {
1779 let desc = serde_json::json!({
1780 "module_id": "math.add",
1781 "metadata": {
1782 "display": {
1783 "cli": { "alias": "math.add" }
1784 }
1785 }
1786 });
1787 let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
1788 assert_eq!(group, Some("math".to_string()));
1789 assert_eq!(cmd, "add");
1790 }
1791
1792 #[test]
1793 fn test_resolve_group_no_dot_is_top_level() {
1794 let desc = serde_json::json!({"module_id": "greet"});
1795 let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
1796 assert!(group.is_none());
1797 assert_eq!(cmd, "greet");
1798 }
1799
1800 #[test]
1801 fn test_resolve_group_dotted_module_id_default() {
1802 let desc = serde_json::json!({"module_id": "text.upper"});
1804 let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
1805 assert_eq!(group, Some("text".to_string()));
1806 assert_eq!(cmd, "upper");
1807 }
1808
1809 #[test]
1810 fn test_resolve_group_invalid_group_name_top_level() {
1811 let desc = serde_json::json!({
1815 "module_id": "x",
1816 "metadata": {
1817 "display": {
1818 "cli": { "group": "123Invalid" }
1819 }
1820 }
1821 });
1822 let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
1823 assert_eq!(group, Some("123Invalid".to_string()));
1824 }
1825
1826 #[test]
1827 fn test_grouped_module_group_list_commands_includes_groups() {
1828 let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
1829 let executor = mock_executor();
1830 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1831 let cmds = gmg.list_commands();
1832 assert!(
1833 cmds.contains(&"math".to_string()),
1834 "must contain group 'math'"
1835 );
1836 assert!(
1838 cmds.contains(&"greet".to_string()),
1839 "must contain top-level 'greet'"
1840 );
1841 }
1842
1843 #[test]
1844 fn test_grouped_module_group_get_command_group() {
1845 let registry = mock_registry(vec!["math.add", "math.mul"]);
1846 let executor = mock_executor();
1847 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1848 let cmd = gmg.get_command("math");
1849 assert!(cmd.is_some(), "must find group 'math'");
1850 let group_cmd = cmd.unwrap();
1851 let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
1852 assert!(subs.contains(&"add"));
1853 assert!(subs.contains(&"mul"));
1854 }
1855
1856 #[test]
1857 fn test_grouped_module_group_get_command_top_level() {
1858 let registry = mock_registry(vec!["greet"]);
1859 let executor = mock_executor();
1860 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1861 let cmd = gmg.get_command("greet");
1862 assert!(cmd.is_some(), "must find top-level 'greet'");
1863 }
1864
1865 #[test]
1866 fn test_grouped_module_group_get_command_not_found() {
1867 let registry = mock_registry(vec![]);
1868 let executor = mock_executor();
1869 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1870 assert!(gmg.get_command("nonexistent").is_none());
1871 }
1872
1873 #[test]
1874 fn test_grouped_module_group_help_max_length() {
1875 let registry = mock_registry(vec![]);
1876 let executor = mock_executor();
1877 let gmg = GroupedModuleGroup::new(registry, executor, 42);
1878 assert_eq!(gmg.help_text_max_length(), 42);
1879 }
1880
1881 #[test]
1882 fn test_is_valid_group_name_valid() {
1883 assert!(is_valid_group_name("math"));
1884 assert!(is_valid_group_name("my-group"));
1885 assert!(is_valid_group_name("g1"));
1886 assert!(is_valid_group_name("a_b"));
1887 }
1888
1889 #[test]
1890 fn test_is_valid_group_name_invalid() {
1891 assert!(!is_valid_group_name(""));
1892 assert!(!is_valid_group_name("1abc"));
1893 assert!(!is_valid_group_name("ABC"));
1894 assert!(!is_valid_group_name("a b"));
1895 }
1896}