1use std::collections::HashMap;
6use std::io::Read;
7use std::path::PathBuf;
8use std::sync::{Arc, Mutex, OnceLock};
9
10use serde_json::Value;
11use thiserror::Error;
12
13use crate::security::AuditLogger;
14
15pub trait ModuleExecutor: Send + Sync {}
25
26pub struct ApCoreExecutorAdapter(pub apcore::Executor);
28
29impl ModuleExecutor for ApCoreExecutorAdapter {}
30
31#[derive(Debug, Error)]
37pub enum CliError {
38 #[error("invalid module id: {0}")]
39 InvalidModuleId(String),
40
41 #[error("reserved module id: '{0}' conflicts with a built-in command name")]
42 ReservedModuleId(String),
43
44 #[error("stdin read error: {0}")]
45 StdinRead(String),
46
47 #[error("json parse error: {0}")]
48 JsonParse(String),
49
50 #[error("input too large (limit {limit} bytes, got {actual} bytes)")]
51 InputTooLarge { limit: usize, actual: usize },
52
53 #[error("expected JSON object, got a different type")]
54 NotAnObject,
55}
56
57static AUDIT_LOGGER: Mutex<Option<AuditLogger>> = Mutex::new(None);
62
63static EXECUTABLES: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
68
69pub fn set_executables(map: HashMap<String, PathBuf>) {
73 let _ = EXECUTABLES.set(map);
74}
75
76pub fn set_audit_logger(audit_logger: Option<AuditLogger>) {
81 match AUDIT_LOGGER.lock() {
82 Ok(mut guard) => {
83 *guard = audit_logger;
84 }
85 Err(_poisoned) => {
86 tracing::warn!("AUDIT_LOGGER mutex poisoned — audit logger not updated");
87 }
88 }
89}
90
91pub fn add_dispatch_flags(cmd: clap::Command) -> clap::Command {
99 use clap::{Arg, ArgAction};
100 cmd.arg(
101 Arg::new("input")
102 .long("input")
103 .value_name("SOURCE")
104 .help("Input source (file path or '-' for stdin)"),
105 )
106 .arg(
107 Arg::new("yes")
108 .long("yes")
109 .short('y')
110 .action(ArgAction::SetTrue)
111 .help("Auto-approve all confirmation prompts"),
112 )
113 .arg(
114 Arg::new("large-input")
115 .long("large-input")
116 .action(ArgAction::SetTrue)
117 .help("Allow larger-than-default input payloads"),
118 )
119 .arg(
120 Arg::new("format")
121 .long("format")
122 .value_parser(["table", "json"])
123 .help("Output format (table or json)"),
124 )
125 .arg(
126 Arg::new("sandbox")
127 .long("sandbox")
128 .action(ArgAction::SetTrue)
129 .help("Run module in subprocess sandbox"),
130 )
131}
132
133pub fn exec_command() -> clap::Command {
137 use clap::{Arg, Command};
138
139 let cmd = Command::new("exec").about("Execute an apcore module").arg(
140 Arg::new("module_id")
141 .required(true)
142 .value_name("MODULE_ID")
143 .help("Fully-qualified module ID to execute"),
144 );
145 add_dispatch_flags(cmd)
146}
147
148pub const BUILTIN_COMMANDS: &[&str] = &["completion", "describe", "exec", "init", "list", "man"];
154
155pub struct LazyModuleGroup {
161 registry: Arc<dyn crate::discovery::RegistryProvider>,
162 #[allow(dead_code)]
163 executor: Arc<dyn ModuleExecutor>,
164 module_cache: HashMap<String, bool>,
167 #[cfg(test)]
169 pub registry_lookup_count: usize,
170}
171
172impl LazyModuleGroup {
173 pub fn new(
179 registry: Arc<dyn crate::discovery::RegistryProvider>,
180 executor: Arc<dyn ModuleExecutor>,
181 ) -> Self {
182 Self {
183 registry,
184 executor,
185 module_cache: HashMap::new(),
186 #[cfg(test)]
187 registry_lookup_count: 0,
188 }
189 }
190
191 pub fn list_commands(&self) -> Vec<String> {
193 let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
194 names.extend(self.registry.list());
195 names.sort_unstable();
197 names.dedup();
198 names
199 }
200
201 pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
206 if BUILTIN_COMMANDS.contains(&name) {
207 return Some(clap::Command::new(name.to_string()));
208 }
209 if self.module_cache.contains_key(name) {
211 return Some(clap::Command::new(name.to_string()));
212 }
213 #[cfg(test)]
215 {
216 self.registry_lookup_count += 1;
217 }
218 let _descriptor = self.registry.get_module_descriptor(name)?;
219 let cmd = clap::Command::new(name.to_string());
220 self.module_cache.insert(name.to_string(), true);
221 tracing::debug!("Loaded module command: {name}");
222 Some(cmd)
223 }
224
225 #[cfg(test)]
228 pub fn registry_lookup_count(&self) -> usize {
229 self.registry_lookup_count
230 }
231}
232
233pub struct GroupedModuleGroup {
243 registry: Arc<dyn crate::discovery::RegistryProvider>,
244 #[allow(dead_code)]
245 executor: Arc<dyn ModuleExecutor>,
246 #[allow(dead_code)]
247 help_text_max_length: usize,
248 group_map: HashMap<String, HashMap<String, (String, Value)>>,
249 top_level_modules: HashMap<String, (String, Value)>,
250 alias_map: HashMap<String, String>,
251 descriptor_cache: HashMap<String, Value>,
252 group_map_built: bool,
253}
254
255impl GroupedModuleGroup {
256 pub fn new(
258 registry: Arc<dyn crate::discovery::RegistryProvider>,
259 executor: Arc<dyn ModuleExecutor>,
260 help_text_max_length: usize,
261 ) -> Self {
262 Self {
263 registry,
264 executor,
265 help_text_max_length,
266 group_map: HashMap::new(),
267 top_level_modules: HashMap::new(),
268 alias_map: HashMap::new(),
269 descriptor_cache: HashMap::new(),
270 group_map_built: false,
271 }
272 }
273
274 pub fn resolve_group(module_id: &str, descriptor: &Value) -> (Option<String>, String) {
279 let display = crate::display_helpers::get_display(descriptor);
280 let cli = display.get("cli").unwrap_or(&Value::Null);
281
282 if let Some(group_val) = cli.get("group") {
284 if let Some(g) = group_val.as_str() {
285 if g.is_empty() {
286 let alias = cli
288 .get("alias")
289 .and_then(|v| v.as_str())
290 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
291 .unwrap_or(module_id);
292 return (None, alias.to_string());
293 }
294 let alias = cli
296 .get("alias")
297 .and_then(|v| v.as_str())
298 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
299 .unwrap_or(module_id);
300 return (Some(g.to_string()), alias.to_string());
301 }
302 }
303
304 let alias = cli
306 .get("alias")
307 .and_then(|v| v.as_str())
308 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
309 .unwrap_or(module_id);
310
311 if let Some(dot_pos) = alias.find('.') {
313 let group = &alias[..dot_pos];
314 let cmd = &alias[dot_pos + 1..];
315 return (Some(group.to_string()), cmd.to_string());
316 }
317
318 (None, alias.to_string())
320 }
321
322 pub fn build_group_map(&mut self) {
324 if self.group_map_built {
325 return;
326 }
327 self.group_map_built = true;
328
329 let module_ids = self.registry.list();
330 for mid in &module_ids {
331 let descriptor = match self.registry.get_definition(mid) {
332 Some(d) => d,
333 None => continue,
334 };
335
336 let (group, cmd_name) = Self::resolve_group(mid, &descriptor);
337 self.alias_map.insert(cmd_name.clone(), mid.clone());
338 self.descriptor_cache
339 .insert(mid.clone(), descriptor.clone());
340
341 match group {
342 Some(g) if is_valid_group_name(&g) => {
343 let entry = self.group_map.entry(g).or_default();
344 entry.insert(cmd_name, (mid.clone(), descriptor));
345 }
346 Some(g) => {
347 tracing::warn!(
348 "Module '{}': group name '{}' is not shell-safe \
349 -- treating as top-level.",
350 mid,
351 g,
352 );
353 self.top_level_modules
354 .insert(cmd_name, (mid.clone(), descriptor));
355 }
356 None => {
357 self.top_level_modules
358 .insert(cmd_name, (mid.clone(), descriptor));
359 }
360 }
361 }
362 }
363
364 pub fn list_commands(&mut self) -> Vec<String> {
367 self.build_group_map();
368 let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
369 for group_name in self.group_map.keys() {
370 names.push(group_name.clone());
371 }
372 for cmd_name in self.top_level_modules.keys() {
373 names.push(cmd_name.clone());
374 }
375 names.sort_unstable();
376 names.dedup();
377 names
378 }
379
380 pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
383 self.build_group_map();
384
385 if BUILTIN_COMMANDS.contains(&name) {
386 return Some(clap::Command::new(name.to_string()));
387 }
388
389 if let Some(members) = self.group_map.get(name) {
391 let mut group_cmd = clap::Command::new(name.to_string());
392 for (cmd_name, (_mid, _desc)) in members {
393 group_cmd = group_cmd.subcommand(clap::Command::new(cmd_name.clone()));
394 }
395 return Some(group_cmd);
396 }
397
398 if self.top_level_modules.contains_key(name) {
400 return Some(clap::Command::new(name.to_string()));
401 }
402
403 None
404 }
405
406 #[cfg(test)]
408 pub fn help_text_max_length(&self) -> usize {
409 self.help_text_max_length
410 }
411}
412
413fn is_valid_group_name(s: &str) -> bool {
415 if s.is_empty() {
416 return false;
417 }
418 let mut chars = s.chars();
419 match chars.next() {
420 Some(c) if c.is_ascii_lowercase() => {}
421 _ => return false,
422 }
423 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
424}
425
426const RESERVED_FLAG_NAMES: &[&str] = &["input", "yes", "large-input", "format", "sandbox"];
434
435pub fn build_module_command(
452 module_def: &apcore::registry::registry::ModuleDescriptor,
453 executor: Arc<dyn ModuleExecutor>,
454) -> Result<clap::Command, CliError> {
455 build_module_command_with_limit(
456 module_def,
457 executor,
458 crate::schema_parser::HELP_TEXT_MAX_LEN,
459 )
460}
461
462pub fn build_module_command_with_limit(
465 module_def: &apcore::registry::registry::ModuleDescriptor,
466 executor: Arc<dyn ModuleExecutor>,
467 help_text_max_length: usize,
468) -> Result<clap::Command, CliError> {
469 let module_id = &module_def.name;
470
471 if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
473 return Err(CliError::ReservedModuleId(module_id.clone()));
474 }
475
476 let resolved_schema =
478 crate::ref_resolver::resolve_refs(&module_def.input_schema, 32, module_id)
479 .unwrap_or_else(|_| module_def.input_schema.clone());
480
481 let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
483 &resolved_schema,
484 help_text_max_length,
485 )
486 .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
487
488 for arg in &schema_args.args {
490 if let Some(long) = arg.get_long() {
491 if RESERVED_FLAG_NAMES.contains(&long) {
492 return Err(CliError::ReservedModuleId(format!(
493 "module '{module_id}' schema property '{long}' conflicts \
494 with a reserved CLI option name"
495 )));
496 }
497 }
498 }
499
500 let _ = executor;
502
503 let mut cmd = clap::Command::new(module_id.clone())
504 .arg(
506 clap::Arg::new("input")
507 .long("input")
508 .value_name("SOURCE")
509 .help("Read input from file or STDIN ('-')."),
510 )
511 .arg(
512 clap::Arg::new("yes")
513 .long("yes")
514 .short('y')
515 .action(clap::ArgAction::SetTrue)
516 .help("Bypass approval prompts."),
517 )
518 .arg(
519 clap::Arg::new("large-input")
520 .long("large-input")
521 .action(clap::ArgAction::SetTrue)
522 .help("Allow STDIN input larger than 10MB."),
523 )
524 .arg(
525 clap::Arg::new("format")
526 .long("format")
527 .value_parser(["json", "table"])
528 .help("Output format."),
529 )
530 .arg(
531 clap::Arg::new("sandbox")
532 .long("sandbox")
533 .action(clap::ArgAction::SetTrue)
534 .help("Run module in subprocess sandbox."),
535 );
536
537 for arg in schema_args.args {
539 cmd = cmd.arg(arg);
540 }
541
542 Ok(cmd)
543}
544
545const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; pub fn collect_input_from_reader<R: Read>(
562 stdin_flag: Option<&str>,
563 cli_kwargs: HashMap<String, Value>,
564 large_input: bool,
565 mut reader: R,
566) -> Result<HashMap<String, Value>, CliError> {
567 let cli_non_null: HashMap<String, Value> = cli_kwargs
569 .into_iter()
570 .filter(|(_, v)| !v.is_null())
571 .collect();
572
573 if stdin_flag != Some("-") {
574 return Ok(cli_non_null);
575 }
576
577 let mut buf = Vec::new();
578 reader
579 .read_to_end(&mut buf)
580 .map_err(|e| CliError::StdinRead(e.to_string()))?;
581
582 if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
583 return Err(CliError::InputTooLarge {
584 limit: STDIN_SIZE_LIMIT_BYTES,
585 actual: buf.len(),
586 });
587 }
588
589 if buf.is_empty() {
590 return Ok(cli_non_null);
591 }
592
593 let stdin_value: Value =
594 serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
595
596 let stdin_map = match stdin_value {
597 Value::Object(m) => m,
598 _ => return Err(CliError::NotAnObject),
599 };
600
601 let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
603 merged.extend(cli_non_null);
604 Ok(merged)
605}
606
607pub fn collect_input(
622 stdin_flag: Option<&str>,
623 cli_kwargs: HashMap<String, Value>,
624 large_input: bool,
625) -> Result<HashMap<String, Value>, CliError> {
626 collect_input_from_reader(stdin_flag, cli_kwargs, large_input, std::io::stdin())
627}
628
629const MODULE_ID_MAX_LEN: usize = 128;
634
635pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
646 if module_id.len() > MODULE_ID_MAX_LEN {
647 return Err(CliError::InvalidModuleId(format!(
648 "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
649 )));
650 }
651 if !is_valid_module_id(module_id) {
652 return Err(CliError::InvalidModuleId(format!(
653 "Invalid module ID format: '{module_id}'."
654 )));
655 }
656 Ok(())
657}
658
659#[inline]
663fn is_valid_module_id(s: &str) -> bool {
664 if s.is_empty() {
665 return false;
666 }
667 for segment in s.split('.') {
669 if segment.is_empty() {
670 return false;
672 }
673 let mut chars = segment.chars();
674 match chars.next() {
676 Some(c) if c.is_ascii_lowercase() => {}
677 _ => return false,
678 }
679 for c in chars {
681 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
682 return false;
683 }
684 }
685 }
686 true
687}
688
689pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
704 use crate::{
705 EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_NOT_FOUND, EXIT_MODULE_EXECUTE_ERROR,
706 EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR,
707 };
708 match error_code {
709 "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
710 "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
711 "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
712 "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
713 "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
714 "ACL_DENIED" => EXIT_ACL_DENIED,
715 _ => EXIT_MODULE_EXECUTE_ERROR,
716 }
717}
718
719pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
725 let code_str = serde_json::to_value(err.code)
727 .ok()
728 .and_then(|v| v.as_str().map(|s| s.to_string()))
729 .unwrap_or_default();
730 map_apcore_error_to_exit_code(&code_str)
731}
732
733pub(crate) fn validate_against_schema(
746 input: &HashMap<String, Value>,
747 schema: &Value,
748) -> Result<(), String> {
749 let required = match schema.get("required") {
751 Some(Value::Array(arr)) => arr,
752 _ => return Ok(()),
753 };
754 for req in required {
755 if let Some(field_name) = req.as_str() {
756 if !input.contains_key(field_name) {
757 return Err(format!("required field '{}' is missing", field_name));
758 }
759 }
760 }
761 Ok(())
762}
763
764pub fn reconcile_bool_pairs(
775 matches: &clap::ArgMatches,
776 bool_pairs: &[crate::schema_parser::BoolFlagPair],
777) -> HashMap<String, Value> {
778 let mut result = HashMap::new();
779 for pair in bool_pairs {
780 let pos_set = matches
783 .try_get_one::<bool>(&pair.prop_name)
784 .ok()
785 .flatten()
786 .copied()
787 .unwrap_or(false);
788 let neg_id = format!("no-{}", pair.prop_name);
789 let neg_set = matches
790 .try_get_one::<bool>(&neg_id)
791 .ok()
792 .flatten()
793 .copied()
794 .unwrap_or(false);
795 let val = if pos_set {
796 true
797 } else if neg_set {
798 false
799 } else {
800 pair.default_val
801 };
802 result.insert(pair.prop_name.clone(), Value::Bool(val));
803 }
804 result
805}
806
807fn extract_cli_kwargs(
812 matches: &clap::ArgMatches,
813 module_def: &apcore::registry::registry::ModuleDescriptor,
814) -> HashMap<String, Value> {
815 use crate::schema_parser::schema_to_clap_args;
816
817 let schema_args = match schema_to_clap_args(&module_def.input_schema) {
818 Ok(sa) => sa,
819 Err(_) => return HashMap::new(),
820 };
821
822 let mut kwargs: HashMap<String, Value> = HashMap::new();
823
824 for arg in &schema_args.args {
826 let id = arg.get_id().as_str().to_string();
827 if id.starts_with("no-") {
829 continue;
830 }
831 if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
834 kwargs.insert(id, Value::String(val.clone()));
835 } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
836 kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
837 } else {
838 kwargs.insert(id, Value::Null);
839 }
840 }
841
842 let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
844 kwargs.extend(bool_vals);
845
846 crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
848}
849
850async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
855 use tokio::io::AsyncWriteExt;
856
857 let mut child = tokio::process::Command::new(executable)
858 .stdin(std::process::Stdio::piped())
859 .stdout(std::process::Stdio::piped())
860 .stderr(std::process::Stdio::piped())
861 .spawn()
862 .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
863
864 if let Some(mut stdin) = child.stdin.take() {
866 let payload =
867 serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
868 stdin
869 .write_all(&payload)
870 .await
871 .map_err(|e| format!("failed to write to stdin: {e}"))?;
872 drop(stdin);
873 }
874
875 let output = child
876 .wait_with_output()
877 .await
878 .map_err(|e| format!("failed to read output: {e}"))?;
879
880 if !output.status.success() {
881 let code = output.status.code().unwrap_or(1);
882 let stderr_hint = String::from_utf8_lossy(&output.stderr);
883 return Err(format!(
884 "script exited with code {code}{}",
885 if stderr_hint.is_empty() {
886 String::new()
887 } else {
888 format!(": {}", stderr_hint.trim())
889 }
890 ));
891 }
892
893 serde_json::from_slice(&output.stdout)
894 .map_err(|e| format!("script stdout is not valid JSON: {e}"))
895}
896
897pub async fn dispatch_module(
902 module_id: &str,
903 matches: &clap::ArgMatches,
904 registry: &Arc<dyn crate::discovery::RegistryProvider>,
905 _executor: &Arc<dyn ModuleExecutor + 'static>,
906 apcore_executor: &apcore::Executor,
907) -> ! {
908 use crate::{
909 EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
910 EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
911 };
912
913 if let Err(e) = validate_module_id(module_id) {
915 eprintln!("Error: Invalid module ID format: '{module_id}'.");
916 let _ = e;
917 std::process::exit(EXIT_INVALID_INPUT);
918 }
919
920 let module_def = match registry.get_module_descriptor(module_id) {
922 Some(def) => def,
923 None => {
924 eprintln!("Error: Module '{module_id}' not found in registry.");
925 std::process::exit(EXIT_MODULE_NOT_FOUND);
926 }
927 };
928
929 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
931 let auto_approve = matches.get_flag("yes");
932 let large_input = matches.get_flag("large-input");
933 let format_flag = matches.get_one::<String>("format").cloned();
934
935 let cli_kwargs = extract_cli_kwargs(matches, &module_def);
937
938 let merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
940 Ok(m) => m,
941 Err(CliError::InputTooLarge { .. }) => {
942 eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
943 std::process::exit(EXIT_INVALID_INPUT);
944 }
945 Err(CliError::JsonParse(detail)) => {
946 eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
947 std::process::exit(EXIT_INVALID_INPUT);
948 }
949 Err(CliError::NotAnObject) => {
950 eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
951 std::process::exit(EXIT_INVALID_INPUT);
952 }
953 Err(e) => {
954 eprintln!("Error: {e}");
955 std::process::exit(EXIT_INVALID_INPUT);
956 }
957 };
958
959 if let Some(schema) = module_def.input_schema.as_object() {
961 if schema.contains_key("properties") {
962 if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
963 eprintln!("Error: Validation failed: {detail}.");
964 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
965 }
966 }
967 }
968
969 let module_json = serde_json::to_value(&module_def).unwrap_or_default();
971 if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
972 eprintln!("Error: {e}");
973 std::process::exit(EXIT_APPROVAL_DENIED);
974 }
975
976 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
978
979 let use_sandbox = matches.get_flag("sandbox");
981
982 let script_executable = EXECUTABLES
984 .get()
985 .and_then(|map| map.get(module_id))
986 .cloned();
987
988 let start = std::time::Instant::now();
990
991 let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
994 tokio::select! {
996 res = execute_script(&exec_path, &input_value) => {
997 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e))
998 }
999 _ = tokio::signal::ctrl_c() => {
1000 eprintln!("Execution cancelled.");
1001 std::process::exit(EXIT_SIGINT);
1002 }
1003 }
1004 } else if use_sandbox {
1005 let sandbox = crate::security::Sandbox::new(true, 0);
1006 tokio::select! {
1007 res = sandbox.execute(module_id, input_value.clone()) => {
1008 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string()))
1009 }
1010 _ = tokio::signal::ctrl_c() => {
1011 eprintln!("Execution cancelled.");
1012 std::process::exit(EXIT_SIGINT);
1013 }
1014 }
1015 } else {
1016 tokio::select! {
1018 res = apcore_executor.call(module_id, input_value.clone(), None, None) => {
1019 res.map_err(|e| {
1020 let code = map_module_error_to_exit_code(&e);
1021 (code, e.to_string())
1022 })
1023 }
1024 _ = tokio::signal::ctrl_c() => {
1025 eprintln!("Execution cancelled.");
1026 std::process::exit(EXIT_SIGINT);
1027 }
1028 }
1029 };
1030
1031 let duration_ms = start.elapsed().as_millis() as u64;
1032
1033 match result {
1034 Ok(output) => {
1035 if let Ok(guard) = AUDIT_LOGGER.lock() {
1037 if let Some(logger) = guard.as_ref() {
1038 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1039 }
1040 }
1041 let fmt = crate::output::resolve_format(format_flag.as_deref());
1043 println!("{}", crate::output::format_exec_result(&output, fmt));
1044 std::process::exit(EXIT_SUCCESS);
1045 }
1046 Err((exit_code, msg)) => {
1047 if let Ok(guard) = AUDIT_LOGGER.lock() {
1049 if let Some(logger) = guard.as_ref() {
1050 logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1051 }
1052 }
1053 eprintln!("Error: Module '{module_id}' execution failed: {msg}.");
1054 std::process::exit(exit_code);
1055 }
1056 }
1057}
1058
1059#[cfg(test)]
1064mod tests {
1065 use super::*;
1066
1067 #[test]
1068 fn test_validate_module_id_valid() {
1069 for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1071 let result = validate_module_id(id);
1072 assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1073 }
1074 }
1075
1076 #[test]
1077 fn test_validate_module_id_too_long() {
1078 let long_id = "a".repeat(129);
1079 assert!(validate_module_id(&long_id).is_err());
1080 }
1081
1082 #[test]
1083 fn test_validate_module_id_invalid_format() {
1084 for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1085 assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1086 }
1087 }
1088
1089 #[test]
1090 fn test_validate_module_id_max_length() {
1091 let max_id = "a".repeat(128);
1092 assert!(validate_module_id(&max_id).is_ok());
1093 }
1094
1095 #[test]
1098 fn test_collect_input_no_stdin_drops_null_values() {
1099 use serde_json::json;
1100 let mut kwargs = HashMap::new();
1101 kwargs.insert("a".to_string(), json!(5));
1102 kwargs.insert("b".to_string(), Value::Null);
1103
1104 let result = collect_input(None, kwargs, false).unwrap();
1105 assert_eq!(result.get("a"), Some(&json!(5)));
1106 assert!(!result.contains_key("b"), "Null values must be dropped");
1107 }
1108
1109 #[test]
1110 fn test_collect_input_stdin_valid_json() {
1111 use serde_json::json;
1112 use std::io::Cursor;
1113 let stdin_bytes = b"{\"x\": 42}";
1114 let reader = Cursor::new(stdin_bytes.to_vec());
1115 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1116 assert_eq!(result.get("x"), Some(&json!(42)));
1117 }
1118
1119 #[test]
1120 fn test_collect_input_cli_overrides_stdin() {
1121 use serde_json::json;
1122 use std::io::Cursor;
1123 let stdin_bytes = b"{\"a\": 5}";
1124 let reader = Cursor::new(stdin_bytes.to_vec());
1125 let mut kwargs = HashMap::new();
1126 kwargs.insert("a".to_string(), json!(99));
1127 let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1128 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1129 }
1130
1131 #[test]
1132 fn test_collect_input_oversized_stdin_rejected() {
1133 use std::io::Cursor;
1134 let big = vec![b' '; 10 * 1024 * 1024 + 1];
1135 let reader = Cursor::new(big);
1136 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1137 assert!(matches!(err, CliError::InputTooLarge { .. }));
1138 }
1139
1140 #[test]
1141 fn test_collect_input_large_input_allowed() {
1142 use std::io::Cursor;
1143 let mut payload = b"{\"k\": \"".to_vec();
1144 payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1145 payload.extend(b"\"}");
1146 let reader = Cursor::new(payload);
1147 let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1148 assert!(
1149 result.is_ok(),
1150 "large_input=true must accept oversized payload"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_collect_input_invalid_json_returns_error() {
1156 use std::io::Cursor;
1157 let reader = Cursor::new(b"not json at all".to_vec());
1158 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1159 assert!(matches!(err, CliError::JsonParse(_)));
1160 }
1161
1162 #[test]
1163 fn test_collect_input_non_object_json_returns_error() {
1164 use std::io::Cursor;
1165 let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1166 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1167 assert!(matches!(err, CliError::NotAnObject));
1168 }
1169
1170 #[test]
1171 fn test_collect_input_empty_stdin_returns_empty_map() {
1172 use std::io::Cursor;
1173 let reader = Cursor::new(b"".to_vec());
1174 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1175 assert!(result.is_empty());
1176 }
1177
1178 #[test]
1179 fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1180 use serde_json::json;
1181 let mut kwargs = HashMap::new();
1182 kwargs.insert("foo".to_string(), json!("bar"));
1183 let result = collect_input(None, kwargs.clone(), false).unwrap();
1184 assert_eq!(result.get("foo"), Some(&json!("bar")));
1185 }
1186
1187 fn make_module_descriptor(
1195 name: &str,
1196 _description: &str,
1197 schema: Option<serde_json::Value>,
1198 ) -> apcore::registry::registry::ModuleDescriptor {
1199 apcore::registry::registry::ModuleDescriptor {
1200 name: name.to_string(),
1201 annotations: apcore::module::ModuleAnnotations::default(),
1202 input_schema: schema.unwrap_or(serde_json::Value::Null),
1203 output_schema: serde_json::Value::Object(Default::default()),
1204 enabled: true,
1205 tags: vec![],
1206 dependencies: vec![],
1207 }
1208 }
1209
1210 #[test]
1211 fn test_build_module_command_name_is_set() {
1212 let module = make_module_descriptor("math.add", "Add two numbers", None);
1213 let executor = mock_executor();
1214 let cmd = build_module_command(&module, executor).unwrap();
1215 assert_eq!(cmd.get_name(), "math.add");
1216 }
1217
1218 #[test]
1219 fn test_build_module_command_has_input_flag() {
1220 let module = make_module_descriptor("a.b", "desc", None);
1221 let executor = mock_executor();
1222 let cmd = build_module_command(&module, executor).unwrap();
1223 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1224 assert!(names.contains(&"input"), "must have --input flag");
1225 }
1226
1227 #[test]
1228 fn test_build_module_command_has_yes_flag() {
1229 let module = make_module_descriptor("a.b", "desc", None);
1230 let executor = mock_executor();
1231 let cmd = build_module_command(&module, executor).unwrap();
1232 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1233 assert!(names.contains(&"yes"), "must have --yes flag");
1234 }
1235
1236 #[test]
1237 fn test_build_module_command_has_large_input_flag() {
1238 let module = make_module_descriptor("a.b", "desc", None);
1239 let executor = mock_executor();
1240 let cmd = build_module_command(&module, executor).unwrap();
1241 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1242 assert!(
1243 names.contains(&"large-input"),
1244 "must have --large-input flag"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_build_module_command_has_format_flag() {
1250 let module = make_module_descriptor("a.b", "desc", None);
1251 let executor = mock_executor();
1252 let cmd = build_module_command(&module, executor).unwrap();
1253 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1254 assert!(names.contains(&"format"), "must have --format flag");
1255 }
1256
1257 #[test]
1258 fn test_build_module_command_has_sandbox_flag() {
1259 let module = make_module_descriptor("a.b", "desc", None);
1260 let executor = mock_executor();
1261 let cmd = build_module_command(&module, executor).unwrap();
1262 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1263 assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1264 }
1265
1266 #[test]
1267 fn test_build_module_command_reserved_name_returns_error() {
1268 for reserved in BUILTIN_COMMANDS {
1269 let module = make_module_descriptor(reserved, "desc", None);
1270 let executor = mock_executor();
1271 let result = build_module_command(&module, executor);
1272 assert!(
1273 matches!(result, Err(CliError::ReservedModuleId(_))),
1274 "expected ReservedModuleId for '{reserved}', got {result:?}"
1275 );
1276 }
1277 }
1278
1279 #[test]
1280 fn test_build_module_command_yes_has_short_flag() {
1281 let module = make_module_descriptor("a.b", "desc", None);
1282 let executor = mock_executor();
1283 let cmd = build_module_command(&module, executor).unwrap();
1284 let has_short_y = cmd
1285 .get_opts()
1286 .filter(|a| a.get_long() == Some("yes"))
1287 .any(|a| a.get_short() == Some('y'));
1288 assert!(has_short_y, "--yes must have short flag -y");
1289 }
1290
1291 struct CliMockRegistry {
1297 modules: Vec<String>,
1298 }
1299
1300 impl crate::discovery::RegistryProvider for CliMockRegistry {
1301 fn list(&self) -> Vec<String> {
1302 self.modules.clone()
1303 }
1304
1305 fn get_definition(&self, name: &str) -> Option<Value> {
1306 if self.modules.iter().any(|m| m == name) {
1307 Some(serde_json::json!({
1308 "module_id": name,
1309 "name": name,
1310 "input_schema": {},
1311 "output_schema": {},
1312 "enabled": true,
1313 "tags": [],
1314 "dependencies": [],
1315 }))
1316 } else {
1317 None
1318 }
1319 }
1320
1321 fn get_module_descriptor(
1322 &self,
1323 name: &str,
1324 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1325 if self.modules.iter().any(|m| m == name) {
1326 Some(apcore::registry::registry::ModuleDescriptor {
1327 name: name.to_string(),
1328 annotations: apcore::module::ModuleAnnotations::default(),
1329 input_schema: serde_json::Value::Object(Default::default()),
1330 output_schema: serde_json::Value::Object(Default::default()),
1331 enabled: true,
1332 tags: vec![],
1333 dependencies: vec![],
1334 })
1335 } else {
1336 None
1337 }
1338 }
1339 }
1340
1341 struct EmptyRegistry;
1343
1344 impl crate::discovery::RegistryProvider for EmptyRegistry {
1345 fn list(&self) -> Vec<String> {
1346 vec![]
1347 }
1348
1349 fn get_definition(&self, _name: &str) -> Option<Value> {
1350 None
1351 }
1352 }
1353
1354 struct MockExecutor;
1356
1357 impl ModuleExecutor for MockExecutor {}
1358
1359 fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1360 Arc::new(CliMockRegistry {
1361 modules: modules.iter().map(|s| s.to_string()).collect(),
1362 })
1363 }
1364
1365 fn mock_executor() -> Arc<dyn ModuleExecutor> {
1366 Arc::new(MockExecutor)
1367 }
1368
1369 #[test]
1370 fn test_lazy_module_group_list_commands_empty_registry() {
1371 let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1372 let cmds = group.list_commands();
1373 for builtin in ["exec", "list", "describe", "completion", "init", "man"] {
1374 assert!(
1375 cmds.contains(&builtin.to_string()),
1376 "missing builtin: {builtin}"
1377 );
1378 }
1379 let mut sorted = cmds.clone();
1381 sorted.sort();
1382 assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1383 }
1384
1385 #[test]
1386 fn test_lazy_module_group_list_commands_includes_modules() {
1387 let group = LazyModuleGroup::new(
1388 mock_registry(vec!["math.add", "text.summarize"]),
1389 mock_executor(),
1390 );
1391 let cmds = group.list_commands();
1392 assert!(cmds.contains(&"math.add".to_string()));
1393 assert!(cmds.contains(&"text.summarize".to_string()));
1394 }
1395
1396 #[test]
1397 fn test_lazy_module_group_list_commands_registry_error() {
1398 let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1399 let cmds = group.list_commands();
1400 assert!(!cmds.is_empty());
1402 assert!(cmds.contains(&"list".to_string()));
1403 }
1404
1405 #[test]
1406 fn test_lazy_module_group_get_command_builtin() {
1407 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1408 let cmd = group.get_command("list");
1409 assert!(cmd.is_some(), "get_command('list') must return Some");
1410 }
1411
1412 #[test]
1413 fn test_lazy_module_group_get_command_not_found() {
1414 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1415 let cmd = group.get_command("nonexistent.module");
1416 assert!(cmd.is_none());
1417 }
1418
1419 #[test]
1420 fn test_lazy_module_group_get_command_caches_module() {
1421 let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1422 let cmd1 = group.get_command("math.add");
1424 assert!(cmd1.is_some());
1425 let cmd2 = group.get_command("math.add");
1427 assert!(cmd2.is_some());
1428 assert_eq!(
1429 group.registry_lookup_count(),
1430 1,
1431 "cached after first lookup"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_lazy_module_group_builtin_commands_sorted() {
1437 let mut sorted = BUILTIN_COMMANDS.to_vec();
1439 sorted.sort_unstable();
1440 assert_eq!(
1441 BUILTIN_COMMANDS,
1442 sorted.as_slice(),
1443 "BUILTIN_COMMANDS must be sorted"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_lazy_module_group_list_deduplicates_builtins() {
1449 let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1452 let cmds = group.list_commands();
1453 let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1454 assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1455 }
1456
1457 #[test]
1462 fn test_map_error_module_not_found_is_44() {
1463 assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1464 }
1465
1466 #[test]
1467 fn test_map_error_module_load_error_is_44() {
1468 assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1469 }
1470
1471 #[test]
1472 fn test_map_error_module_disabled_is_44() {
1473 assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1474 }
1475
1476 #[test]
1477 fn test_map_error_schema_validation_error_is_45() {
1478 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1479 }
1480
1481 #[test]
1482 fn test_map_error_approval_denied_is_46() {
1483 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1484 }
1485
1486 #[test]
1487 fn test_map_error_approval_timeout_is_46() {
1488 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1489 }
1490
1491 #[test]
1492 fn test_map_error_approval_pending_is_46() {
1493 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1494 }
1495
1496 #[test]
1497 fn test_map_error_config_not_found_is_47() {
1498 assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1499 }
1500
1501 #[test]
1502 fn test_map_error_config_invalid_is_47() {
1503 assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1504 }
1505
1506 #[test]
1507 fn test_map_error_schema_circular_ref_is_48() {
1508 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1509 }
1510
1511 #[test]
1512 fn test_map_error_acl_denied_is_77() {
1513 assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1514 }
1515
1516 #[test]
1517 fn test_map_error_module_execute_error_is_1() {
1518 assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1519 }
1520
1521 #[test]
1522 fn test_map_error_module_timeout_is_1() {
1523 assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1524 }
1525
1526 #[test]
1527 fn test_map_error_unknown_is_1() {
1528 assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1529 }
1530
1531 #[test]
1532 fn test_map_error_empty_string_is_1() {
1533 assert_eq!(map_apcore_error_to_exit_code(""), 1);
1534 }
1535
1536 #[test]
1541 fn test_set_audit_logger_none_clears_logger() {
1542 set_audit_logger(None);
1544 let guard = AUDIT_LOGGER.lock().unwrap();
1545 assert!(guard.is_none(), "setting None must clear the audit logger");
1546 }
1547
1548 #[test]
1549 fn test_set_audit_logger_some_stores_logger() {
1550 use crate::security::AuditLogger;
1551 set_audit_logger(Some(AuditLogger::new(None)));
1552 let guard = AUDIT_LOGGER.lock().unwrap();
1553 assert!(guard.is_some(), "setting Some must store the audit logger");
1554 drop(guard);
1556 set_audit_logger(None);
1557 }
1558
1559 #[test]
1564 fn test_validate_against_schema_passes_with_no_properties() {
1565 let schema = serde_json::json!({});
1566 let input = std::collections::HashMap::new();
1567 let result = validate_against_schema(&input, &schema);
1569 assert!(result.is_ok(), "empty schema must pass: {result:?}");
1570 }
1571
1572 #[test]
1573 fn test_validate_against_schema_required_field_missing_fails() {
1574 let schema = serde_json::json!({
1575 "properties": {
1576 "a": {"type": "integer"}
1577 },
1578 "required": ["a"]
1579 });
1580 let input: std::collections::HashMap<String, serde_json::Value> =
1581 std::collections::HashMap::new();
1582 let result = validate_against_schema(&input, &schema);
1583 assert!(result.is_err(), "missing required field must fail");
1584 }
1585
1586 #[test]
1587 fn test_validate_against_schema_required_field_present_passes() {
1588 let schema = serde_json::json!({
1589 "properties": {
1590 "a": {"type": "integer"}
1591 },
1592 "required": ["a"]
1593 });
1594 let mut input = std::collections::HashMap::new();
1595 input.insert("a".to_string(), serde_json::json!(42));
1596 let result = validate_against_schema(&input, &schema);
1597 assert!(
1598 result.is_ok(),
1599 "present required field must pass: {result:?}"
1600 );
1601 }
1602
1603 #[test]
1604 fn test_validate_against_schema_no_required_any_input_passes() {
1605 let schema = serde_json::json!({
1606 "properties": {
1607 "x": {"type": "string"}
1608 }
1609 });
1610 let input: std::collections::HashMap<String, serde_json::Value> =
1611 std::collections::HashMap::new();
1612 let result = validate_against_schema(&input, &schema);
1613 assert!(result.is_ok(), "no required fields: empty input must pass");
1614 }
1615
1616 #[test]
1621 fn test_resolve_group_explicit_group() {
1622 let desc = serde_json::json!({
1623 "module_id": "my.thing",
1624 "metadata": {
1625 "display": {
1626 "cli": {
1627 "group": "tools",
1628 "alias": "thing"
1629 }
1630 }
1631 }
1632 });
1633 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1634 assert_eq!(group, Some("tools".to_string()));
1635 assert_eq!(cmd, "thing");
1636 }
1637
1638 #[test]
1639 fn test_resolve_group_explicit_empty_is_top_level() {
1640 let desc = serde_json::json!({
1641 "module_id": "my.thing",
1642 "metadata": {
1643 "display": {
1644 "cli": {
1645 "group": "",
1646 "alias": "thing"
1647 }
1648 }
1649 }
1650 });
1651 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1652 assert!(group.is_none());
1653 assert_eq!(cmd, "thing");
1654 }
1655
1656 #[test]
1657 fn test_resolve_group_dotted_alias() {
1658 let desc = serde_json::json!({
1659 "module_id": "math.add",
1660 "metadata": {
1661 "display": {
1662 "cli": { "alias": "math.add" }
1663 }
1664 }
1665 });
1666 let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
1667 assert_eq!(group, Some("math".to_string()));
1668 assert_eq!(cmd, "add");
1669 }
1670
1671 #[test]
1672 fn test_resolve_group_no_dot_is_top_level() {
1673 let desc = serde_json::json!({"module_id": "greet"});
1674 let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
1675 assert!(group.is_none());
1676 assert_eq!(cmd, "greet");
1677 }
1678
1679 #[test]
1680 fn test_resolve_group_dotted_module_id_default() {
1681 let desc = serde_json::json!({"module_id": "text.upper"});
1683 let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
1684 assert_eq!(group, Some("text".to_string()));
1685 assert_eq!(cmd, "upper");
1686 }
1687
1688 #[test]
1689 fn test_resolve_group_invalid_group_name_top_level() {
1690 let desc = serde_json::json!({
1694 "module_id": "x",
1695 "metadata": {
1696 "display": {
1697 "cli": { "group": "123Invalid" }
1698 }
1699 }
1700 });
1701 let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
1702 assert_eq!(group, Some("123Invalid".to_string()));
1703 }
1704
1705 #[test]
1706 fn test_grouped_module_group_list_commands_includes_groups() {
1707 let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
1708 let executor = mock_executor();
1709 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1710 let cmds = gmg.list_commands();
1711 assert!(
1712 cmds.contains(&"math".to_string()),
1713 "must contain group 'math'"
1714 );
1715 assert!(
1717 cmds.contains(&"greet".to_string()),
1718 "must contain top-level 'greet'"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_grouped_module_group_get_command_group() {
1724 let registry = mock_registry(vec!["math.add", "math.mul"]);
1725 let executor = mock_executor();
1726 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1727 let cmd = gmg.get_command("math");
1728 assert!(cmd.is_some(), "must find group 'math'");
1729 let group_cmd = cmd.unwrap();
1730 let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
1731 assert!(subs.contains(&"add"));
1732 assert!(subs.contains(&"mul"));
1733 }
1734
1735 #[test]
1736 fn test_grouped_module_group_get_command_top_level() {
1737 let registry = mock_registry(vec!["greet"]);
1738 let executor = mock_executor();
1739 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1740 let cmd = gmg.get_command("greet");
1741 assert!(cmd.is_some(), "must find top-level 'greet'");
1742 }
1743
1744 #[test]
1745 fn test_grouped_module_group_get_command_not_found() {
1746 let registry = mock_registry(vec![]);
1747 let executor = mock_executor();
1748 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1749 assert!(gmg.get_command("nonexistent").is_none());
1750 }
1751
1752 #[test]
1753 fn test_grouped_module_group_help_max_length() {
1754 let registry = mock_registry(vec![]);
1755 let executor = mock_executor();
1756 let gmg = GroupedModuleGroup::new(registry, executor, 42);
1757 assert_eq!(gmg.help_text_max_length(), 42);
1758 }
1759
1760 #[test]
1761 fn test_is_valid_group_name_valid() {
1762 assert!(is_valid_group_name("math"));
1763 assert!(is_valid_group_name("my-group"));
1764 assert!(is_valid_group_name("g1"));
1765 assert!(is_valid_group_name("a_b"));
1766 }
1767
1768 #[test]
1769 fn test_is_valid_group_name_invalid() {
1770 assert!(!is_valid_group_name(""));
1771 assert!(!is_valid_group_name("1abc"));
1772 assert!(!is_valid_group_name("ABC"));
1773 assert!(!is_valid_group_name("a b"));
1774 }
1775}