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
16#[derive(Debug, Error)]
28pub enum CliError {
29 #[error("invalid module id: {0}")]
30 InvalidModuleId(String),
31
32 #[error("reserved module id: '{0}' conflicts with a built-in command name")]
33 ReservedModuleId(String),
34
35 #[error("stdin read error: {0}")]
36 StdinRead(String),
37
38 #[error("json parse error: {0}")]
39 JsonParse(String),
40
41 #[error("input too large (limit {limit} bytes, got {actual} bytes)")]
42 InputTooLarge { limit: usize, actual: usize },
43
44 #[error("expected JSON object, got a different type")]
45 NotAnObject,
46
47 #[error("schema $ref resolution failed for module '{module_id}': {source}")]
50 SchemaRefResolution {
51 module_id: String,
52 source: crate::ref_resolver::RefResolverError,
53 },
54}
55
56impl CliError {
57 pub fn exit_code(&self) -> i32 {
60 match self {
61 CliError::SchemaRefResolution { .. } => crate::EXIT_SCHEMA_CIRCULAR_REF,
62 _ => crate::EXIT_INVALID_INPUT,
63 }
64 }
65}
66
67static VERBOSE_HELP: AtomicBool = AtomicBool::new(false);
73
74pub fn set_verbose_help(verbose: bool) {
77 VERBOSE_HELP.store(verbose, Ordering::Relaxed);
78}
79
80pub fn is_verbose_help() -> bool {
82 VERBOSE_HELP.load(Ordering::Relaxed)
83}
84
85static DOCS_URL: Mutex<Option<String>> = Mutex::new(None);
91
92pub fn set_docs_url(url: Option<String>) {
101 if let Ok(mut guard) = DOCS_URL.lock() {
102 *guard = url;
103 }
104}
105
106pub fn get_docs_url() -> Option<String> {
108 match DOCS_URL.lock() {
109 Ok(guard) => guard.clone(),
110 Err(_) => None,
111 }
112}
113
114static AUDIT_LOGGER: Mutex<Option<AuditLogger>> = Mutex::new(None);
119
120static EXECUTABLES: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
125
126pub fn set_executables(map: HashMap<String, PathBuf>) {
130 let _ = EXECUTABLES.set(map);
131}
132
133pub fn set_audit_logger(audit_logger: Option<AuditLogger>) {
138 match AUDIT_LOGGER.lock() {
139 Ok(mut guard) => {
140 *guard = audit_logger;
141 }
142 Err(_poisoned) => {
143 tracing::warn!("AUDIT_LOGGER mutex poisoned — audit logger not updated");
144 }
145 }
146}
147
148fn audit_log_entry(module_id: &str, input: &Value, status: &str, exit_code: i32, duration_ms: u64) {
155 if let Ok(guard) = AUDIT_LOGGER.lock() {
156 if let Some(logger) = guard.as_ref() {
157 logger.log_execution(module_id, input, status, exit_code, duration_ms);
158 }
159 }
160}
161
162fn audit_success(module_id: &str, input: &Value, duration_ms: u64) {
164 audit_log_entry(module_id, input, "success", 0, duration_ms);
165}
166
167fn audit_error(module_id: &str, input: &Value, exit_code: i32, duration_ms: u64) {
170 audit_log_entry(module_id, input, "error", exit_code, duration_ms);
171}
172
173pub fn add_dispatch_flags(cmd: clap::Command) -> clap::Command {
181 use clap::{Arg, ArgAction};
182 let hide = !is_verbose_help();
183 cmd.arg(
184 Arg::new("input")
185 .long("input")
186 .value_name("SOURCE")
187 .help(
188 "Read JSON input from a file path, \
189 or use '-' to read from stdin pipe",
190 )
191 .hide(hide),
192 )
193 .arg(
194 Arg::new("yes")
195 .long("yes")
196 .short('y')
197 .action(ArgAction::SetTrue)
198 .help(
199 "Skip interactive approval prompts \
200 (for scripts and CI)",
201 )
202 .hide(hide),
203 )
204 .arg(
205 Arg::new("large-input")
206 .long("large-input")
207 .action(ArgAction::SetTrue)
208 .help(
209 "Allow stdin input larger than 10MB \
210 (default limit protects against \
211 accidental pipes)",
212 )
213 .hide(hide),
214 )
215 .arg(
216 Arg::new("format")
217 .long("format")
218 .value_parser(["table", "json", "csv", "yaml", "jsonl"])
219 .help(
220 "Output format: json, table, csv, \
221 yaml, jsonl.",
222 )
223 .hide(hide),
224 )
225 .arg(
226 Arg::new("fields")
227 .long("fields")
228 .value_name("FIELDS")
229 .help(
230 "Comma-separated dot-paths to select \
231 from the result (e.g., 'status,data.count').",
232 )
233 .hide(hide),
234 )
235 .arg(
236 Arg::new("sandbox")
238 .long("sandbox")
239 .action(ArgAction::SetTrue)
240 .help(
241 "Run module in an isolated subprocess \
242 with restricted filesystem and env \
243 access",
244 )
245 .hide(true),
246 )
247 .arg(
248 Arg::new("dry-run")
249 .long("dry-run")
250 .action(ArgAction::SetTrue)
251 .help(
252 "Run preflight checks without executing \
253 the module. Shows validation results.",
254 )
255 .hide(hide),
256 )
257 .arg(
258 Arg::new("trace")
259 .long("trace")
260 .action(ArgAction::SetTrue)
261 .help(
262 "Show execution pipeline trace with \
263 per-step timing after the result.",
264 )
265 .hide(hide),
266 )
267 .arg(
268 Arg::new("stream")
269 .long("stream")
270 .action(ArgAction::SetTrue)
271 .help(
272 "Stream module output as JSONL (one JSON \
273 object per line, flushed immediately).",
274 )
275 .hide(hide),
276 )
277 .arg(
278 Arg::new("strategy")
279 .long("strategy")
280 .value_parser(["standard", "internal", "testing", "performance", "minimal"])
281 .value_name("STRATEGY")
282 .help(
283 "Execution pipeline strategy: standard \
284 (default), internal, testing, performance.",
285 )
286 .hide(hide),
287 )
288 .arg(
289 Arg::new("approval-timeout")
290 .long("approval-timeout")
291 .value_name("SECONDS")
292 .help(
293 "Override approval prompt timeout in \
294 seconds (default: 60).",
295 )
296 .hide(hide),
297 )
298 .arg(
299 Arg::new("approval-token")
300 .long("approval-token")
301 .value_name("TOKEN")
302 .help(
303 "Resume a pending approval with the \
304 given token (for async approval flows).",
305 )
306 .hide(hide),
307 )
308}
309
310pub fn exec_command() -> clap::Command {
314 use clap::{Arg, Command};
315
316 let cmd = Command::new("exec").about("Execute an apcore module").arg(
317 Arg::new("module_id")
318 .required(true)
319 .value_name("MODULE_ID")
320 .help("Fully-qualified module ID to execute"),
321 );
322 add_dispatch_flags(cmd)
323}
324
325const RESERVED_FLAG_NAMES: &[&str] = &[
351 "approval-timeout",
352 "approval-token",
353 "dry-run",
354 "fields",
355 "format",
356 "input",
357 "large-input",
358 "sandbox",
359 "strategy",
360 "stream",
361 "trace",
362 "verbose",
363 "yes",
364];
365
366pub fn build_module_command(
391 module_def: &apcore::registry::registry::ModuleDescriptor,
392) -> Result<clap::Command, CliError> {
393 build_module_command_with_limit(module_def, crate::schema_parser::HELP_TEXT_MAX_LEN)
394}
395
396pub fn build_module_command_with_limit(
399 module_def: &apcore::registry::registry::ModuleDescriptor,
400 help_text_max_length: usize,
401) -> Result<clap::Command, CliError> {
402 let module_id = &module_def.module_id;
403
404 if crate::builtin_group::RESERVED_GROUP_NAMES.contains(&module_id.as_str()) {
406 return Err(CliError::ReservedModuleId(module_id.clone()));
407 }
408
409 let resolved_schema = crate::ref_resolver::resolve_refs(
415 &module_def.input_schema,
416 crate::ref_resolver::MAX_REF_DEPTH,
417 module_id,
418 )
419 .map_err(|e| CliError::SchemaRefResolution {
420 module_id: module_id.clone(),
421 source: e,
422 })?;
423
424 let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
426 &resolved_schema,
427 help_text_max_length,
428 )
429 .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
430
431 for arg in &schema_args.args {
433 if let Some(long) = arg.get_long() {
434 if RESERVED_FLAG_NAMES.contains(&long) {
435 return Err(CliError::ReservedModuleId(format!(
436 "module '{module_id}' schema property '{long}' conflicts \
437 with a reserved CLI option name"
438 )));
439 }
440 }
441 }
442
443 let hide = !is_verbose_help();
444
445 let mut footer_parts = Vec::new();
447 if hide {
448 footer_parts.push(
449 "Use --verbose to show all options \
450 (including built-in apcore options)."
451 .to_string(),
452 );
453 }
454 if let Some(url) = get_docs_url() {
455 footer_parts.push(format!("Docs: {url}/commands/{module_id}"));
456 }
457 let footer = footer_parts.join("\n");
458
459 let mut cmd = add_dispatch_flags(clap::Command::new(module_id.clone()).after_help(footer));
460
461 for arg in schema_args.args {
463 cmd = cmd.arg(arg);
464 }
465
466 Ok(cmd)
467}
468
469const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; pub fn collect_input_from_reader<R: Read>(
486 stdin_flag: Option<&str>,
487 cli_kwargs: HashMap<String, Value>,
488 large_input: bool,
489 mut reader: R,
490) -> Result<HashMap<String, Value>, CliError> {
491 let cli_non_null: HashMap<String, Value> = cli_kwargs
493 .into_iter()
494 .filter(|(_, v)| !v.is_null())
495 .collect();
496
497 if stdin_flag != Some("-") {
498 return Ok(cli_non_null);
499 }
500
501 let mut buf = Vec::new();
502 reader
503 .read_to_end(&mut buf)
504 .map_err(|e| CliError::StdinRead(e.to_string()))?;
505
506 if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
507 return Err(CliError::InputTooLarge {
508 limit: STDIN_SIZE_LIMIT_BYTES,
509 actual: buf.len(),
510 });
511 }
512
513 if buf.is_empty() {
514 return Ok(cli_non_null);
515 }
516
517 let stdin_value: Value =
518 serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
519
520 let stdin_map = match stdin_value {
521 Value::Object(m) => m,
522 _ => return Err(CliError::NotAnObject),
523 };
524
525 let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
527 merged.extend(cli_non_null);
528 Ok(merged)
529}
530
531pub fn collect_input(
549 stdin_flag: Option<&str>,
550 cli_kwargs: HashMap<String, Value>,
551 large_input: bool,
552) -> Result<HashMap<String, Value>, CliError> {
553 match stdin_flag {
554 None | Some("") => {
555 collect_input_from_reader(None, cli_kwargs, large_input, std::io::stdin())
556 }
557 Some("-") => {
558 collect_input_from_reader(Some("-"), cli_kwargs, large_input, std::io::stdin())
559 }
560 Some(path) => {
561 let file = std::fs::File::open(path).map_err(|e| {
562 CliError::StdinRead(format!("cannot open input file '{}': {}", path, e))
563 })?;
564 collect_input_from_reader(Some("-"), cli_kwargs, large_input, file)
565 }
566 }
567}
568
569const MODULE_ID_MAX_LEN: usize = 192;
580
581pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
601 if module_id.len() > MODULE_ID_MAX_LEN {
602 return Err(CliError::InvalidModuleId(format!(
603 "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
604 )));
605 }
606 if !is_valid_module_id(module_id) {
607 return Err(CliError::InvalidModuleId(format!(
608 "Invalid module ID format: '{module_id}'."
609 )));
610 }
611 Ok(())
612}
613
614pub fn validate_module_id_or_exit(module_id: &str) {
624 if let Err(CliError::InvalidModuleId(msg)) = validate_module_id(module_id) {
625 eprintln!("Error: {msg}");
626 std::process::exit(crate::EXIT_INVALID_INPUT);
627 }
628}
629
630#[inline]
634fn is_valid_module_id(s: &str) -> bool {
635 if s.is_empty() {
636 return false;
637 }
638 for segment in s.split('.') {
640 if segment.is_empty() {
641 return false;
643 }
644 let mut chars = segment.chars();
645 match chars.next() {
647 Some(c) if c.is_ascii_lowercase() => {}
648 _ => return false,
649 }
650 for c in chars {
652 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
653 return false;
654 }
655 }
656 }
657 true
658}
659
660pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
675 use crate::{
676 EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_BIND_ERROR, EXIT_CONFIG_MOUNT_ERROR,
677 EXIT_CONFIG_NAMESPACE_RESERVED, EXIT_CONFIG_NOT_FOUND, EXIT_ERROR_FORMATTER_DUPLICATE,
678 EXIT_MODULE_EXECUTE_ERROR, EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF,
679 EXIT_SCHEMA_VALIDATION_ERROR,
680 };
681 match error_code {
682 "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
683 "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
684 "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
685 "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
686 "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
687 "ACL_DENIED" => EXIT_ACL_DENIED,
688 "CONFIG_NAMESPACE_RESERVED"
690 | "CONFIG_NAMESPACE_DUPLICATE"
691 | "CONFIG_ENV_PREFIX_CONFLICT"
692 | "CONFIG_ENV_MAP_CONFLICT" => EXIT_CONFIG_NAMESPACE_RESERVED,
693 "CONFIG_MOUNT_ERROR" => EXIT_CONFIG_MOUNT_ERROR,
694 "CONFIG_BIND_ERROR" => EXIT_CONFIG_BIND_ERROR,
695 "ERROR_FORMATTER_DUPLICATE" => EXIT_ERROR_FORMATTER_DUPLICATE,
696 _ => EXIT_MODULE_EXECUTE_ERROR,
697 }
698}
699
700pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
706 let code_str = serde_json::to_value(err.code)
708 .ok()
709 .and_then(|v| v.as_str().map(|s| s.to_string()))
710 .unwrap_or_default();
711 map_apcore_error_to_exit_code(&code_str)
712}
713
714pub(crate) fn validate_against_schema(
731 input: &HashMap<String, Value>,
732 schema: &Value,
733) -> Result<(), String> {
734 let mut instance =
735 serde_json::to_value(input).map_err(|e| format!("failed to serialize input: {e}"))?;
736
737 if let (Some(obj), Some(props)) = (
739 instance.as_object_mut(),
740 schema.get("properties").and_then(|p| p.as_object()),
741 ) {
742 for (key, prop) in props {
743 let type_str = match prop.get("type").and_then(|t| t.as_str()) {
744 Some(t) => t,
745 None => continue,
746 };
747 if let Some(Value::String(s)) = obj.get(key) {
748 let coerced = match type_str {
749 "integer" => s.parse::<i64>().ok().map(Value::from),
750 "number" => s.parse::<f64>().ok().map(Value::from),
751 _ => None,
752 };
753 if let Some(v) = coerced {
754 obj.insert(key.clone(), v);
755 }
756 }
757 }
758 }
759
760 let validator =
761 jsonschema::validator_for(schema).map_err(|e| format!("invalid schema: {e}"))?;
762
763 let errors: Vec<String> = validator
764 .iter_errors(&instance)
765 .map(|e| e.to_string())
766 .collect();
767
768 match errors.first() {
769 Some(msg) => Err(msg.clone()),
770 None => Ok(()),
771 }
772}
773
774fn emit_error_json(
788 _module_id: &str,
789 message: &str,
790 exit_code: i32,
791 error_data: Option<&serde_json::Value>,
792) {
793 let mut payload = serde_json::json!({
794 "error": true,
795 "code": "UNKNOWN",
796 "message": message,
797 "exit_code": exit_code,
798 });
799 if let Some(data) = error_data {
801 if let Some(obj) = data.as_object() {
802 for key in &[
803 "code",
804 "message",
805 "details",
806 "suggestion",
807 "ai_guidance",
808 "retryable",
809 "user_fixable",
810 ] {
811 if let Some(val) = obj.get(*key) {
812 if !val.is_null() {
813 payload[*key] = val.clone();
814 }
815 }
816 }
817 }
818 }
819 eprintln!("{}", serde_json::to_string(&payload).unwrap_or_default());
820}
821
822fn emit_error_tty(
827 _module_id: &str,
828 message: &str,
829 exit_code: i32,
830 error_data: Option<&serde_json::Value>,
831) {
832 if let Some(code) = error_data
834 .and_then(|d| d.get("code"))
835 .and_then(|v| v.as_str())
836 {
837 eprintln!("Error [{code}]: {message}");
838 } else {
839 eprintln!("Error: {message}");
840 }
841
842 if let Some(details) = error_data
844 .and_then(|d| d.get("details"))
845 .and_then(|v| v.as_object())
846 {
847 eprintln!("\n Details:");
848 for (k, v) in details {
849 eprintln!(" {k}: {v}");
850 }
851 }
852
853 if let Some(suggestion) = error_data
855 .and_then(|d| d.get("suggestion"))
856 .and_then(|v| v.as_str())
857 {
858 eprintln!("\n Suggestion: {suggestion}");
859 }
860
861 if let Some(retryable) = error_data
863 .and_then(|d| d.get("retryable"))
864 .and_then(|v| v.as_bool())
865 {
866 let label = if retryable {
867 "Yes"
868 } else {
869 "No (same input will fail again)"
870 };
871 eprintln!(" Retryable: {label}");
872 }
873
874 eprintln!("\n Exit code: {exit_code}");
875}
876
877pub fn reconcile_bool_pairs(
888 matches: &clap::ArgMatches,
889 bool_pairs: &[crate::schema_parser::BoolFlagPair],
890) -> HashMap<String, Value> {
891 let mut result = HashMap::new();
892 for pair in bool_pairs {
893 let pos_set = matches
896 .try_get_one::<bool>(&pair.prop_name)
897 .ok()
898 .flatten()
899 .copied()
900 .unwrap_or(false);
901 let neg_id = format!("no-{}", pair.prop_name);
902 let neg_set = matches
903 .try_get_one::<bool>(&neg_id)
904 .ok()
905 .flatten()
906 .copied()
907 .unwrap_or(false);
908 let val = if pos_set {
909 true
910 } else if neg_set {
911 false
912 } else {
913 pair.default_val
914 };
915 result.insert(pair.prop_name.clone(), Value::Bool(val));
916 }
917 result
918}
919
920fn extract_cli_kwargs(
925 matches: &clap::ArgMatches,
926 module_def: &apcore::registry::registry::ModuleDescriptor,
927) -> HashMap<String, Value> {
928 use crate::schema_parser::schema_to_clap_args;
929
930 let schema_args = match schema_to_clap_args(&module_def.input_schema, None) {
931 Ok(sa) => sa,
932 Err(_) => return HashMap::new(),
933 };
934
935 let mut kwargs: HashMap<String, Value> = HashMap::new();
936
937 for arg in &schema_args.args {
939 let id = arg.get_id().as_str().to_string();
940 if id.starts_with("no-") {
942 continue;
943 }
944 if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
947 kwargs.insert(id, Value::String(val.clone()));
948 } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
949 kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
950 } else {
951 kwargs.insert(id, Value::Null);
952 }
953 }
954
955 let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
957 kwargs.extend(bool_vals);
958
959 crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
961}
962
963async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
968 use tokio::io::AsyncWriteExt;
969
970 let mut child = tokio::process::Command::new(executable)
971 .stdin(std::process::Stdio::piped())
972 .stdout(std::process::Stdio::piped())
973 .stderr(std::process::Stdio::piped())
974 .kill_on_drop(true)
978 .spawn()
979 .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
980
981 if let Some(mut stdin) = child.stdin.take() {
983 let payload =
984 serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
985 stdin
986 .write_all(&payload)
987 .await
988 .map_err(|e| format!("failed to write to stdin: {e}"))?;
989 drop(stdin);
990 }
991
992 let output = child
993 .wait_with_output()
994 .await
995 .map_err(|e| format!("failed to read output: {e}"))?;
996
997 if !output.status.success() {
998 let code = output.status.code().unwrap_or(1);
999 let stderr_hint = String::from_utf8_lossy(&output.stderr);
1000 return Err(format!(
1001 "script exited with code {code}{}",
1002 if stderr_hint.is_empty() {
1003 String::new()
1004 } else {
1005 format!(": {}", stderr_hint.trim())
1006 }
1007 ));
1008 }
1009
1010 serde_json::from_slice(&output.stdout)
1011 .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1012}
1013
1014pub async fn dispatch_module(
1019 module_id: &str,
1020 matches: &clap::ArgMatches,
1021 registry: &Arc<dyn crate::discovery::RegistryProvider>,
1022 apcore_executor: &apcore::Executor,
1023) -> ! {
1024 use crate::{
1025 EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1026 EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1027 };
1028
1029 validate_module_id_or_exit(module_id);
1031
1032 let module_def = match registry.get_module_descriptor(module_id) {
1034 Some(def) => def,
1035 None => {
1036 eprintln!("Error: Module '{module_id}' not found in registry.");
1037 std::process::exit(EXIT_MODULE_NOT_FOUND);
1038 }
1039 };
1040
1041 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1043 let auto_approve = matches.get_flag("yes");
1044 let large_input = matches.get_flag("large-input");
1045 let format_flag = matches.get_one::<String>("format").cloned();
1046 let fields_flag = matches.get_one::<String>("fields").cloned();
1047 let dry_run = matches.get_flag("dry-run");
1048 let trace_flag = matches.get_flag("trace");
1049 let stream_flag = matches.get_flag("stream");
1050 let strategy_name = matches.get_one::<String>("strategy").cloned();
1051 let approval_timeout_arg = matches.get_one::<String>("approval-timeout").cloned();
1052 let approval_token = matches.get_one::<String>("approval-token").cloned();
1053
1054 let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1056
1057 let mut merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1059 Ok(m) => m,
1060 Err(CliError::InputTooLarge { .. }) => {
1061 eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1062 std::process::exit(EXIT_INVALID_INPUT);
1063 }
1064 Err(CliError::JsonParse(detail)) => {
1065 eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1066 std::process::exit(EXIT_INVALID_INPUT);
1067 }
1068 Err(CliError::NotAnObject) => {
1069 eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1070 std::process::exit(EXIT_INVALID_INPUT);
1071 }
1072 Err(e) => {
1073 eprintln!("Error: {e}");
1074 std::process::exit(EXIT_INVALID_INPUT);
1075 }
1076 };
1077
1078 if dry_run {
1080 let show_trace_preview = trace_flag;
1082 let print_pipeline_preview = || {
1083 if show_trace_preview {
1084 let pure_steps = [
1085 "context_creation",
1086 "call_chain_guard",
1087 "module_lookup",
1088 "acl_check",
1089 "input_validation",
1090 ];
1091 let all_steps = [
1092 "context_creation",
1093 "call_chain_guard",
1094 "module_lookup",
1095 "acl_check",
1096 "approval_gate",
1097 "middleware_before",
1098 "input_validation",
1099 "execute",
1100 "output_validation",
1101 "middleware_after",
1102 "return_result",
1103 ];
1104 eprintln!("\nPipeline preview (dry-run):");
1105 for s in &all_steps {
1106 if pure_steps.contains(s) {
1107 eprintln!(" v {:<24} (pure -- would execute)", s);
1108 } else {
1109 eprintln!(" o {:<24} (impure -- skipped in dry-run)", s);
1110 }
1111 }
1112 }
1113 };
1114 let input_value =
1115 serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1116
1117 let preflight =
1122 crate::validate::build_preflight_result(apcore_executor, &module_def, &input_value)
1123 .await;
1124 crate::validate::format_preflight_result(&preflight, format_flag.as_deref());
1125 print_pipeline_preview();
1126 let valid = preflight
1127 .get("valid")
1128 .and_then(|v| v.as_bool())
1129 .unwrap_or(false);
1130 if valid {
1131 std::process::exit(EXIT_SUCCESS);
1132 } else {
1133 let checks = preflight
1138 .get("checks")
1139 .and_then(|v| v.as_array())
1140 .cloned()
1141 .unwrap_or_default();
1142 std::process::exit(crate::validate::first_failed_exit_code(&checks));
1143 }
1144 }
1145
1146 if let Some(schema) = module_def.input_schema.as_object() {
1148 if schema.contains_key("properties") {
1149 if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1150 eprintln!("Error: Validation failed: {detail}.");
1151 std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1152 }
1153 }
1154 }
1155
1156 if let Some(ref token) = approval_token {
1158 merged.insert("_approval_token".to_string(), Value::String(token.clone()));
1159 }
1160
1161 let approval_timeout_secs = approval_timeout_arg
1166 .as_deref()
1167 .and_then(|s| s.parse::<u64>().ok())
1168 .or_else(|| {
1169 let resolver = crate::config::ConfigResolver::new(
1172 None,
1173 Some(std::path::PathBuf::from("apcore.yaml")),
1174 );
1175 resolver
1176 .resolve("cli.approval_timeout", None, None)
1177 .and_then(|s| s.parse::<u64>().ok())
1178 })
1179 .unwrap_or(crate::approval::DEFAULT_APPROVAL_TIMEOUT_SECS);
1180 let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1181 if let Err(e) = crate::approval::check_approval_with_timeout(
1182 &module_json,
1183 auto_approve,
1184 approval_timeout_secs,
1185 )
1186 .await
1187 {
1188 eprintln!("Error: {e}");
1189 std::process::exit(EXIT_APPROVAL_DENIED);
1190 }
1191
1192 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1194
1195 let use_sandbox = matches.get_flag("sandbox");
1197
1198 let script_executable = EXECUTABLES
1200 .get()
1201 .and_then(|map| map.get(module_id))
1202 .cloned();
1203
1204 if stream_flag {
1206 if format_flag.as_deref() == Some("table") {
1208 eprintln!("Warning: Streaming mode always outputs JSONL; --format table is ignored.");
1209 }
1210 let start = std::time::Instant::now();
1211 if let Some(exec_path) = script_executable.as_ref() {
1213 let res = tokio::select! {
1215 res = execute_script(exec_path, &input_value) => res,
1216 _ = tokio::signal::ctrl_c() => {
1217 eprintln!("Execution cancelled.");
1218 std::process::exit(EXIT_SIGINT);
1219 }
1220 };
1221 let duration_ms = start.elapsed().as_millis() as u64;
1222 match res {
1223 Ok(val) => {
1224 println!("{}", serde_json::to_string(&val).unwrap_or_default());
1225 audit_success(module_id, &input_value, duration_ms);
1226 std::process::exit(EXIT_SUCCESS);
1227 }
1228 Err(e) => {
1229 audit_error(
1230 module_id,
1231 &input_value,
1232 crate::EXIT_MODULE_EXECUTE_ERROR,
1233 duration_ms,
1234 );
1235 eprintln!("Error: {e}");
1236 std::process::exit(crate::EXIT_MODULE_EXECUTE_ERROR);
1237 }
1238 }
1239 }
1240 let res = tokio::select! {
1244 res = apcore_executor.call(
1245 module_id, input_value.clone(), None, None,
1246 ) => res,
1247 _ = tokio::signal::ctrl_c() => {
1248 eprintln!("Execution cancelled.");
1249 std::process::exit(EXIT_SIGINT);
1250 }
1251 };
1252 let duration_ms = start.elapsed().as_millis() as u64;
1253 match res {
1254 Ok(val) => {
1255 if let Some(arr) = val.as_array() {
1256 for item in arr {
1257 println!("{}", serde_json::to_string(item).unwrap_or_default());
1258 }
1259 } else {
1260 println!("{}", serde_json::to_string(&val).unwrap_or_default());
1261 }
1262 audit_success(module_id, &input_value, duration_ms);
1263 std::process::exit(EXIT_SUCCESS);
1264 }
1265 Err(e) => {
1266 let code = map_module_error_to_exit_code(&e);
1267 audit_error(module_id, &input_value, code, duration_ms);
1268 eprintln!("Error: Module '{module_id}' execution failed: {e}.");
1269 std::process::exit(code);
1270 }
1271 }
1272 }
1273
1274 if trace_flag {
1276 let start = std::time::Instant::now();
1277 let res = tokio::select! {
1281 res = apcore_executor.call(
1282 module_id,
1283 input_value.clone(),
1284 None,
1285 None,
1286 ) => res,
1287 _ = tokio::signal::ctrl_c() => {
1288 eprintln!("Execution cancelled.");
1289 std::process::exit(EXIT_SIGINT);
1290 }
1291 };
1292 let duration_ms = start.elapsed().as_millis() as u64;
1293 match res {
1294 Ok(output) => {
1295 let fmt = crate::output::resolve_format(format_flag.as_deref());
1297 if fmt == "json" {
1298 let trace_data = serde_json::json!({
1300 "strategy": strategy_name.as_deref().unwrap_or("standard"),
1301 "total_duration_ms": duration_ms,
1302 "success": true,
1303 });
1304 let combined = if output.is_object() {
1305 let mut obj = output.as_object().unwrap().clone();
1306 obj.insert("_trace".to_string(), trace_data);
1307 Value::Object(obj)
1308 } else {
1309 serde_json::json!({
1310 "result": output,
1311 "_trace": trace_data,
1312 })
1313 };
1314 println!(
1315 "{}",
1316 serde_json::to_string_pretty(&combined).unwrap_or_default()
1317 );
1318 } else {
1319 let out_str =
1320 crate::output::format_exec_result(&output, fmt, fields_flag.as_deref());
1321 println!("{out_str}");
1322 eprintln!(
1323 "\nPipeline Trace (strategy: {}, {duration_ms}ms)",
1324 strategy_name.as_deref().unwrap_or("standard"),
1325 );
1326 }
1327 audit_success(module_id, &input_value, duration_ms);
1329 std::process::exit(EXIT_SUCCESS);
1330 }
1331 Err(e) => {
1332 let code = map_module_error_to_exit_code(&e);
1333 audit_error(module_id, &input_value, code, duration_ms);
1334 eprintln!("Error: Module '{module_id}' execution failed: {e}.");
1335 std::process::exit(code);
1336 }
1337 }
1338 }
1339
1340 let start = std::time::Instant::now();
1342
1343 let result: Result<Value, (i32, String, Option<Value>)> =
1346 if let Some(exec_path) = script_executable {
1347 tokio::select! {
1349 res = execute_script(&exec_path, &input_value) => {
1350 res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e, None))
1351 }
1352 _ = tokio::signal::ctrl_c() => {
1353 eprintln!("Execution cancelled.");
1354 std::process::exit(EXIT_SIGINT);
1355 }
1356 }
1357 } else if use_sandbox {
1358 let sandbox = crate::security::Sandbox::new(true, 0);
1359 tokio::select! {
1360 res = sandbox.execute(module_id, input_value.clone(), apcore_executor) => {
1361 res.map_err(|e| {
1362 match &e {
1368 crate::security::ModuleExecutionError::ModuleError(inner) => {
1369 let code = map_module_error_to_exit_code(inner);
1370 let data = serde_json::to_value(inner).ok();
1371 (code, e.to_string(), data)
1372 }
1373 _ => (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string(), None),
1374 }
1375 })
1376 }
1377 _ = tokio::signal::ctrl_c() => {
1378 eprintln!("Execution cancelled.");
1379 std::process::exit(EXIT_SIGINT);
1380 }
1381 }
1382 } else {
1383 tokio::select! {
1388 res = apcore_executor.call(
1389 module_id,
1390 input_value.clone(),
1391 None,
1392 None,
1393 ) => {
1394 res.map_err(|e| {
1395 let code = map_module_error_to_exit_code(&e);
1396 let data = serde_json::to_value(&e).ok();
1398 (code, e.to_string(), data)
1399 })
1400 }
1401 _ = tokio::signal::ctrl_c() => {
1402 eprintln!("Execution cancelled.");
1403 std::process::exit(EXIT_SIGINT);
1404 }
1405 }
1406 };
1407
1408 let duration_ms = start.elapsed().as_millis() as u64;
1409
1410 match result {
1411 Ok(output) => {
1412 let fmt = crate::output::resolve_format(format_flag.as_deref());
1414 println!(
1415 "{}",
1416 crate::output::format_exec_result(&output, fmt, fields_flag.as_deref(),)
1417 );
1418 audit_success(module_id, &input_value, duration_ms);
1420 std::process::exit(EXIT_SUCCESS);
1421 }
1422 Err((exit_code, msg, error_data)) => {
1423 audit_error(module_id, &input_value, exit_code, duration_ms);
1425 if format_flag.as_deref() == Some("json") || !std::io::stderr().is_terminal() {
1427 emit_error_json(module_id, &msg, exit_code, error_data.as_ref());
1428 } else {
1429 emit_error_tty(module_id, &msg, exit_code, error_data.as_ref());
1430 }
1431 std::process::exit(exit_code);
1432 }
1433 }
1434}
1435
1436#[cfg(test)]
1441mod tests {
1442 use super::*;
1443
1444 #[test]
1445 fn test_validate_module_id_valid() {
1446 for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1448 let result = validate_module_id(id);
1449 assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1450 }
1451 }
1452
1453 #[test]
1454 fn test_validate_module_id_too_long() {
1455 let long_id = "a".repeat(193);
1457 assert!(validate_module_id(&long_id).is_err());
1458 }
1459
1460 #[test]
1461 fn test_validate_module_id_invalid_format() {
1462 for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1463 assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1464 }
1465 }
1466
1467 #[test]
1468 fn test_validate_module_id_max_length() {
1469 let max_id = "a".repeat(192);
1471 assert!(validate_module_id(&max_id).is_ok());
1472 }
1473
1474 #[test]
1475 fn test_validate_module_id_over_max_length_message() {
1476 let overlong = "a".repeat(193);
1477 let err = validate_module_id(&overlong).expect_err("expected length error");
1478 assert!(format!("{err:?}").contains("Maximum length"));
1479 }
1480
1481 #[test]
1484 fn test_collect_input_no_stdin_drops_null_values() {
1485 use serde_json::json;
1486 let mut kwargs = HashMap::new();
1487 kwargs.insert("a".to_string(), json!(5));
1488 kwargs.insert("b".to_string(), Value::Null);
1489
1490 let result = collect_input(None, kwargs, false).unwrap();
1491 assert_eq!(result.get("a"), Some(&json!(5)));
1492 assert!(!result.contains_key("b"), "Null values must be dropped");
1493 }
1494
1495 #[test]
1496 fn test_collect_input_stdin_valid_json() {
1497 use serde_json::json;
1498 use std::io::Cursor;
1499 let stdin_bytes = b"{\"x\": 42}";
1500 let reader = Cursor::new(stdin_bytes.to_vec());
1501 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1502 assert_eq!(result.get("x"), Some(&json!(42)));
1503 }
1504
1505 #[test]
1506 fn test_collect_input_cli_overrides_stdin() {
1507 use serde_json::json;
1508 use std::io::Cursor;
1509 let stdin_bytes = b"{\"a\": 5}";
1510 let reader = Cursor::new(stdin_bytes.to_vec());
1511 let mut kwargs = HashMap::new();
1512 kwargs.insert("a".to_string(), json!(99));
1513 let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1514 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1515 }
1516
1517 #[test]
1518 fn test_collect_input_oversized_stdin_rejected() {
1519 use std::io::Cursor;
1520 let big = vec![b' '; 10 * 1024 * 1024 + 1];
1521 let reader = Cursor::new(big);
1522 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1523 assert!(matches!(err, CliError::InputTooLarge { .. }));
1524 }
1525
1526 #[test]
1527 fn test_collect_input_large_input_allowed() {
1528 use std::io::Cursor;
1529 let mut payload = b"{\"k\": \"".to_vec();
1530 payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1531 payload.extend(b"\"}");
1532 let reader = Cursor::new(payload);
1533 let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1534 assert!(
1535 result.is_ok(),
1536 "large_input=true must accept oversized payload"
1537 );
1538 }
1539
1540 #[test]
1541 fn test_collect_input_invalid_json_returns_error() {
1542 use std::io::Cursor;
1543 let reader = Cursor::new(b"not json at all".to_vec());
1544 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1545 assert!(matches!(err, CliError::JsonParse(_)));
1546 }
1547
1548 #[test]
1549 fn test_collect_input_non_object_json_returns_error() {
1550 use std::io::Cursor;
1551 let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1552 let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1553 assert!(matches!(err, CliError::NotAnObject));
1554 }
1555
1556 #[test]
1557 fn test_collect_input_empty_stdin_returns_empty_map() {
1558 use std::io::Cursor;
1559 let reader = Cursor::new(b"".to_vec());
1560 let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1561 assert!(result.is_empty());
1562 }
1563
1564 #[test]
1565 fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1566 use serde_json::json;
1567 let mut kwargs = HashMap::new();
1568 kwargs.insert("foo".to_string(), json!("bar"));
1569 let result = collect_input(None, kwargs.clone(), false).unwrap();
1570 assert_eq!(result.get("foo"), Some(&json!("bar")));
1571 }
1572
1573 #[test]
1574 fn test_collect_input_file_path_reads_json() {
1575 use serde_json::json;
1576 use std::io::Write;
1577 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1578 write!(tmp, r#"{{"port": 8080}}"#).unwrap();
1579 let path = tmp.path().to_str().unwrap().to_string();
1580 let result = collect_input(Some(&path), HashMap::new(), false).unwrap();
1581 assert_eq!(result.get("port"), Some(&json!(8080)));
1582 }
1583
1584 #[test]
1585 fn test_collect_input_file_path_cli_overrides_file() {
1586 use serde_json::json;
1587 use std::io::Write;
1588 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1589 write!(tmp, r#"{{"a": 1, "b": 2}}"#).unwrap();
1590 let path = tmp.path().to_str().unwrap().to_string();
1591 let mut kwargs = HashMap::new();
1592 kwargs.insert("a".to_string(), json!(99));
1593 let result = collect_input(Some(&path), kwargs, false).unwrap();
1594 assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override file");
1595 assert_eq!(result.get("b"), Some(&json!(2)));
1596 }
1597
1598 #[test]
1599 fn test_collect_input_file_path_missing_returns_error() {
1600 let err =
1601 collect_input(Some("/nonexistent/path/data.json"), HashMap::new(), false).unwrap_err();
1602 assert!(matches!(err, CliError::StdinRead(_)));
1603 }
1604
1605 fn make_module_descriptor(
1613 name: &str,
1614 description: &str,
1615 schema: Option<serde_json::Value>,
1616 ) -> apcore::registry::registry::ModuleDescriptor {
1617 apcore::registry::registry::ModuleDescriptor {
1618 module_id: name.to_string(),
1619 name: None,
1620 description: description.to_string(),
1621 documentation: None,
1622 input_schema: schema.unwrap_or(serde_json::Value::Null),
1623 output_schema: serde_json::Value::Object(Default::default()),
1624 version: "1.0.0".to_string(),
1625 tags: vec![],
1626 annotations: Some(apcore::module::ModuleAnnotations::default()),
1627 examples: vec![],
1628 metadata: std::collections::HashMap::new(),
1629 display: None,
1630 sunset_date: None,
1631 dependencies: vec![],
1632 enabled: true,
1633 }
1634 }
1635
1636 #[test]
1637 fn test_build_module_command_name_is_set() {
1638 let module = make_module_descriptor("math.add", "Add two numbers", None);
1639 let cmd = build_module_command(&module).unwrap();
1640 assert_eq!(cmd.get_name(), "math.add");
1641 }
1642
1643 #[test]
1644 fn test_build_module_command_has_input_flag() {
1645 let module = make_module_descriptor("a.b", "desc", None);
1646 let cmd = build_module_command(&module).unwrap();
1647 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1648 assert!(names.contains(&"input"), "must have --input flag");
1649 }
1650
1651 #[test]
1652 fn test_build_module_command_has_yes_flag() {
1653 let module = make_module_descriptor("a.b", "desc", None);
1654 let cmd = build_module_command(&module).unwrap();
1655 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1656 assert!(names.contains(&"yes"), "must have --yes flag");
1657 }
1658
1659 #[test]
1660 fn test_build_module_command_has_large_input_flag() {
1661 let module = make_module_descriptor("a.b", "desc", None);
1662 let cmd = build_module_command(&module).unwrap();
1663 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1664 assert!(
1665 names.contains(&"large-input"),
1666 "must have --large-input flag"
1667 );
1668 }
1669
1670 #[test]
1671 fn test_build_module_command_has_format_flag() {
1672 let module = make_module_descriptor("a.b", "desc", None);
1673 let cmd = build_module_command(&module).unwrap();
1674 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1675 assert!(names.contains(&"format"), "must have --format flag");
1676 }
1677
1678 #[test]
1679 fn test_build_module_command_has_sandbox_flag() {
1680 let module = make_module_descriptor("a.b", "desc", None);
1681 let cmd = build_module_command(&module).unwrap();
1682 let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1683 assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1684 }
1685
1686 #[test]
1687 fn test_build_module_command_reserved_name_returns_error() {
1688 for reserved in crate::builtin_group::RESERVED_GROUP_NAMES {
1692 let module = make_module_descriptor(reserved, "desc", None);
1693 let result = build_module_command(&module);
1694 assert!(
1695 matches!(result, Err(CliError::ReservedModuleId(_))),
1696 "expected ReservedModuleId for '{reserved}', got {result:?}"
1697 );
1698 }
1699 }
1700
1701 #[test]
1702 fn test_build_module_command_former_builtin_names_allowed() {
1703 for name in &["list", "describe", "exec", "init", "health", "config"] {
1706 let module = make_module_descriptor(name, "desc", None);
1707 let result = build_module_command(&module);
1708 assert!(
1709 result.is_ok(),
1710 "former built-in '{name}' should no longer be reserved; got {result:?}"
1711 );
1712 }
1713 }
1714
1715 #[test]
1716 fn test_build_module_command_yes_has_short_flag() {
1717 let module = make_module_descriptor("a.b", "desc", None);
1718 let cmd = build_module_command(&module).unwrap();
1719 let has_short_y = cmd
1720 .get_opts()
1721 .filter(|a| a.get_long() == Some("yes"))
1722 .any(|a| a.get_short() == Some('y'));
1723 assert!(has_short_y, "--yes must have short flag -y");
1724 }
1725
1726 #[test]
1731 fn test_reserved_group_names_single_entry() {
1732 assert_eq!(crate::builtin_group::RESERVED_GROUP_NAMES, &["apcli"]);
1735 }
1736
1737 #[test]
1738 fn test_apcli_subcommand_names_matches_spec() {
1739 let expected: &[&str] = &[
1741 "list",
1742 "describe",
1743 "exec",
1744 "validate",
1745 "init",
1746 "health",
1747 "usage",
1748 "enable",
1749 "disable",
1750 "reload",
1751 "config",
1752 "completion",
1753 "describe-pipeline",
1754 ];
1755 assert_eq!(crate::builtin_group::APCLI_SUBCOMMAND_NAMES, expected);
1756 }
1757
1758 #[test]
1763 fn test_map_error_module_not_found_is_44() {
1764 assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1765 }
1766
1767 #[test]
1768 fn test_map_error_module_load_error_is_44() {
1769 assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1770 }
1771
1772 #[test]
1773 fn test_map_error_module_disabled_is_44() {
1774 assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1775 }
1776
1777 #[test]
1778 fn test_map_error_schema_validation_error_is_45() {
1779 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1780 }
1781
1782 #[test]
1783 fn test_map_error_approval_denied_is_46() {
1784 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1785 }
1786
1787 #[test]
1788 fn test_map_error_approval_timeout_is_46() {
1789 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1790 }
1791
1792 #[test]
1793 fn test_map_error_approval_pending_is_46() {
1794 assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1795 }
1796
1797 #[test]
1798 fn test_map_error_config_not_found_is_47() {
1799 assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1800 }
1801
1802 #[test]
1803 fn test_map_error_config_invalid_is_47() {
1804 assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1805 }
1806
1807 #[test]
1808 fn test_map_error_schema_circular_ref_is_48() {
1809 assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1810 }
1811
1812 #[test]
1813 fn test_map_error_acl_denied_is_77() {
1814 assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1815 }
1816
1817 #[test]
1818 fn test_map_error_module_execute_error_is_1() {
1819 assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1820 }
1821
1822 #[test]
1823 fn test_map_error_module_timeout_is_1() {
1824 assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1825 }
1826
1827 #[test]
1828 fn test_map_error_unknown_is_1() {
1829 assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1830 }
1831
1832 #[test]
1833 fn test_map_error_empty_string_is_1() {
1834 assert_eq!(map_apcore_error_to_exit_code(""), 1);
1835 }
1836
1837 #[test]
1842 fn test_set_audit_logger_none_clears_logger() {
1843 set_audit_logger(None);
1845 let guard = AUDIT_LOGGER.lock().unwrap();
1846 assert!(guard.is_none(), "setting None must clear the audit logger");
1847 }
1848
1849 #[test]
1850 fn test_set_audit_logger_some_stores_logger() {
1851 use crate::security::AuditLogger;
1852 set_audit_logger(Some(AuditLogger::new(None)));
1853 let guard = AUDIT_LOGGER.lock().unwrap();
1854 assert!(guard.is_some(), "setting Some must store the audit logger");
1855 drop(guard);
1857 set_audit_logger(None);
1858 }
1859
1860 #[test]
1865 fn test_validate_against_schema_passes_with_no_properties() {
1866 let schema = serde_json::json!({});
1867 let input = std::collections::HashMap::new();
1868 let result = validate_against_schema(&input, &schema);
1870 assert!(result.is_ok(), "empty schema must pass: {result:?}");
1871 }
1872
1873 #[test]
1874 fn test_validate_against_schema_required_field_missing_fails() {
1875 let schema = serde_json::json!({
1876 "properties": {
1877 "a": {"type": "integer"}
1878 },
1879 "required": ["a"]
1880 });
1881 let input: std::collections::HashMap<String, serde_json::Value> =
1882 std::collections::HashMap::new();
1883 let result = validate_against_schema(&input, &schema);
1884 assert!(result.is_err(), "missing required field must fail");
1885 }
1886
1887 #[test]
1888 fn test_validate_against_schema_required_field_present_passes() {
1889 let schema = serde_json::json!({
1890 "properties": {
1891 "a": {"type": "integer"}
1892 },
1893 "required": ["a"]
1894 });
1895 let mut input = std::collections::HashMap::new();
1896 input.insert("a".to_string(), serde_json::json!(42));
1897 let result = validate_against_schema(&input, &schema);
1898 assert!(
1899 result.is_ok(),
1900 "present required field must pass: {result:?}"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_validate_against_schema_no_required_any_input_passes() {
1906 let schema = serde_json::json!({
1907 "properties": {
1908 "x": {"type": "string"}
1909 }
1910 });
1911 let input: std::collections::HashMap<String, serde_json::Value> =
1912 std::collections::HashMap::new();
1913 let result = validate_against_schema(&input, &schema);
1914 assert!(result.is_ok(), "no required fields: empty input must pass");
1915 }
1916
1917 #[test]
1918 fn test_validate_against_schema_type_mismatch_fails() {
1919 let schema = serde_json::json!({
1920 "properties": {
1921 "port": {"type": "integer"}
1922 },
1923 "required": ["port"]
1924 });
1925 let mut input = std::collections::HashMap::new();
1926 input.insert("port".to_string(), serde_json::json!("not_a_number"));
1927 let result = validate_against_schema(&input, &schema);
1928 assert!(result.is_err(), "type mismatch must fail validation");
1929 }
1930
1931 #[test]
1932 fn test_validate_against_schema_enum_violation_fails() {
1933 let schema = serde_json::json!({
1934 "properties": {
1935 "mode": {"type": "string", "enum": ["read", "write"]}
1936 },
1937 "required": ["mode"]
1938 });
1939 let mut input = std::collections::HashMap::new();
1940 input.insert("mode".to_string(), serde_json::json!("delete"));
1941 let result = validate_against_schema(&input, &schema);
1942 assert!(result.is_err(), "enum violation must fail validation");
1943 }
1944
1945 }