1use std::collections::HashMap;
6use std::io::{IsTerminal, 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", "csv", "yaml", "jsonl"])
185 .help(
186 "Output format: json, table, csv, \
187 yaml, jsonl.",
188 )
189 .hide(hide),
190 )
191 .arg(
192 Arg::new("fields")
193 .long("fields")
194 .value_name("FIELDS")
195 .help(
196 "Comma-separated dot-paths to select \
197 from the result (e.g., 'status,data.count').",
198 )
199 .hide(hide),
200 )
201 .arg(
202 Arg::new("sandbox")
204 .long("sandbox")
205 .action(ArgAction::SetTrue)
206 .help(
207 "Run module in an isolated subprocess \
208 with restricted filesystem and env \
209 access",
210 )
211 .hide(true),
212 )
213 .arg(
214 Arg::new("dry-run")
215 .long("dry-run")
216 .action(ArgAction::SetTrue)
217 .help(
218 "Run preflight checks without executing \
219 the module. Shows validation results.",
220 )
221 .hide(hide),
222 )
223 .arg(
224 Arg::new("trace")
225 .long("trace")
226 .action(ArgAction::SetTrue)
227 .help(
228 "Show execution pipeline trace with \
229 per-step timing after the result.",
230 )
231 .hide(hide),
232 )
233 .arg(
234 Arg::new("stream")
235 .long("stream")
236 .action(ArgAction::SetTrue)
237 .help(
238 "Stream module output as JSONL (one JSON \
239 object per line, flushed immediately).",
240 )
241 .hide(hide),
242 )
243 .arg(
244 Arg::new("strategy")
245 .long("strategy")
246 .value_parser(["standard", "internal", "testing", "performance", "minimal"])
247 .value_name("STRATEGY")
248 .help(
249 "Execution pipeline strategy: standard \
250 (default), internal, testing, performance.",
251 )
252 .hide(hide),
253 )
254 .arg(
255 Arg::new("approval-timeout")
256 .long("approval-timeout")
257 .value_name("SECONDS")
258 .help(
259 "Override approval prompt timeout in \
260 seconds (default: 60).",
261 )
262 .hide(hide),
263 )
264 .arg(
265 Arg::new("approval-token")
266 .long("approval-token")
267 .value_name("TOKEN")
268 .help(
269 "Resume a pending approval with the \
270 given token (for async approval flows).",
271 )
272 .hide(hide),
273 )
274}
275
276pub fn exec_command() -> clap::Command {
280 use clap::{Arg, Command};
281
282 let cmd = Command::new("exec").about("Execute an apcore module").arg(
283 Arg::new("module_id")
284 .required(true)
285 .value_name("MODULE_ID")
286 .help("Fully-qualified module ID to execute"),
287 );
288 add_dispatch_flags(cmd)
289}
290
291pub const BUILTIN_COMMANDS: &[&str] = &[
297 "completion",
298 "config",
299 "describe",
300 "describe-pipeline",
301 "disable",
302 "enable",
303 "exec",
304 "health",
305 "init",
306 "list",
307 "man",
308 "reload",
309 "usage",
310 "validate",
311];
312
313pub struct LazyModuleGroup {
319 registry: Arc<dyn crate::discovery::RegistryProvider>,
320 #[allow(dead_code)]
321 executor: Arc<dyn ModuleExecutor>,
322 module_cache: HashMap<String, bool>,
325 #[cfg(test)]
327 pub registry_lookup_count: usize,
328}
329
330impl LazyModuleGroup {
331 pub fn new(
337 registry: Arc<dyn crate::discovery::RegistryProvider>,
338 executor: Arc<dyn ModuleExecutor>,
339 ) -> Self {
340 Self {
341 registry,
342 executor,
343 module_cache: HashMap::new(),
344 #[cfg(test)]
345 registry_lookup_count: 0,
346 }
347 }
348
349 pub fn list_commands(&self) -> Vec<String> {
351 let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
352 names.extend(self.registry.list());
353 names.sort_unstable();
355 names.dedup();
356 names
357 }
358
359 pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
364 if BUILTIN_COMMANDS.contains(&name) {
365 return Some(clap::Command::new(name.to_string()));
366 }
367 if self.module_cache.contains_key(name) {
369 return Some(clap::Command::new(name.to_string()));
370 }
371 #[cfg(test)]
373 {
374 self.registry_lookup_count += 1;
375 }
376 let _descriptor = self.registry.get_module_descriptor(name)?;
377 let cmd = clap::Command::new(name.to_string());
378 self.module_cache.insert(name.to_string(), true);
379 tracing::debug!("Loaded module command: {name}");
380 Some(cmd)
381 }
382
383 #[cfg(test)]
386 pub fn registry_lookup_count(&self) -> usize {
387 self.registry_lookup_count
388 }
389}
390
391pub struct GroupedModuleGroup {
401 registry: Arc<dyn crate::discovery::RegistryProvider>,
402 #[allow(dead_code)]
403 executor: Arc<dyn ModuleExecutor>,
404 #[allow(dead_code)]
405 help_text_max_length: usize,
406 group_map: HashMap<String, HashMap<String, (String, Value)>>,
407 top_level_modules: HashMap<String, (String, Value)>,
408 alias_map: HashMap<String, String>,
409 descriptor_cache: HashMap<String, Value>,
410 group_map_built: bool,
411}
412
413impl GroupedModuleGroup {
414 pub fn new(
416 registry: Arc<dyn crate::discovery::RegistryProvider>,
417 executor: Arc<dyn ModuleExecutor>,
418 help_text_max_length: usize,
419 ) -> Self {
420 Self {
421 registry,
422 executor,
423 help_text_max_length,
424 group_map: HashMap::new(),
425 top_level_modules: HashMap::new(),
426 alias_map: HashMap::new(),
427 descriptor_cache: HashMap::new(),
428 group_map_built: false,
429 }
430 }
431
432 pub fn resolve_group(module_id: &str, descriptor: &Value) -> (Option<String>, String) {
437 let display = crate::display_helpers::get_display(descriptor);
438 let cli = display.get("cli").unwrap_or(&Value::Null);
439
440 if let Some(group_val) = cli.get("group") {
442 if let Some(g) = group_val.as_str() {
443 if g.is_empty() {
444 let alias = cli
446 .get("alias")
447 .and_then(|v| v.as_str())
448 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
449 .unwrap_or(module_id);
450 return (None, alias.to_string());
451 }
452 let alias = cli
454 .get("alias")
455 .and_then(|v| v.as_str())
456 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
457 .unwrap_or(module_id);
458 return (Some(g.to_string()), alias.to_string());
459 }
460 }
461
462 let alias = cli
464 .get("alias")
465 .and_then(|v| v.as_str())
466 .or_else(|| display.get("alias").and_then(|v| v.as_str()))
467 .unwrap_or(module_id);
468
469 if let Some(dot_pos) = alias.find('.') {
471 let group = &alias[..dot_pos];
472 let cmd = &alias[dot_pos + 1..];
473 return (Some(group.to_string()), cmd.to_string());
474 }
475
476 (None, alias.to_string())
478 }
479
480 pub fn build_group_map(&mut self) {
482 if self.group_map_built {
483 return;
484 }
485 self.group_map_built = true;
486
487 let module_ids = self.registry.list();
488 for mid in &module_ids {
489 let descriptor = match self.registry.get_definition(mid) {
490 Some(d) => d,
491 None => continue,
492 };
493
494 let (group, cmd_name) = Self::resolve_group(mid, &descriptor);
495 self.alias_map.insert(cmd_name.clone(), mid.clone());
496 self.descriptor_cache
497 .insert(mid.clone(), descriptor.clone());
498
499 match group {
500 Some(g) if is_valid_group_name(&g) => {
501 let entry = self.group_map.entry(g).or_default();
502 entry.insert(cmd_name, (mid.clone(), descriptor));
503 }
504 Some(g) => {
505 tracing::warn!(
506 "Module '{}': group name '{}' is not shell-safe \
507 -- treating as top-level.",
508 mid,
509 g,
510 );
511 self.top_level_modules
512 .insert(cmd_name, (mid.clone(), descriptor));
513 }
514 None => {
515 self.top_level_modules
516 .insert(cmd_name, (mid.clone(), descriptor));
517 }
518 }
519 }
520 }
521
522 pub fn list_commands(&mut self) -> Vec<String> {
525 self.build_group_map();
526 let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
527 for group_name in self.group_map.keys() {
528 names.push(group_name.clone());
529 }
530 for cmd_name in self.top_level_modules.keys() {
531 names.push(cmd_name.clone());
532 }
533 names.sort_unstable();
534 names.dedup();
535 names
536 }
537
538 pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
541 self.build_group_map();
542
543 if BUILTIN_COMMANDS.contains(&name) {
544 return Some(clap::Command::new(name.to_string()));
545 }
546
547 if let Some(members) = self.group_map.get(name) {
549 let mut group_cmd = clap::Command::new(name.to_string());
550 for (cmd_name, (_mid, _desc)) in members {
551 group_cmd = group_cmd.subcommand(clap::Command::new(cmd_name.clone()));
552 }
553 return Some(group_cmd);
554 }
555
556 if self.top_level_modules.contains_key(name) {
558 return Some(clap::Command::new(name.to_string()));
559 }
560
561 None
562 }
563
564 #[cfg(test)]
566 pub fn help_text_max_length(&self) -> usize {
567 self.help_text_max_length
568 }
569}
570
571fn is_valid_group_name(s: &str) -> bool {
573 if s.is_empty() {
574 return false;
575 }
576 let mut chars = s.chars();
577 match chars.next() {
578 Some(c) if c.is_ascii_lowercase() => {}
579 _ => return false,
580 }
581 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
582}
583
584const RESERVED_FLAG_NAMES: &[&str] = &[
592 "approval-timeout",
593 "approval-token",
594 "dry-run",
595 "fields",
596 "format",
597 "input",
598 "large-input",
599 "sandbox",
600 "strategy",
601 "stream",
602 "trace",
603 "verbose",
604 "yes",
605];
606
607pub fn build_module_command(
624 module_def: &apcore::registry::registry::ModuleDescriptor,
625 executor: Arc<dyn ModuleExecutor>,
626) -> Result<clap::Command, CliError> {
627 build_module_command_with_limit(
628 module_def,
629 executor,
630 crate::schema_parser::HELP_TEXT_MAX_LEN,
631 )
632}
633
634pub fn build_module_command_with_limit(
637 module_def: &apcore::registry::registry::ModuleDescriptor,
638 executor: Arc<dyn ModuleExecutor>,
639 help_text_max_length: usize,
640) -> Result<clap::Command, CliError> {
641 let module_id = &module_def.name;
642
643 if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
645 return Err(CliError::ReservedModuleId(module_id.clone()));
646 }
647
648 let resolved_schema =
650 crate::ref_resolver::resolve_refs(&module_def.input_schema, 32, module_id)
651 .unwrap_or_else(|_| module_def.input_schema.clone());
652
653 let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
655 &resolved_schema,
656 help_text_max_length,
657 )
658 .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
659
660 for arg in &schema_args.args {
662 if let Some(long) = arg.get_long() {
663 if RESERVED_FLAG_NAMES.contains(&long) {
664 return Err(CliError::ReservedModuleId(format!(
665 "module '{module_id}' schema property '{long}' conflicts \
666 with a reserved CLI option name"
667 )));
668 }
669 }
670 }
671
672 let _ = executor;
674
675 let hide = !is_verbose_help();
676
677 let mut footer_parts = Vec::new();
679 if hide {
680 footer_parts.push(
681 "Use --verbose to show all options \
682 (including built-in apcore options)."
683 .to_string(),
684 );
685 }
686 if let Some(url) = get_docs_url() {
687 footer_parts.push(format!("Docs: {url}/commands/{module_id}"));
688 }
689 let footer = footer_parts.join("\n");
690
691 let mut cmd = add_dispatch_flags(clap::Command::new(module_id.clone()).after_help(footer));
692
693 for arg in schema_args.args {
695 cmd = cmd.arg(arg);
696 }
697
698 Ok(cmd)
699}
700
701const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; pub fn collect_input_from_reader<R: Read>(
718 stdin_flag: Option<&str>,
719 cli_kwargs: HashMap<String, Value>,
720 large_input: bool,
721 mut reader: R,
722) -> Result<HashMap<String, Value>, CliError> {
723 let cli_non_null: HashMap<String, Value> = cli_kwargs
725 .into_iter()
726 .filter(|(_, v)| !v.is_null())
727 .collect();
728
729 if stdin_flag != Some("-") {
730 return Ok(cli_non_null);
731 }
732
733 let mut buf = Vec::new();
734 reader
735 .read_to_end(&mut buf)
736 .map_err(|e| CliError::StdinRead(e.to_string()))?;
737
738 if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
739 return Err(CliError::InputTooLarge {
740 limit: STDIN_SIZE_LIMIT_BYTES,
741 actual: buf.len(),
742 });
743 }
744
745 if buf.is_empty() {
746 return Ok(cli_non_null);
747 }
748
749 let stdin_value: Value =
750 serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
751
752 let stdin_map = match stdin_value {
753 Value::Object(m) => m,
754 _ => return Err(CliError::NotAnObject),
755 };
756
757 let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
759 merged.extend(cli_non_null);
760 Ok(merged)
761}
762
763pub fn collect_input(
778 stdin_flag: Option<&str>,
779 cli_kwargs: HashMap<String, Value>,
780 large_input: bool,
781) -> Result<HashMap<String, Value>, CliError> {
782 collect_input_from_reader(stdin_flag, cli_kwargs, large_input, std::io::stdin())
783}
784
785const MODULE_ID_MAX_LEN: usize = 128;
790
791pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
802 if module_id.len() > MODULE_ID_MAX_LEN {
803 return Err(CliError::InvalidModuleId(format!(
804 "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
805 )));
806 }
807 if !is_valid_module_id(module_id) {
808 return Err(CliError::InvalidModuleId(format!(
809 "Invalid module ID format: '{module_id}'."
810 )));
811 }
812 Ok(())
813}
814
815#[inline]
819fn is_valid_module_id(s: &str) -> bool {
820 if s.is_empty() {
821 return false;
822 }
823 for segment in s.split('.') {
825 if segment.is_empty() {
826 return false;
828 }
829 let mut chars = segment.chars();
830 match chars.next() {
832 Some(c) if c.is_ascii_lowercase() => {}
833 _ => return false,
834 }
835 for c in chars {
837 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
838 return false;
839 }
840 }
841 }
842 true
843}
844
845pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
860 use crate::{
861 EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_BIND_ERROR, EXIT_CONFIG_MOUNT_ERROR,
862 EXIT_CONFIG_NAMESPACE_RESERVED, EXIT_CONFIG_NOT_FOUND, EXIT_ERROR_FORMATTER_DUPLICATE,
863 EXIT_MODULE_EXECUTE_ERROR, EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF,
864 EXIT_SCHEMA_VALIDATION_ERROR,
865 };
866 match error_code {
867 "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
868 "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
869 "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
870 "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
871 "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
872 "ACL_DENIED" => EXIT_ACL_DENIED,
873 "CONFIG_NAMESPACE_RESERVED"
875 | "CONFIG_NAMESPACE_DUPLICATE"
876 | "CONFIG_ENV_PREFIX_CONFLICT"
877 | "CONFIG_ENV_MAP_CONFLICT" => EXIT_CONFIG_NAMESPACE_RESERVED,
878 "CONFIG_MOUNT_ERROR" => EXIT_CONFIG_MOUNT_ERROR,
879 "CONFIG_BIND_ERROR" => EXIT_CONFIG_BIND_ERROR,
880 "ERROR_FORMATTER_DUPLICATE" => EXIT_ERROR_FORMATTER_DUPLICATE,
881 _ => EXIT_MODULE_EXECUTE_ERROR,
882 }
883}
884
885pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
891 let code_str = serde_json::to_value(err.code)
893 .ok()
894 .and_then(|v| v.as_str().map(|s| s.to_string()))
895 .unwrap_or_default();
896 map_apcore_error_to_exit_code(&code_str)
897}
898
899pub(crate) fn validate_against_schema(
912 input: &HashMap<String, Value>,
913 schema: &Value,
914) -> Result<(), String> {
915 let required = match schema.get("required") {
917 Some(Value::Array(arr)) => arr,
918 _ => return Ok(()),
919 };
920 for req in required {
921 if let Some(field_name) = req.as_str() {
922 if !input.contains_key(field_name) {
923 return Err(format!("required field '{}' is missing", field_name));
924 }
925 }
926 }
927 Ok(())
928}
929
930fn emit_error_json(
944 _module_id: &str,
945 message: &str,
946 exit_code: i32,
947 error_data: Option<&serde_json::Value>,
948) {
949 let mut payload = serde_json::json!({
950 "error": true,
951 "code": "UNKNOWN",
952 "message": message,
953 "exit_code": exit_code,
954 });
955 if let Some(data) = error_data {
957 if let Some(obj) = data.as_object() {
958 for key in &[
959 "code",
960 "message",
961 "details",
962 "suggestion",
963 "ai_guidance",
964 "retryable",
965 "user_fixable",
966 ] {
967 if let Some(val) = obj.get(*key) {
968 if !val.is_null() {
969 payload[*key] = val.clone();
970 }
971 }
972 }
973 }
974 }
975 eprintln!("{}", serde_json::to_string(&payload).unwrap_or_default());
976}
977
978fn emit_error_tty(
983 _module_id: &str,
984 message: &str,
985 exit_code: i32,
986 error_data: Option<&serde_json::Value>,
987) {
988 if let Some(code) = error_data
990 .and_then(|d| d.get("code"))
991 .and_then(|v| v.as_str())
992 {
993 eprintln!("Error [{code}]: {message}");
994 } else {
995 eprintln!("Error: {message}");
996 }
997
998 if let Some(details) = error_data
1000 .and_then(|d| d.get("details"))
1001 .and_then(|v| v.as_object())
1002 {
1003 eprintln!("\n Details:");
1004 for (k, v) in details {
1005 eprintln!(" {k}: {v}");
1006 }
1007 }
1008
1009 if let Some(suggestion) = error_data
1011 .and_then(|d| d.get("suggestion"))
1012 .and_then(|v| v.as_str())
1013 {
1014 eprintln!("\n Suggestion: {suggestion}");
1015 }
1016
1017 if let Some(retryable) = error_data
1019 .and_then(|d| d.get("retryable"))
1020 .and_then(|v| v.as_bool())
1021 {
1022 let label = if retryable {
1023 "Yes"
1024 } else {
1025 "No (same input will fail again)"
1026 };
1027 eprintln!(" Retryable: {label}");
1028 }
1029
1030 eprintln!("\n Exit code: {exit_code}");
1031}
1032
1033pub fn reconcile_bool_pairs(
1044 matches: &clap::ArgMatches,
1045 bool_pairs: &[crate::schema_parser::BoolFlagPair],
1046) -> HashMap<String, Value> {
1047 let mut result = HashMap::new();
1048 for pair in bool_pairs {
1049 let pos_set = matches
1052 .try_get_one::<bool>(&pair.prop_name)
1053 .ok()
1054 .flatten()
1055 .copied()
1056 .unwrap_or(false);
1057 let neg_id = format!("no-{}", pair.prop_name);
1058 let neg_set = matches
1059 .try_get_one::<bool>(&neg_id)
1060 .ok()
1061 .flatten()
1062 .copied()
1063 .unwrap_or(false);
1064 let val = if pos_set {
1065 true
1066 } else if neg_set {
1067 false
1068 } else {
1069 pair.default_val
1070 };
1071 result.insert(pair.prop_name.clone(), Value::Bool(val));
1072 }
1073 result
1074}
1075
1076fn extract_cli_kwargs(
1081 matches: &clap::ArgMatches,
1082 module_def: &apcore::registry::registry::ModuleDescriptor,
1083) -> HashMap<String, Value> {
1084 use crate::schema_parser::schema_to_clap_args;
1085
1086 let schema_args = match schema_to_clap_args(&module_def.input_schema) {
1087 Ok(sa) => sa,
1088 Err(_) => return HashMap::new(),
1089 };
1090
1091 let mut kwargs: HashMap<String, Value> = HashMap::new();
1092
1093 for arg in &schema_args.args {
1095 let id = arg.get_id().as_str().to_string();
1096 if id.starts_with("no-") {
1098 continue;
1099 }
1100 if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
1103 kwargs.insert(id, Value::String(val.clone()));
1104 } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
1105 kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
1106 } else {
1107 kwargs.insert(id, Value::Null);
1108 }
1109 }
1110
1111 let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
1113 kwargs.extend(bool_vals);
1114
1115 crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
1117}
1118
1119async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
1124 use tokio::io::AsyncWriteExt;
1125
1126 let mut child = tokio::process::Command::new(executable)
1127 .stdin(std::process::Stdio::piped())
1128 .stdout(std::process::Stdio::piped())
1129 .stderr(std::process::Stdio::piped())
1130 .spawn()
1131 .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
1132
1133 if let Some(mut stdin) = child.stdin.take() {
1135 let payload =
1136 serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
1137 stdin
1138 .write_all(&payload)
1139 .await
1140 .map_err(|e| format!("failed to write to stdin: {e}"))?;
1141 drop(stdin);
1142 }
1143
1144 let output = child
1145 .wait_with_output()
1146 .await
1147 .map_err(|e| format!("failed to read output: {e}"))?;
1148
1149 if !output.status.success() {
1150 let code = output.status.code().unwrap_or(1);
1151 let stderr_hint = String::from_utf8_lossy(&output.stderr);
1152 return Err(format!(
1153 "script exited with code {code}{}",
1154 if stderr_hint.is_empty() {
1155 String::new()
1156 } else {
1157 format!(": {}", stderr_hint.trim())
1158 }
1159 ));
1160 }
1161
1162 serde_json::from_slice(&output.stdout)
1163 .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1164}
1165
1166pub async fn dispatch_module(
1171 module_id: &str,
1172 matches: &clap::ArgMatches,
1173 registry: &Arc<dyn crate::discovery::RegistryProvider>,
1174 _executor: &Arc<dyn ModuleExecutor + 'static>,
1175 apcore_executor: &apcore::Executor,
1176) -> ! {
1177 use crate::{
1178 EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1179 EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1180 };
1181
1182 if let Err(e) = validate_module_id(module_id) {
1184 eprintln!("Error: Invalid module ID format: '{module_id}'.");
1185 let _ = e;
1186 std::process::exit(EXIT_INVALID_INPUT);
1187 }
1188
1189 let module_def = match registry.get_module_descriptor(module_id) {
1191 Some(def) => def,
1192 None => {
1193 eprintln!("Error: Module '{module_id}' not found in registry.");
1194 std::process::exit(EXIT_MODULE_NOT_FOUND);
1195 }
1196 };
1197
1198 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1200 let auto_approve = matches.get_flag("yes");
1201 let large_input = matches.get_flag("large-input");
1202 let format_flag = matches.get_one::<String>("format").cloned();
1203 let fields_flag = matches.get_one::<String>("fields").cloned();
1204 let dry_run = matches.get_flag("dry-run");
1205 let trace_flag = matches.get_flag("trace");
1206 let stream_flag = matches.get_flag("stream");
1207 let strategy_name = matches.get_one::<String>("strategy").cloned();
1208 let _approval_timeout = matches.get_one::<String>("approval-timeout");
1209 let approval_token = matches.get_one::<String>("approval-token").cloned();
1210
1211 let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1213
1214 let mut merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1216 Ok(m) => m,
1217 Err(CliError::InputTooLarge { .. }) => {
1218 eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1219 std::process::exit(EXIT_INVALID_INPUT);
1220 }
1221 Err(CliError::JsonParse(detail)) => {
1222 eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1223 std::process::exit(EXIT_INVALID_INPUT);
1224 }
1225 Err(CliError::NotAnObject) => {
1226 eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1227 std::process::exit(EXIT_INVALID_INPUT);
1228 }
1229 Err(e) => {
1230 eprintln!("Error: {e}");
1231 std::process::exit(EXIT_INVALID_INPUT);
1232 }
1233 };
1234
1235 if dry_run {
1237 let show_trace_preview = trace_flag;
1239 let print_pipeline_preview = || {
1240 if show_trace_preview {
1241 let pure_steps = [
1242 "context_creation",
1243 "call_chain_guard",
1244 "module_lookup",
1245 "acl_check",
1246 "input_validation",
1247 ];
1248 let all_steps = [
1249 "context_creation",
1250 "call_chain_guard",
1251 "module_lookup",
1252 "acl_check",
1253 "approval_gate",
1254 "middleware_before",
1255 "input_validation",
1256 "execute",
1257 "output_validation",
1258 "middleware_after",
1259 "return_result",
1260 ];
1261 eprintln!("\nPipeline preview (dry-run):");
1262 for s in &all_steps {
1263 if pure_steps.contains(s) {
1264 eprintln!(" v {:<24} (pure -- would execute)", s);
1265 } else {
1266 eprintln!(" o {:<24} (impure -- skipped in dry-run)", s);
1267 }
1268 }
1269 }
1270 };
1271 let input_value =
1272 serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1273 let preflight_input = serde_json::json!({
1274 "module_id": module_id,
1275 "input": input_value,
1276 });
1277 let result = apcore_executor
1278 .call("system.validate", preflight_input, None, None)
1279 .await;
1280 match result {
1281 Ok(preflight_val) => {
1282 crate::validate::format_preflight_result(&preflight_val, format_flag.as_deref());
1283 print_pipeline_preview();
1284 let valid = preflight_val
1285 .get("valid")
1286 .and_then(|v| v.as_bool())
1287 .unwrap_or(false);
1288 if valid {
1289 std::process::exit(EXIT_SUCCESS);
1290 } else {
1291 std::process::exit(crate::EXIT_MODULE_EXECUTE_ERROR);
1292 }
1293 }
1294 Err(_e) => {
1295 tracing::debug!(
1296 "system.validate call failed: {_e}; falling back to basic schema validation"
1297 );
1298 let schema_ok = if let Some(schema) = module_def.input_schema.as_object() {
1300 if schema.contains_key("properties") {
1301 validate_against_schema(&merged, &module_def.input_schema).is_ok()
1302 } else {
1303 true
1304 }
1305 } else {
1306 true
1307 };
1308
1309 let checks = vec![
1310 serde_json::json!({"check": "module_id", "passed": true}),
1311 serde_json::json!({"check": "module_lookup", "passed": true}),
1312 serde_json::json!({"check": "schema", "passed": schema_ok}),
1313 ];
1314 let preflight = serde_json::json!({
1315 "valid": schema_ok,
1316 "requires_approval": false,
1317 "checks": checks,
1318 });
1319 crate::validate::format_preflight_result(&preflight, format_flag.as_deref());
1320 print_pipeline_preview();
1321 if schema_ok {
1322 std::process::exit(EXIT_SUCCESS);
1323 } else {
1324 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1325 }
1326 }
1327 }
1328 }
1329
1330 if let Some(schema) = module_def.input_schema.as_object() {
1332 if schema.contains_key("properties") {
1333 if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1334 eprintln!("Error: Validation failed: {detail}.");
1335 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1336 }
1337 }
1338 }
1339
1340 if let Some(ref token) = approval_token {
1342 merged.insert("_approval_token".to_string(), Value::String(token.clone()));
1343 }
1344
1345 let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1347 if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
1348 eprintln!("Error: {e}");
1349 std::process::exit(EXIT_APPROVAL_DENIED);
1350 }
1351
1352 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1354
1355 let use_sandbox = matches.get_flag("sandbox");
1357
1358 let script_executable = EXECUTABLES
1360 .get()
1361 .and_then(|map| map.get(module_id))
1362 .cloned();
1363
1364 if stream_flag {
1366 if format_flag.as_deref() == Some("table") {
1368 eprintln!("Warning: Streaming mode always outputs JSONL; --format table is ignored.");
1369 }
1370 let start = std::time::Instant::now();
1371 if let Some(exec_path) = script_executable.as_ref() {
1373 let res = tokio::select! {
1375 res = execute_script(exec_path, &input_value) => res,
1376 _ = tokio::signal::ctrl_c() => {
1377 eprintln!("Execution cancelled.");
1378 std::process::exit(EXIT_SIGINT);
1379 }
1380 };
1381 match res {
1382 Ok(val) => {
1383 println!("{}", serde_json::to_string(&val).unwrap_or_default());
1385 let duration_ms = start.elapsed().as_millis() as u64;
1386 if let Ok(guard) = AUDIT_LOGGER.lock() {
1387 if let Some(logger) = guard.as_ref() {
1388 logger.log_execution(
1389 module_id,
1390 &input_value,
1391 "success",
1392 0,
1393 duration_ms,
1394 );
1395 }
1396 }
1397 std::process::exit(EXIT_SUCCESS);
1398 }
1399 Err(e) => {
1400 eprintln!("Error: {e}");
1401 std::process::exit(crate::EXIT_MODULE_EXECUTE_ERROR);
1402 }
1403 }
1404 }
1405 let res = tokio::select! {
1409 res = apcore_executor.call(
1410 module_id, input_value.clone(), None, None,
1411 ) => res,
1412 _ = tokio::signal::ctrl_c() => {
1413 eprintln!("Execution cancelled.");
1414 std::process::exit(EXIT_SIGINT);
1415 }
1416 };
1417 let duration_ms = start.elapsed().as_millis() as u64;
1418 match res {
1419 Ok(val) => {
1420 if let Some(arr) = val.as_array() {
1422 for item in arr {
1423 println!("{}", serde_json::to_string(item).unwrap_or_default());
1424 }
1425 } else {
1426 println!("{}", serde_json::to_string(&val).unwrap_or_default());
1427 }
1428 if let Ok(guard) = AUDIT_LOGGER.lock() {
1429 if let Some(logger) = guard.as_ref() {
1430 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1431 }
1432 }
1433 std::process::exit(EXIT_SUCCESS);
1434 }
1435 Err(e) => {
1436 let code = map_module_error_to_exit_code(&e);
1437 eprintln!("Error: Module '{module_id}' execution failed: {e}.");
1438 std::process::exit(code);
1439 }
1440 }
1441 }
1442
1443 if trace_flag {
1445 let start = std::time::Instant::now();
1446 let res = tokio::select! {
1450 res = apcore_executor.call(
1451 module_id,
1452 input_value.clone(),
1453 None,
1454 None,
1455 ) => res,
1456 _ = tokio::signal::ctrl_c() => {
1457 eprintln!("Execution cancelled.");
1458 std::process::exit(EXIT_SIGINT);
1459 }
1460 };
1461 let duration_ms = start.elapsed().as_millis() as u64;
1462 match res {
1463 Ok(output) => {
1464 if let Ok(guard) = AUDIT_LOGGER.lock() {
1465 if let Some(logger) = guard.as_ref() {
1466 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1467 }
1468 }
1469 let fmt = crate::output::resolve_format(format_flag.as_deref());
1471 if fmt == "json" {
1472 let trace_data = serde_json::json!({
1474 "strategy": strategy_name.as_deref().unwrap_or("standard"),
1475 "total_duration_ms": duration_ms,
1476 "success": true,
1477 });
1478 let combined = if output.is_object() {
1479 let mut obj = output.as_object().unwrap().clone();
1480 obj.insert("_trace".to_string(), trace_data);
1481 Value::Object(obj)
1482 } else {
1483 serde_json::json!({
1484 "result": output,
1485 "_trace": trace_data,
1486 })
1487 };
1488 println!(
1489 "{}",
1490 serde_json::to_string_pretty(&combined).unwrap_or_default()
1491 );
1492 } else {
1493 let out_str =
1494 crate::output::format_exec_result(&output, fmt, fields_flag.as_deref());
1495 println!("{out_str}");
1496 eprintln!(
1497 "\nPipeline Trace (strategy: {}, {duration_ms}ms)",
1498 strategy_name.as_deref().unwrap_or("standard"),
1499 );
1500 }
1501 std::process::exit(EXIT_SUCCESS);
1502 }
1503 Err(e) => {
1504 let code = map_module_error_to_exit_code(&e);
1505 eprintln!("Error: Module '{module_id}' execution failed: {e}.");
1506 std::process::exit(code);
1507 }
1508 }
1509 }
1510
1511 let start = std::time::Instant::now();
1513
1514 let result: Result<Value, (i32, String, Option<Value>)> =
1517 if let Some(exec_path) = script_executable {
1518 tokio::select! {
1520 res = execute_script(&exec_path, &input_value) => {
1521 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e, None))
1522 }
1523 _ = tokio::signal::ctrl_c() => {
1524 eprintln!("Execution cancelled.");
1525 std::process::exit(EXIT_SIGINT);
1526 }
1527 }
1528 } else if use_sandbox {
1529 let sandbox = crate::security::Sandbox::new(true, 0);
1530 tokio::select! {
1531 res = sandbox.execute(module_id, input_value.clone()) => {
1532 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string(), None))
1533 }
1534 _ = tokio::signal::ctrl_c() => {
1535 eprintln!("Execution cancelled.");
1536 std::process::exit(EXIT_SIGINT);
1537 }
1538 }
1539 } else {
1540 tokio::select! {
1545 res = apcore_executor.call(
1546 module_id,
1547 input_value.clone(),
1548 None,
1549 None,
1550 ) => {
1551 res.map_err(|e| {
1552 let code = map_module_error_to_exit_code(&e);
1553 let data = serde_json::to_value(&e).ok();
1555 (code, e.to_string(), data)
1556 })
1557 }
1558 _ = tokio::signal::ctrl_c() => {
1559 eprintln!("Execution cancelled.");
1560 std::process::exit(EXIT_SIGINT);
1561 }
1562 }
1563 };
1564
1565 let duration_ms = start.elapsed().as_millis() as u64;
1566
1567 match result {
1568 Ok(output) => {
1569 if let Ok(guard) = AUDIT_LOGGER.lock() {
1571 if let Some(logger) = guard.as_ref() {
1572 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1573 }
1574 }
1575 let fmt = crate::output::resolve_format(format_flag.as_deref());
1577 println!(
1578 "{}",
1579 crate::output::format_exec_result(&output, fmt, fields_flag.as_deref(),)
1580 );
1581 std::process::exit(EXIT_SUCCESS);
1582 }
1583 Err((exit_code, msg, error_data)) => {
1584 if let Ok(guard) = AUDIT_LOGGER.lock() {
1586 if let Some(logger) = guard.as_ref() {
1587 logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1588 }
1589 }
1590 if format_flag.as_deref() == Some("json") || !std::io::stderr().is_terminal() {
1592 emit_error_json(module_id, &msg, exit_code, error_data.as_ref());
1593 } else {
1594 emit_error_tty(module_id, &msg, exit_code, error_data.as_ref());
1595 }
1596 std::process::exit(exit_code);
1597 }
1598 }
1599}
1600
1601#[cfg(test)]
1606mod tests {
1607 use super::*;
1608
1609 #[test]
1610 fn test_validate_module_id_valid() {
1611 for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1613 let result = validate_module_id(id);
1614 assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1615 }
1616 }
1617
1618 #[test]
1619 fn test_validate_module_id_too_long() {
1620 let long_id = "a".repeat(129);
1621 assert!(validate_module_id(&long_id).is_err());
1622 }
1623
1624 #[test]
1625 fn test_validate_module_id_invalid_format() {
1626 for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1627 assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1628 }
1629 }
1630
1631 #[test]
1632 fn test_validate_module_id_max_length() {
1633 let max_id = "a".repeat(128);
1634 assert!(validate_module_id(&max_id).is_ok());
1635 }
1636
1637 #[test]
1640 fn test_collect_input_no_stdin_drops_null_values() {
1641 use serde_json::json;
1642 let mut kwargs = HashMap::new();
1643 kwargs.insert("a".to_string(), json!(5));
1644 kwargs.insert("b".to_string(), Value::Null);
1645
1646 let result = collect_input(None, kwargs, false).unwrap();
1647 assert_eq!(result.get("a"), Some(&json!(5)));
1648 assert!(!result.contains_key("b"), "Null values must be dropped");
1649 }
1650
1651 #[test]
1652 fn test_collect_input_stdin_valid_json() {
1653 use serde_json::json;
1654 use std::io::Cursor;
1655 let stdin_bytes = b"{\"x\": 42}";
1656 let reader = Cursor::new(stdin_bytes.to_vec());
1657 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1658 assert_eq!(result.get("x"), Some(&json!(42)));
1659 }
1660
1661 #[test]
1662 fn test_collect_input_cli_overrides_stdin() {
1663 use serde_json::json;
1664 use std::io::Cursor;
1665 let stdin_bytes = b"{\"a\": 5}";
1666 let reader = Cursor::new(stdin_bytes.to_vec());
1667 let mut kwargs = HashMap::new();
1668 kwargs.insert("a".to_string(), json!(99));
1669 let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1670 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1671 }
1672
1673 #[test]
1674 fn test_collect_input_oversized_stdin_rejected() {
1675 use std::io::Cursor;
1676 let big = vec![b' '; 10 * 1024 * 1024 + 1];
1677 let reader = Cursor::new(big);
1678 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1679 assert!(matches!(err, CliError::InputTooLarge { .. }));
1680 }
1681
1682 #[test]
1683 fn test_collect_input_large_input_allowed() {
1684 use std::io::Cursor;
1685 let mut payload = b"{\"k\": \"".to_vec();
1686 payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1687 payload.extend(b"\"}");
1688 let reader = Cursor::new(payload);
1689 let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1690 assert!(
1691 result.is_ok(),
1692 "large_input=true must accept oversized payload"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_collect_input_invalid_json_returns_error() {
1698 use std::io::Cursor;
1699 let reader = Cursor::new(b"not json at all".to_vec());
1700 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1701 assert!(matches!(err, CliError::JsonParse(_)));
1702 }
1703
1704 #[test]
1705 fn test_collect_input_non_object_json_returns_error() {
1706 use std::io::Cursor;
1707 let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1708 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1709 assert!(matches!(err, CliError::NotAnObject));
1710 }
1711
1712 #[test]
1713 fn test_collect_input_empty_stdin_returns_empty_map() {
1714 use std::io::Cursor;
1715 let reader = Cursor::new(b"".to_vec());
1716 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1717 assert!(result.is_empty());
1718 }
1719
1720 #[test]
1721 fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1722 use serde_json::json;
1723 let mut kwargs = HashMap::new();
1724 kwargs.insert("foo".to_string(), json!("bar"));
1725 let result = collect_input(None, kwargs.clone(), false).unwrap();
1726 assert_eq!(result.get("foo"), Some(&json!("bar")));
1727 }
1728
1729 fn make_module_descriptor(
1737 name: &str,
1738 _description: &str,
1739 schema: Option<serde_json::Value>,
1740 ) -> apcore::registry::registry::ModuleDescriptor {
1741 apcore::registry::registry::ModuleDescriptor {
1742 name: name.to_string(),
1743 annotations: apcore::module::ModuleAnnotations::default(),
1744 input_schema: schema.unwrap_or(serde_json::Value::Null),
1745 output_schema: serde_json::Value::Object(Default::default()),
1746 enabled: true,
1747 tags: vec![],
1748 dependencies: vec![],
1749 }
1750 }
1751
1752 #[test]
1753 fn test_build_module_command_name_is_set() {
1754 let module = make_module_descriptor("math.add", "Add two numbers", None);
1755 let executor = mock_executor();
1756 let cmd = build_module_command(&module, executor).unwrap();
1757 assert_eq!(cmd.get_name(), "math.add");
1758 }
1759
1760 #[test]
1761 fn test_build_module_command_has_input_flag() {
1762 let module = make_module_descriptor("a.b", "desc", None);
1763 let executor = mock_executor();
1764 let cmd = build_module_command(&module, executor).unwrap();
1765 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1766 assert!(names.contains(&"input"), "must have --input flag");
1767 }
1768
1769 #[test]
1770 fn test_build_module_command_has_yes_flag() {
1771 let module = make_module_descriptor("a.b", "desc", None);
1772 let executor = mock_executor();
1773 let cmd = build_module_command(&module, executor).unwrap();
1774 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1775 assert!(names.contains(&"yes"), "must have --yes flag");
1776 }
1777
1778 #[test]
1779 fn test_build_module_command_has_large_input_flag() {
1780 let module = make_module_descriptor("a.b", "desc", None);
1781 let executor = mock_executor();
1782 let cmd = build_module_command(&module, executor).unwrap();
1783 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1784 assert!(
1785 names.contains(&"large-input"),
1786 "must have --large-input flag"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_build_module_command_has_format_flag() {
1792 let module = make_module_descriptor("a.b", "desc", None);
1793 let executor = mock_executor();
1794 let cmd = build_module_command(&module, executor).unwrap();
1795 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1796 assert!(names.contains(&"format"), "must have --format flag");
1797 }
1798
1799 #[test]
1800 fn test_build_module_command_has_sandbox_flag() {
1801 let module = make_module_descriptor("a.b", "desc", None);
1802 let executor = mock_executor();
1803 let cmd = build_module_command(&module, executor).unwrap();
1804 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1805 assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1806 }
1807
1808 #[test]
1809 fn test_build_module_command_reserved_name_returns_error() {
1810 for reserved in BUILTIN_COMMANDS {
1811 let module = make_module_descriptor(reserved, "desc", None);
1812 let executor = mock_executor();
1813 let result = build_module_command(&module, executor);
1814 assert!(
1815 matches!(result, Err(CliError::ReservedModuleId(_))),
1816 "expected ReservedModuleId for '{reserved}', got {result:?}"
1817 );
1818 }
1819 }
1820
1821 #[test]
1822 fn test_build_module_command_yes_has_short_flag() {
1823 let module = make_module_descriptor("a.b", "desc", None);
1824 let executor = mock_executor();
1825 let cmd = build_module_command(&module, executor).unwrap();
1826 let has_short_y = cmd
1827 .get_opts()
1828 .filter(|a| a.get_long() == Some("yes"))
1829 .any(|a| a.get_short() == Some('y'));
1830 assert!(has_short_y, "--yes must have short flag -y");
1831 }
1832
1833 struct CliMockRegistry {
1839 modules: Vec<String>,
1840 }
1841
1842 impl crate::discovery::RegistryProvider for CliMockRegistry {
1843 fn list(&self) -> Vec<String> {
1844 self.modules.clone()
1845 }
1846
1847 fn get_definition(&self, name: &str) -> Option<Value> {
1848 if self.modules.iter().any(|m| m == name) {
1849 Some(serde_json::json!({
1850 "module_id": name,
1851 "name": name,
1852 "input_schema": {},
1853 "output_schema": {},
1854 "enabled": true,
1855 "tags": [],
1856 "dependencies": [],
1857 }))
1858 } else {
1859 None
1860 }
1861 }
1862
1863 fn get_module_descriptor(
1864 &self,
1865 name: &str,
1866 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1867 if self.modules.iter().any(|m| m == name) {
1868 Some(apcore::registry::registry::ModuleDescriptor {
1869 name: name.to_string(),
1870 annotations: apcore::module::ModuleAnnotations::default(),
1871 input_schema: serde_json::Value::Object(Default::default()),
1872 output_schema: serde_json::Value::Object(Default::default()),
1873 enabled: true,
1874 tags: vec![],
1875 dependencies: vec![],
1876 })
1877 } else {
1878 None
1879 }
1880 }
1881 }
1882
1883 struct EmptyRegistry;
1885
1886 impl crate::discovery::RegistryProvider for EmptyRegistry {
1887 fn list(&self) -> Vec<String> {
1888 vec![]
1889 }
1890
1891 fn get_definition(&self, _name: &str) -> Option<Value> {
1892 None
1893 }
1894 }
1895
1896 struct MockExecutor;
1898
1899 impl ModuleExecutor for MockExecutor {}
1900
1901 fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1902 Arc::new(CliMockRegistry {
1903 modules: modules.iter().map(|s| s.to_string()).collect(),
1904 })
1905 }
1906
1907 fn mock_executor() -> Arc<dyn ModuleExecutor> {
1908 Arc::new(MockExecutor)
1909 }
1910
1911 #[test]
1912 fn test_lazy_module_group_list_commands_empty_registry() {
1913 let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1914 let cmds = group.list_commands();
1915 for builtin in BUILTIN_COMMANDS {
1916 assert!(
1917 cmds.contains(&builtin.to_string()),
1918 "missing builtin: {builtin}"
1919 );
1920 }
1921 let mut sorted = cmds.clone();
1923 sorted.sort();
1924 assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1925 }
1926
1927 #[test]
1928 fn test_lazy_module_group_list_commands_includes_modules() {
1929 let group = LazyModuleGroup::new(
1930 mock_registry(vec!["math.add", "text.summarize"]),
1931 mock_executor(),
1932 );
1933 let cmds = group.list_commands();
1934 assert!(cmds.contains(&"math.add".to_string()));
1935 assert!(cmds.contains(&"text.summarize".to_string()));
1936 }
1937
1938 #[test]
1939 fn test_lazy_module_group_list_commands_registry_error() {
1940 let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1941 let cmds = group.list_commands();
1942 assert!(!cmds.is_empty());
1944 assert!(cmds.contains(&"list".to_string()));
1945 }
1946
1947 #[test]
1948 fn test_lazy_module_group_get_command_builtin() {
1949 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1950 let cmd = group.get_command("list");
1951 assert!(cmd.is_some(), "get_command('list') must return Some");
1952 }
1953
1954 #[test]
1955 fn test_lazy_module_group_get_command_not_found() {
1956 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1957 let cmd = group.get_command("nonexistent.module");
1958 assert!(cmd.is_none());
1959 }
1960
1961 #[test]
1962 fn test_lazy_module_group_get_command_caches_module() {
1963 let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1964 let cmd1 = group.get_command("math.add");
1966 assert!(cmd1.is_some());
1967 let cmd2 = group.get_command("math.add");
1969 assert!(cmd2.is_some());
1970 assert_eq!(
1971 group.registry_lookup_count(),
1972 1,
1973 "cached after first lookup"
1974 );
1975 }
1976
1977 #[test]
1978 fn test_lazy_module_group_builtin_commands_sorted() {
1979 let mut sorted = BUILTIN_COMMANDS.to_vec();
1981 sorted.sort_unstable();
1982 assert_eq!(
1983 BUILTIN_COMMANDS,
1984 sorted.as_slice(),
1985 "BUILTIN_COMMANDS must be sorted"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_lazy_module_group_list_deduplicates_builtins() {
1991 let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1994 let cmds = group.list_commands();
1995 let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1996 assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1997 }
1998
1999 #[test]
2004 fn test_map_error_module_not_found_is_44() {
2005 assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
2006 }
2007
2008 #[test]
2009 fn test_map_error_module_load_error_is_44() {
2010 assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
2011 }
2012
2013 #[test]
2014 fn test_map_error_module_disabled_is_44() {
2015 assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
2016 }
2017
2018 #[test]
2019 fn test_map_error_schema_validation_error_is_45() {
2020 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
2021 }
2022
2023 #[test]
2024 fn test_map_error_approval_denied_is_46() {
2025 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
2026 }
2027
2028 #[test]
2029 fn test_map_error_approval_timeout_is_46() {
2030 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
2031 }
2032
2033 #[test]
2034 fn test_map_error_approval_pending_is_46() {
2035 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
2036 }
2037
2038 #[test]
2039 fn test_map_error_config_not_found_is_47() {
2040 assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
2041 }
2042
2043 #[test]
2044 fn test_map_error_config_invalid_is_47() {
2045 assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
2046 }
2047
2048 #[test]
2049 fn test_map_error_schema_circular_ref_is_48() {
2050 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
2051 }
2052
2053 #[test]
2054 fn test_map_error_acl_denied_is_77() {
2055 assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
2056 }
2057
2058 #[test]
2059 fn test_map_error_module_execute_error_is_1() {
2060 assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
2061 }
2062
2063 #[test]
2064 fn test_map_error_module_timeout_is_1() {
2065 assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
2066 }
2067
2068 #[test]
2069 fn test_map_error_unknown_is_1() {
2070 assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
2071 }
2072
2073 #[test]
2074 fn test_map_error_empty_string_is_1() {
2075 assert_eq!(map_apcore_error_to_exit_code(""), 1);
2076 }
2077
2078 #[test]
2083 fn test_set_audit_logger_none_clears_logger() {
2084 set_audit_logger(None);
2086 let guard = AUDIT_LOGGER.lock().unwrap();
2087 assert!(guard.is_none(), "setting None must clear the audit logger");
2088 }
2089
2090 #[test]
2091 fn test_set_audit_logger_some_stores_logger() {
2092 use crate::security::AuditLogger;
2093 set_audit_logger(Some(AuditLogger::new(None)));
2094 let guard = AUDIT_LOGGER.lock().unwrap();
2095 assert!(guard.is_some(), "setting Some must store the audit logger");
2096 drop(guard);
2098 set_audit_logger(None);
2099 }
2100
2101 #[test]
2106 fn test_validate_against_schema_passes_with_no_properties() {
2107 let schema = serde_json::json!({});
2108 let input = std::collections::HashMap::new();
2109 let result = validate_against_schema(&input, &schema);
2111 assert!(result.is_ok(), "empty schema must pass: {result:?}");
2112 }
2113
2114 #[test]
2115 fn test_validate_against_schema_required_field_missing_fails() {
2116 let schema = serde_json::json!({
2117 "properties": {
2118 "a": {"type": "integer"}
2119 },
2120 "required": ["a"]
2121 });
2122 let input: std::collections::HashMap<String, serde_json::Value> =
2123 std::collections::HashMap::new();
2124 let result = validate_against_schema(&input, &schema);
2125 assert!(result.is_err(), "missing required field must fail");
2126 }
2127
2128 #[test]
2129 fn test_validate_against_schema_required_field_present_passes() {
2130 let schema = serde_json::json!({
2131 "properties": {
2132 "a": {"type": "integer"}
2133 },
2134 "required": ["a"]
2135 });
2136 let mut input = std::collections::HashMap::new();
2137 input.insert("a".to_string(), serde_json::json!(42));
2138 let result = validate_against_schema(&input, &schema);
2139 assert!(
2140 result.is_ok(),
2141 "present required field must pass: {result:?}"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_validate_against_schema_no_required_any_input_passes() {
2147 let schema = serde_json::json!({
2148 "properties": {
2149 "x": {"type": "string"}
2150 }
2151 });
2152 let input: std::collections::HashMap<String, serde_json::Value> =
2153 std::collections::HashMap::new();
2154 let result = validate_against_schema(&input, &schema);
2155 assert!(result.is_ok(), "no required fields: empty input must pass");
2156 }
2157
2158 #[test]
2163 fn test_resolve_group_explicit_group() {
2164 let desc = serde_json::json!({
2165 "module_id": "my.thing",
2166 "metadata": {
2167 "display": {
2168 "cli": {
2169 "group": "tools",
2170 "alias": "thing"
2171 }
2172 }
2173 }
2174 });
2175 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
2176 assert_eq!(group, Some("tools".to_string()));
2177 assert_eq!(cmd, "thing");
2178 }
2179
2180 #[test]
2181 fn test_resolve_group_explicit_empty_is_top_level() {
2182 let desc = serde_json::json!({
2183 "module_id": "my.thing",
2184 "metadata": {
2185 "display": {
2186 "cli": {
2187 "group": "",
2188 "alias": "thing"
2189 }
2190 }
2191 }
2192 });
2193 let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
2194 assert!(group.is_none());
2195 assert_eq!(cmd, "thing");
2196 }
2197
2198 #[test]
2199 fn test_resolve_group_dotted_alias() {
2200 let desc = serde_json::json!({
2201 "module_id": "math.add",
2202 "metadata": {
2203 "display": {
2204 "cli": { "alias": "math.add" }
2205 }
2206 }
2207 });
2208 let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
2209 assert_eq!(group, Some("math".to_string()));
2210 assert_eq!(cmd, "add");
2211 }
2212
2213 #[test]
2214 fn test_resolve_group_no_dot_is_top_level() {
2215 let desc = serde_json::json!({"module_id": "greet"});
2216 let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
2217 assert!(group.is_none());
2218 assert_eq!(cmd, "greet");
2219 }
2220
2221 #[test]
2222 fn test_resolve_group_dotted_module_id_default() {
2223 let desc = serde_json::json!({"module_id": "text.upper"});
2225 let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
2226 assert_eq!(group, Some("text".to_string()));
2227 assert_eq!(cmd, "upper");
2228 }
2229
2230 #[test]
2231 fn test_resolve_group_invalid_group_name_top_level() {
2232 let desc = serde_json::json!({
2236 "module_id": "x",
2237 "metadata": {
2238 "display": {
2239 "cli": { "group": "123Invalid" }
2240 }
2241 }
2242 });
2243 let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
2244 assert_eq!(group, Some("123Invalid".to_string()));
2245 }
2246
2247 #[test]
2248 fn test_grouped_module_group_list_commands_includes_groups() {
2249 let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
2250 let executor = mock_executor();
2251 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2252 let cmds = gmg.list_commands();
2253 assert!(
2254 cmds.contains(&"math".to_string()),
2255 "must contain group 'math'"
2256 );
2257 assert!(
2259 cmds.contains(&"greet".to_string()),
2260 "must contain top-level 'greet'"
2261 );
2262 }
2263
2264 #[test]
2265 fn test_grouped_module_group_get_command_group() {
2266 let registry = mock_registry(vec!["math.add", "math.mul"]);
2267 let executor = mock_executor();
2268 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2269 let cmd = gmg.get_command("math");
2270 assert!(cmd.is_some(), "must find group 'math'");
2271 let group_cmd = cmd.unwrap();
2272 let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
2273 assert!(subs.contains(&"add"));
2274 assert!(subs.contains(&"mul"));
2275 }
2276
2277 #[test]
2278 fn test_grouped_module_group_get_command_top_level() {
2279 let registry = mock_registry(vec!["greet"]);
2280 let executor = mock_executor();
2281 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2282 let cmd = gmg.get_command("greet");
2283 assert!(cmd.is_some(), "must find top-level 'greet'");
2284 }
2285
2286 #[test]
2287 fn test_grouped_module_group_get_command_not_found() {
2288 let registry = mock_registry(vec![]);
2289 let executor = mock_executor();
2290 let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2291 assert!(gmg.get_command("nonexistent").is_none());
2292 }
2293
2294 #[test]
2295 fn test_grouped_module_group_help_max_length() {
2296 let registry = mock_registry(vec![]);
2297 let executor = mock_executor();
2298 let gmg = GroupedModuleGroup::new(registry, executor, 42);
2299 assert_eq!(gmg.help_text_max_length(), 42);
2300 }
2301
2302 #[test]
2303 fn test_is_valid_group_name_valid() {
2304 assert!(is_valid_group_name("math"));
2305 assert!(is_valid_group_name("my-group"));
2306 assert!(is_valid_group_name("g1"));
2307 assert!(is_valid_group_name("a_b"));
2308 }
2309
2310 #[test]
2311 fn test_is_valid_group_name_invalid() {
2312 assert!(!is_valid_group_name(""));
2313 assert!(!is_valid_group_name("1abc"));
2314 assert!(!is_valid_group_name("ABC"));
2315 assert!(!is_valid_group_name("a b"));
2316 }
2317}