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", "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
233const RESERVED_FLAG_NAMES: &[&str] = &["input", "yes", "large-input", "format", "sandbox"];
241
242pub fn build_module_command(
259 module_def: &apcore::registry::registry::ModuleDescriptor,
260 executor: Arc<dyn ModuleExecutor>,
261) -> Result<clap::Command, CliError> {
262 build_module_command_with_limit(
263 module_def,
264 executor,
265 crate::schema_parser::HELP_TEXT_MAX_LEN,
266 )
267}
268
269pub fn build_module_command_with_limit(
272 module_def: &apcore::registry::registry::ModuleDescriptor,
273 executor: Arc<dyn ModuleExecutor>,
274 help_text_max_length: usize,
275) -> Result<clap::Command, CliError> {
276 let module_id = &module_def.name;
277
278 if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
280 return Err(CliError::ReservedModuleId(module_id.clone()));
281 }
282
283 let resolved_schema =
285 crate::ref_resolver::resolve_refs(&module_def.input_schema, 32, module_id)
286 .unwrap_or_else(|_| module_def.input_schema.clone());
287
288 let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
290 &resolved_schema,
291 help_text_max_length,
292 )
293 .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
294
295 for arg in &schema_args.args {
297 if let Some(long) = arg.get_long() {
298 if RESERVED_FLAG_NAMES.contains(&long) {
299 return Err(CliError::ReservedModuleId(format!(
300 "module '{module_id}' schema property '{long}' conflicts \
301 with a reserved CLI option name"
302 )));
303 }
304 }
305 }
306
307 let _ = executor;
309
310 let mut cmd = clap::Command::new(module_id.clone())
311 .arg(
313 clap::Arg::new("input")
314 .long("input")
315 .value_name("SOURCE")
316 .help("Read input from file or STDIN ('-')."),
317 )
318 .arg(
319 clap::Arg::new("yes")
320 .long("yes")
321 .short('y')
322 .action(clap::ArgAction::SetTrue)
323 .help("Bypass approval prompts."),
324 )
325 .arg(
326 clap::Arg::new("large-input")
327 .long("large-input")
328 .action(clap::ArgAction::SetTrue)
329 .help("Allow STDIN input larger than 10MB."),
330 )
331 .arg(
332 clap::Arg::new("format")
333 .long("format")
334 .value_parser(["json", "table"])
335 .help("Output format."),
336 )
337 .arg(
338 clap::Arg::new("sandbox")
339 .long("sandbox")
340 .action(clap::ArgAction::SetTrue)
341 .help("Run module in subprocess sandbox."),
342 );
343
344 for arg in schema_args.args {
346 cmd = cmd.arg(arg);
347 }
348
349 Ok(cmd)
350}
351
352const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; pub fn collect_input_from_reader<R: Read>(
369 stdin_flag: Option<&str>,
370 cli_kwargs: HashMap<String, Value>,
371 large_input: bool,
372 mut reader: R,
373) -> Result<HashMap<String, Value>, CliError> {
374 let cli_non_null: HashMap<String, Value> = cli_kwargs
376 .into_iter()
377 .filter(|(_, v)| !v.is_null())
378 .collect();
379
380 if stdin_flag != Some("-") {
381 return Ok(cli_non_null);
382 }
383
384 let mut buf = Vec::new();
385 reader
386 .read_to_end(&mut buf)
387 .map_err(|e| CliError::StdinRead(e.to_string()))?;
388
389 if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
390 return Err(CliError::InputTooLarge {
391 limit: STDIN_SIZE_LIMIT_BYTES,
392 actual: buf.len(),
393 });
394 }
395
396 if buf.is_empty() {
397 return Ok(cli_non_null);
398 }
399
400 let stdin_value: Value =
401 serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
402
403 let stdin_map = match stdin_value {
404 Value::Object(m) => m,
405 _ => return Err(CliError::NotAnObject),
406 };
407
408 let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
410 merged.extend(cli_non_null);
411 Ok(merged)
412}
413
414pub fn collect_input(
429 stdin_flag: Option<&str>,
430 cli_kwargs: HashMap<String, Value>,
431 large_input: bool,
432) -> Result<HashMap<String, Value>, CliError> {
433 collect_input_from_reader(stdin_flag, cli_kwargs, large_input, std::io::stdin())
434}
435
436const MODULE_ID_MAX_LEN: usize = 128;
441
442pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
453 if module_id.len() > MODULE_ID_MAX_LEN {
454 return Err(CliError::InvalidModuleId(format!(
455 "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
456 )));
457 }
458 if !is_valid_module_id(module_id) {
459 return Err(CliError::InvalidModuleId(format!(
460 "Invalid module ID format: '{module_id}'."
461 )));
462 }
463 Ok(())
464}
465
466#[inline]
470fn is_valid_module_id(s: &str) -> bool {
471 if s.is_empty() {
472 return false;
473 }
474 for segment in s.split('.') {
476 if segment.is_empty() {
477 return false;
479 }
480 let mut chars = segment.chars();
481 match chars.next() {
483 Some(c) if c.is_ascii_lowercase() => {}
484 _ => return false,
485 }
486 for c in chars {
488 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
489 return false;
490 }
491 }
492 }
493 true
494}
495
496pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
511 use crate::{
512 EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_NOT_FOUND, EXIT_MODULE_EXECUTE_ERROR,
513 EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR,
514 };
515 match error_code {
516 "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
517 "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
518 "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
519 "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
520 "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
521 "ACL_DENIED" => EXIT_ACL_DENIED,
522 _ => EXIT_MODULE_EXECUTE_ERROR,
523 }
524}
525
526pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
532 let code_str = serde_json::to_value(err.code)
534 .ok()
535 .and_then(|v| v.as_str().map(|s| s.to_string()))
536 .unwrap_or_default();
537 map_apcore_error_to_exit_code(&code_str)
538}
539
540pub(crate) fn validate_against_schema(
553 input: &HashMap<String, Value>,
554 schema: &Value,
555) -> Result<(), String> {
556 let required = match schema.get("required") {
558 Some(Value::Array(arr)) => arr,
559 _ => return Ok(()),
560 };
561 for req in required {
562 if let Some(field_name) = req.as_str() {
563 if !input.contains_key(field_name) {
564 return Err(format!("required field '{}' is missing", field_name));
565 }
566 }
567 }
568 Ok(())
569}
570
571pub fn reconcile_bool_pairs(
582 matches: &clap::ArgMatches,
583 bool_pairs: &[crate::schema_parser::BoolFlagPair],
584) -> HashMap<String, Value> {
585 let mut result = HashMap::new();
586 for pair in bool_pairs {
587 let pos_set = matches
590 .try_get_one::<bool>(&pair.prop_name)
591 .ok()
592 .flatten()
593 .copied()
594 .unwrap_or(false);
595 let neg_id = format!("no-{}", pair.prop_name);
596 let neg_set = matches
597 .try_get_one::<bool>(&neg_id)
598 .ok()
599 .flatten()
600 .copied()
601 .unwrap_or(false);
602 let val = if pos_set {
603 true
604 } else if neg_set {
605 false
606 } else {
607 pair.default_val
608 };
609 result.insert(pair.prop_name.clone(), Value::Bool(val));
610 }
611 result
612}
613
614fn extract_cli_kwargs(
619 matches: &clap::ArgMatches,
620 module_def: &apcore::registry::registry::ModuleDescriptor,
621) -> HashMap<String, Value> {
622 use crate::schema_parser::schema_to_clap_args;
623
624 let schema_args = match schema_to_clap_args(&module_def.input_schema) {
625 Ok(sa) => sa,
626 Err(_) => return HashMap::new(),
627 };
628
629 let mut kwargs: HashMap<String, Value> = HashMap::new();
630
631 for arg in &schema_args.args {
633 let id = arg.get_id().as_str().to_string();
634 if id.starts_with("no-") {
636 continue;
637 }
638 if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
641 kwargs.insert(id, Value::String(val.clone()));
642 } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
643 kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
644 } else {
645 kwargs.insert(id, Value::Null);
646 }
647 }
648
649 let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
651 kwargs.extend(bool_vals);
652
653 crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
655}
656
657async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
662 use tokio::io::AsyncWriteExt;
663
664 let mut child = tokio::process::Command::new(executable)
665 .stdin(std::process::Stdio::piped())
666 .stdout(std::process::Stdio::piped())
667 .stderr(std::process::Stdio::piped())
668 .spawn()
669 .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
670
671 if let Some(mut stdin) = child.stdin.take() {
673 let payload =
674 serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
675 stdin
676 .write_all(&payload)
677 .await
678 .map_err(|e| format!("failed to write to stdin: {e}"))?;
679 drop(stdin);
680 }
681
682 let output = child
683 .wait_with_output()
684 .await
685 .map_err(|e| format!("failed to read output: {e}"))?;
686
687 if !output.status.success() {
688 let code = output.status.code().unwrap_or(1);
689 let stderr_hint = String::from_utf8_lossy(&output.stderr);
690 return Err(format!(
691 "script exited with code {code}{}",
692 if stderr_hint.is_empty() {
693 String::new()
694 } else {
695 format!(": {}", stderr_hint.trim())
696 }
697 ));
698 }
699
700 serde_json::from_slice(&output.stdout)
701 .map_err(|e| format!("script stdout is not valid JSON: {e}"))
702}
703
704pub async fn dispatch_module(
709 module_id: &str,
710 matches: &clap::ArgMatches,
711 registry: &Arc<dyn crate::discovery::RegistryProvider>,
712 _executor: &Arc<dyn ModuleExecutor + 'static>,
713 apcore_executor: &apcore::Executor,
714) -> ! {
715 use crate::{
716 EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
717 EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
718 };
719
720 if let Err(e) = validate_module_id(module_id) {
722 eprintln!("Error: Invalid module ID format: '{module_id}'.");
723 let _ = e;
724 std::process::exit(EXIT_INVALID_INPUT);
725 }
726
727 let module_def = match registry.get_module_descriptor(module_id) {
729 Some(def) => def,
730 None => {
731 eprintln!("Error: Module '{module_id}' not found in registry.");
732 std::process::exit(EXIT_MODULE_NOT_FOUND);
733 }
734 };
735
736 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
738 let auto_approve = matches.get_flag("yes");
739 let large_input = matches.get_flag("large-input");
740 let format_flag = matches.get_one::<String>("format").cloned();
741
742 let cli_kwargs = extract_cli_kwargs(matches, &module_def);
744
745 let merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
747 Ok(m) => m,
748 Err(CliError::InputTooLarge { .. }) => {
749 eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
750 std::process::exit(EXIT_INVALID_INPUT);
751 }
752 Err(CliError::JsonParse(detail)) => {
753 eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
754 std::process::exit(EXIT_INVALID_INPUT);
755 }
756 Err(CliError::NotAnObject) => {
757 eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
758 std::process::exit(EXIT_INVALID_INPUT);
759 }
760 Err(e) => {
761 eprintln!("Error: {e}");
762 std::process::exit(EXIT_INVALID_INPUT);
763 }
764 };
765
766 if let Some(schema) = module_def.input_schema.as_object() {
768 if schema.contains_key("properties") {
769 if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
770 eprintln!("Error: Validation failed: {detail}.");
771 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
772 }
773 }
774 }
775
776 let module_json = serde_json::to_value(&module_def).unwrap_or_default();
778 if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
779 eprintln!("Error: {e}");
780 std::process::exit(EXIT_APPROVAL_DENIED);
781 }
782
783 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
785
786 let use_sandbox = matches.get_flag("sandbox");
788
789 let script_executable = EXECUTABLES
791 .get()
792 .and_then(|map| map.get(module_id))
793 .cloned();
794
795 let start = std::time::Instant::now();
797
798 let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
801 tokio::select! {
803 res = execute_script(&exec_path, &input_value) => {
804 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e))
805 }
806 _ = tokio::signal::ctrl_c() => {
807 eprintln!("Execution cancelled.");
808 std::process::exit(EXIT_SIGINT);
809 }
810 }
811 } else if use_sandbox {
812 let sandbox = crate::security::Sandbox::new(true, 0);
813 tokio::select! {
814 res = sandbox.execute(module_id, input_value.clone()) => {
815 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string()))
816 }
817 _ = tokio::signal::ctrl_c() => {
818 eprintln!("Execution cancelled.");
819 std::process::exit(EXIT_SIGINT);
820 }
821 }
822 } else {
823 tokio::select! {
825 res = apcore_executor.call(module_id, input_value.clone(), None, None) => {
826 res.map_err(|e| {
827 let code = map_module_error_to_exit_code(&e);
828 (code, e.to_string())
829 })
830 }
831 _ = tokio::signal::ctrl_c() => {
832 eprintln!("Execution cancelled.");
833 std::process::exit(EXIT_SIGINT);
834 }
835 }
836 };
837
838 let duration_ms = start.elapsed().as_millis() as u64;
839
840 match result {
841 Ok(output) => {
842 if let Ok(guard) = AUDIT_LOGGER.lock() {
844 if let Some(logger) = guard.as_ref() {
845 logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
846 }
847 }
848 let fmt = crate::output::resolve_format(format_flag.as_deref());
850 println!("{}", crate::output::format_exec_result(&output, fmt));
851 std::process::exit(EXIT_SUCCESS);
852 }
853 Err((exit_code, msg)) => {
854 if let Ok(guard) = AUDIT_LOGGER.lock() {
856 if let Some(logger) = guard.as_ref() {
857 logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
858 }
859 }
860 eprintln!("Error: Module '{module_id}' execution failed: {msg}.");
861 std::process::exit(exit_code);
862 }
863 }
864}
865
866#[cfg(test)]
871mod tests {
872 use super::*;
873
874 #[test]
875 fn test_validate_module_id_valid() {
876 for id in ["math.add", "text.summarize", "a", "a.b.c"] {
878 let result = validate_module_id(id);
879 assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
880 }
881 }
882
883 #[test]
884 fn test_validate_module_id_too_long() {
885 let long_id = "a".repeat(129);
886 assert!(validate_module_id(&long_id).is_err());
887 }
888
889 #[test]
890 fn test_validate_module_id_invalid_format() {
891 for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
892 assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
893 }
894 }
895
896 #[test]
897 fn test_validate_module_id_max_length() {
898 let max_id = "a".repeat(128);
899 assert!(validate_module_id(&max_id).is_ok());
900 }
901
902 #[test]
905 fn test_collect_input_no_stdin_drops_null_values() {
906 use serde_json::json;
907 let mut kwargs = HashMap::new();
908 kwargs.insert("a".to_string(), json!(5));
909 kwargs.insert("b".to_string(), Value::Null);
910
911 let result = collect_input(None, kwargs, false).unwrap();
912 assert_eq!(result.get("a"), Some(&json!(5)));
913 assert!(!result.contains_key("b"), "Null values must be dropped");
914 }
915
916 #[test]
917 fn test_collect_input_stdin_valid_json() {
918 use serde_json::json;
919 use std::io::Cursor;
920 let stdin_bytes = b"{\"x\": 42}";
921 let reader = Cursor::new(stdin_bytes.to_vec());
922 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
923 assert_eq!(result.get("x"), Some(&json!(42)));
924 }
925
926 #[test]
927 fn test_collect_input_cli_overrides_stdin() {
928 use serde_json::json;
929 use std::io::Cursor;
930 let stdin_bytes = b"{\"a\": 5}";
931 let reader = Cursor::new(stdin_bytes.to_vec());
932 let mut kwargs = HashMap::new();
933 kwargs.insert("a".to_string(), json!(99));
934 let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
935 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
936 }
937
938 #[test]
939 fn test_collect_input_oversized_stdin_rejected() {
940 use std::io::Cursor;
941 let big = vec![b' '; 10 * 1024 * 1024 + 1];
942 let reader = Cursor::new(big);
943 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
944 assert!(matches!(err, CliError::InputTooLarge { .. }));
945 }
946
947 #[test]
948 fn test_collect_input_large_input_allowed() {
949 use std::io::Cursor;
950 let mut payload = b"{\"k\": \"".to_vec();
951 payload.extend(vec![b'x'; 11 * 1024 * 1024]);
952 payload.extend(b"\"}");
953 let reader = Cursor::new(payload);
954 let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
955 assert!(
956 result.is_ok(),
957 "large_input=true must accept oversized payload"
958 );
959 }
960
961 #[test]
962 fn test_collect_input_invalid_json_returns_error() {
963 use std::io::Cursor;
964 let reader = Cursor::new(b"not json at all".to_vec());
965 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
966 assert!(matches!(err, CliError::JsonParse(_)));
967 }
968
969 #[test]
970 fn test_collect_input_non_object_json_returns_error() {
971 use std::io::Cursor;
972 let reader = Cursor::new(b"[1, 2, 3]".to_vec());
973 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
974 assert!(matches!(err, CliError::NotAnObject));
975 }
976
977 #[test]
978 fn test_collect_input_empty_stdin_returns_empty_map() {
979 use std::io::Cursor;
980 let reader = Cursor::new(b"".to_vec());
981 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
982 assert!(result.is_empty());
983 }
984
985 #[test]
986 fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
987 use serde_json::json;
988 let mut kwargs = HashMap::new();
989 kwargs.insert("foo".to_string(), json!("bar"));
990 let result = collect_input(None, kwargs.clone(), false).unwrap();
991 assert_eq!(result.get("foo"), Some(&json!("bar")));
992 }
993
994 fn make_module_descriptor(
1002 name: &str,
1003 _description: &str,
1004 schema: Option<serde_json::Value>,
1005 ) -> apcore::registry::registry::ModuleDescriptor {
1006 apcore::registry::registry::ModuleDescriptor {
1007 name: name.to_string(),
1008 annotations: apcore::module::ModuleAnnotations::default(),
1009 input_schema: schema.unwrap_or(serde_json::Value::Null),
1010 output_schema: serde_json::Value::Object(Default::default()),
1011 enabled: true,
1012 tags: vec![],
1013 dependencies: vec![],
1014 }
1015 }
1016
1017 #[test]
1018 fn test_build_module_command_name_is_set() {
1019 let module = make_module_descriptor("math.add", "Add two numbers", None);
1020 let executor = mock_executor();
1021 let cmd = build_module_command(&module, executor).unwrap();
1022 assert_eq!(cmd.get_name(), "math.add");
1023 }
1024
1025 #[test]
1026 fn test_build_module_command_has_input_flag() {
1027 let module = make_module_descriptor("a.b", "desc", None);
1028 let executor = mock_executor();
1029 let cmd = build_module_command(&module, executor).unwrap();
1030 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1031 assert!(names.contains(&"input"), "must have --input flag");
1032 }
1033
1034 #[test]
1035 fn test_build_module_command_has_yes_flag() {
1036 let module = make_module_descriptor("a.b", "desc", None);
1037 let executor = mock_executor();
1038 let cmd = build_module_command(&module, executor).unwrap();
1039 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1040 assert!(names.contains(&"yes"), "must have --yes flag");
1041 }
1042
1043 #[test]
1044 fn test_build_module_command_has_large_input_flag() {
1045 let module = make_module_descriptor("a.b", "desc", None);
1046 let executor = mock_executor();
1047 let cmd = build_module_command(&module, executor).unwrap();
1048 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1049 assert!(
1050 names.contains(&"large-input"),
1051 "must have --large-input flag"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_build_module_command_has_format_flag() {
1057 let module = make_module_descriptor("a.b", "desc", None);
1058 let executor = mock_executor();
1059 let cmd = build_module_command(&module, executor).unwrap();
1060 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1061 assert!(names.contains(&"format"), "must have --format flag");
1062 }
1063
1064 #[test]
1065 fn test_build_module_command_has_sandbox_flag() {
1066 let module = make_module_descriptor("a.b", "desc", None);
1067 let executor = mock_executor();
1068 let cmd = build_module_command(&module, executor).unwrap();
1069 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1070 assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1071 }
1072
1073 #[test]
1074 fn test_build_module_command_reserved_name_returns_error() {
1075 for reserved in BUILTIN_COMMANDS {
1076 let module = make_module_descriptor(reserved, "desc", None);
1077 let executor = mock_executor();
1078 let result = build_module_command(&module, executor);
1079 assert!(
1080 matches!(result, Err(CliError::ReservedModuleId(_))),
1081 "expected ReservedModuleId for '{reserved}', got {result:?}"
1082 );
1083 }
1084 }
1085
1086 #[test]
1087 fn test_build_module_command_yes_has_short_flag() {
1088 let module = make_module_descriptor("a.b", "desc", None);
1089 let executor = mock_executor();
1090 let cmd = build_module_command(&module, executor).unwrap();
1091 let has_short_y = cmd
1092 .get_opts()
1093 .filter(|a| a.get_long() == Some("yes"))
1094 .any(|a| a.get_short() == Some('y'));
1095 assert!(has_short_y, "--yes must have short flag -y");
1096 }
1097
1098 struct CliMockRegistry {
1104 modules: Vec<String>,
1105 }
1106
1107 impl crate::discovery::RegistryProvider for CliMockRegistry {
1108 fn list(&self) -> Vec<String> {
1109 self.modules.clone()
1110 }
1111
1112 fn get_definition(&self, name: &str) -> Option<Value> {
1113 if self.modules.iter().any(|m| m == name) {
1114 Some(serde_json::json!({
1115 "module_id": name,
1116 "name": name,
1117 "input_schema": {},
1118 "output_schema": {},
1119 "enabled": true,
1120 "tags": [],
1121 "dependencies": [],
1122 }))
1123 } else {
1124 None
1125 }
1126 }
1127
1128 fn get_module_descriptor(
1129 &self,
1130 name: &str,
1131 ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1132 if self.modules.iter().any(|m| m == name) {
1133 Some(apcore::registry::registry::ModuleDescriptor {
1134 name: name.to_string(),
1135 annotations: apcore::module::ModuleAnnotations::default(),
1136 input_schema: serde_json::Value::Object(Default::default()),
1137 output_schema: serde_json::Value::Object(Default::default()),
1138 enabled: true,
1139 tags: vec![],
1140 dependencies: vec![],
1141 })
1142 } else {
1143 None
1144 }
1145 }
1146 }
1147
1148 struct EmptyRegistry;
1150
1151 impl crate::discovery::RegistryProvider for EmptyRegistry {
1152 fn list(&self) -> Vec<String> {
1153 vec![]
1154 }
1155
1156 fn get_definition(&self, _name: &str) -> Option<Value> {
1157 None
1158 }
1159 }
1160
1161 struct MockExecutor;
1163
1164 impl ModuleExecutor for MockExecutor {}
1165
1166 fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1167 Arc::new(CliMockRegistry {
1168 modules: modules.iter().map(|s| s.to_string()).collect(),
1169 })
1170 }
1171
1172 fn mock_executor() -> Arc<dyn ModuleExecutor> {
1173 Arc::new(MockExecutor)
1174 }
1175
1176 #[test]
1177 fn test_lazy_module_group_list_commands_empty_registry() {
1178 let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1179 let cmds = group.list_commands();
1180 for builtin in ["exec", "list", "describe", "completion", "man"] {
1181 assert!(
1182 cmds.contains(&builtin.to_string()),
1183 "missing builtin: {builtin}"
1184 );
1185 }
1186 let mut sorted = cmds.clone();
1188 sorted.sort();
1189 assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1190 }
1191
1192 #[test]
1193 fn test_lazy_module_group_list_commands_includes_modules() {
1194 let group = LazyModuleGroup::new(
1195 mock_registry(vec!["math.add", "text.summarize"]),
1196 mock_executor(),
1197 );
1198 let cmds = group.list_commands();
1199 assert!(cmds.contains(&"math.add".to_string()));
1200 assert!(cmds.contains(&"text.summarize".to_string()));
1201 }
1202
1203 #[test]
1204 fn test_lazy_module_group_list_commands_registry_error() {
1205 let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1206 let cmds = group.list_commands();
1207 assert!(!cmds.is_empty());
1209 assert!(cmds.contains(&"list".to_string()));
1210 }
1211
1212 #[test]
1213 fn test_lazy_module_group_get_command_builtin() {
1214 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1215 let cmd = group.get_command("list");
1216 assert!(cmd.is_some(), "get_command('list') must return Some");
1217 }
1218
1219 #[test]
1220 fn test_lazy_module_group_get_command_not_found() {
1221 let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1222 let cmd = group.get_command("nonexistent.module");
1223 assert!(cmd.is_none());
1224 }
1225
1226 #[test]
1227 fn test_lazy_module_group_get_command_caches_module() {
1228 let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1229 let cmd1 = group.get_command("math.add");
1231 assert!(cmd1.is_some());
1232 let cmd2 = group.get_command("math.add");
1234 assert!(cmd2.is_some());
1235 assert_eq!(
1236 group.registry_lookup_count(),
1237 1,
1238 "cached after first lookup"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_lazy_module_group_builtin_commands_sorted() {
1244 let mut sorted = BUILTIN_COMMANDS.to_vec();
1246 sorted.sort_unstable();
1247 assert_eq!(
1248 BUILTIN_COMMANDS,
1249 sorted.as_slice(),
1250 "BUILTIN_COMMANDS must be sorted"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_lazy_module_group_list_deduplicates_builtins() {
1256 let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1259 let cmds = group.list_commands();
1260 let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1261 assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1262 }
1263
1264 #[test]
1269 fn test_map_error_module_not_found_is_44() {
1270 assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1271 }
1272
1273 #[test]
1274 fn test_map_error_module_load_error_is_44() {
1275 assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1276 }
1277
1278 #[test]
1279 fn test_map_error_module_disabled_is_44() {
1280 assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1281 }
1282
1283 #[test]
1284 fn test_map_error_schema_validation_error_is_45() {
1285 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1286 }
1287
1288 #[test]
1289 fn test_map_error_approval_denied_is_46() {
1290 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1291 }
1292
1293 #[test]
1294 fn test_map_error_approval_timeout_is_46() {
1295 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1296 }
1297
1298 #[test]
1299 fn test_map_error_approval_pending_is_46() {
1300 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1301 }
1302
1303 #[test]
1304 fn test_map_error_config_not_found_is_47() {
1305 assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1306 }
1307
1308 #[test]
1309 fn test_map_error_config_invalid_is_47() {
1310 assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1311 }
1312
1313 #[test]
1314 fn test_map_error_schema_circular_ref_is_48() {
1315 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1316 }
1317
1318 #[test]
1319 fn test_map_error_acl_denied_is_77() {
1320 assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1321 }
1322
1323 #[test]
1324 fn test_map_error_module_execute_error_is_1() {
1325 assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1326 }
1327
1328 #[test]
1329 fn test_map_error_module_timeout_is_1() {
1330 assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1331 }
1332
1333 #[test]
1334 fn test_map_error_unknown_is_1() {
1335 assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1336 }
1337
1338 #[test]
1339 fn test_map_error_empty_string_is_1() {
1340 assert_eq!(map_apcore_error_to_exit_code(""), 1);
1341 }
1342
1343 #[test]
1348 fn test_set_audit_logger_none_clears_logger() {
1349 set_audit_logger(None);
1351 let guard = AUDIT_LOGGER.lock().unwrap();
1352 assert!(guard.is_none(), "setting None must clear the audit logger");
1353 }
1354
1355 #[test]
1356 fn test_set_audit_logger_some_stores_logger() {
1357 use crate::security::AuditLogger;
1358 set_audit_logger(Some(AuditLogger::new(None)));
1359 let guard = AUDIT_LOGGER.lock().unwrap();
1360 assert!(guard.is_some(), "setting Some must store the audit logger");
1361 drop(guard);
1363 set_audit_logger(None);
1364 }
1365
1366 #[test]
1371 fn test_validate_against_schema_passes_with_no_properties() {
1372 let schema = serde_json::json!({});
1373 let input = std::collections::HashMap::new();
1374 let result = validate_against_schema(&input, &schema);
1376 assert!(result.is_ok(), "empty schema must pass: {result:?}");
1377 }
1378
1379 #[test]
1380 fn test_validate_against_schema_required_field_missing_fails() {
1381 let schema = serde_json::json!({
1382 "properties": {
1383 "a": {"type": "integer"}
1384 },
1385 "required": ["a"]
1386 });
1387 let input: std::collections::HashMap<String, serde_json::Value> =
1388 std::collections::HashMap::new();
1389 let result = validate_against_schema(&input, &schema);
1390 assert!(result.is_err(), "missing required field must fail");
1391 }
1392
1393 #[test]
1394 fn test_validate_against_schema_required_field_present_passes() {
1395 let schema = serde_json::json!({
1396 "properties": {
1397 "a": {"type": "integer"}
1398 },
1399 "required": ["a"]
1400 });
1401 let mut input = std::collections::HashMap::new();
1402 input.insert("a".to_string(), serde_json::json!(42));
1403 let result = validate_against_schema(&input, &schema);
1404 assert!(
1405 result.is_ok(),
1406 "present required field must pass: {result:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_validate_against_schema_no_required_any_input_passes() {
1412 let schema = serde_json::json!({
1413 "properties": {
1414 "x": {"type": "string"}
1415 }
1416 });
1417 let input: std::collections::HashMap<String, serde_json::Value> =
1418 std::collections::HashMap::new();
1419 let result = validate_against_schema(&input, &schema);
1420 assert!(result.is_ok(), "no required fields: empty input must pass");
1421 }
1422}