Skip to main content

apcore_cli/
cli.rs

1// apcore-cli — Core CLI dispatcher.
2// Protocol spec: FE-01 (LazyModuleGroup equivalent, build_module_command,
3//                        collect_input, validate_module_id, set_audit_logger)
4
5use 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
15// ---------------------------------------------------------------------------
16// Local trait abstractions for Executor
17// ---------------------------------------------------------------------------
18// apcore::Executor is a concrete struct, not a trait.
19// This local trait allows LazyModuleGroup to be generic over both the real
20// implementation and test mocks without depending on apcore internals.
21// Registry access uses the unified `discovery::RegistryProvider` trait.
22
23/// Minimal executor interface required by LazyModuleGroup.
24pub trait ModuleExecutor: Send + Sync {}
25
26/// Adapter that implements ModuleExecutor for the real apcore::Executor.
27pub struct ApCoreExecutorAdapter(pub apcore::Executor);
28
29impl ModuleExecutor for ApCoreExecutorAdapter {}
30
31// ---------------------------------------------------------------------------
32// Error type
33// ---------------------------------------------------------------------------
34
35/// Errors produced by CLI dispatch operations.
36#[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
57// ---------------------------------------------------------------------------
58// Global audit logger (module-level singleton, set once at startup)
59// ---------------------------------------------------------------------------
60
61static AUDIT_LOGGER: Mutex<Option<AuditLogger>> = Mutex::new(None);
62
63// ---------------------------------------------------------------------------
64// Global executable map (module name -> script path, set once at startup)
65// ---------------------------------------------------------------------------
66
67static EXECUTABLES: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
68
69/// Store the executable map built during module discovery.
70///
71/// Must be called once before any `dispatch_module` invocation.
72pub fn set_executables(map: HashMap<String, PathBuf>) {
73    let _ = EXECUTABLES.set(map);
74}
75
76/// Set (or clear) the global audit logger used by all module commands.
77///
78/// Pass `None` to disable auditing. Typically called once during CLI
79/// initialisation, before any commands are dispatched.
80pub 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
91// ---------------------------------------------------------------------------
92// exec_command — clap subcommand builder for `exec`
93// ---------------------------------------------------------------------------
94
95/// Add the standard dispatch flags (--input, --yes, --large-input, --format,
96/// --sandbox) to a clap Command. Used by both `exec_command()` and the external
97/// subcommand re-parser in main.rs.
98pub 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
133/// Build the `exec` clap subcommand.
134///
135/// `exec` runs an apcore module by its fully-qualified module ID.
136pub 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
148// ---------------------------------------------------------------------------
149// LazyModuleGroup — lazy command builder
150// ---------------------------------------------------------------------------
151
152/// Built-in command names that are always present regardless of the registry.
153pub const BUILTIN_COMMANDS: &[&str] = &["completion", "describe", "exec", "list", "man"];
154
155/// Lazy command registry: builds module subcommands on-demand from the
156/// apcore Registry, caching them after first construction.
157///
158/// This is the Rust equivalent of the Python `LazyModuleGroup` (Click group
159/// subclass with lazy `get_command` / `list_commands`).
160pub struct LazyModuleGroup {
161    registry: Arc<dyn crate::discovery::RegistryProvider>,
162    #[allow(dead_code)]
163    executor: Arc<dyn ModuleExecutor>,
164    /// Cache of module name -> name string (we store the name, not the Command,
165    /// since clap::Command is not Clone in all configurations).
166    module_cache: HashMap<String, bool>,
167    /// Count of registry descriptor lookups (test instrumentation only).
168    #[cfg(test)]
169    pub registry_lookup_count: usize,
170}
171
172impl LazyModuleGroup {
173    /// Create a new lazy module group.
174    ///
175    /// # Arguments
176    /// * `registry` — module registry (real or mock)
177    /// * `executor` — module executor (real or mock)
178    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    /// Return sorted list of all command names: built-ins + module ids.
192    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        // Sort and dedup in one pass.
196        names.sort_unstable();
197        names.dedup();
198        names
199    }
200
201    /// Look up a command by name. Returns `None` if the name is not a builtin
202    /// and is not found in the registry.
203    ///
204    /// For module commands, builds and caches a lightweight clap Command.
205    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        // Check the in-memory cache first.
210        if self.module_cache.contains_key(name) {
211            return Some(clap::Command::new(name.to_string()));
212        }
213        // Registry lookup.
214        #[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    /// Return the number of times the registry was queried for a descriptor.
226    /// Available in test builds only.
227    #[cfg(test)]
228    pub fn registry_lookup_count(&self) -> usize {
229        self.registry_lookup_count
230    }
231}
232
233// ---------------------------------------------------------------------------
234// build_module_command
235// ---------------------------------------------------------------------------
236
237/// Built-in flag names added to every generated module command. A schema
238/// property that collides with one of these names will cause
239/// `std::process::exit(2)`.
240const RESERVED_FLAG_NAMES: &[&str] = &["input", "yes", "large-input", "format", "sandbox"];
241
242/// Build a clap `Command` for a single module definition.
243///
244/// The resulting subcommand has:
245/// * its `name` set to `module_def.name`
246/// * its `about` derived from the module descriptor (empty if unavailable)
247/// * the 5 built-in flags: `--input`, `--yes`/`-y`, `--large-input`,
248///   `--format`, `--sandbox`
249/// * schema-derived flags from `schema_to_clap_args` (stub: empty vec)
250///
251/// `executor` is accepted for API symmetry with the Python counterpart but is
252/// not embedded in the `clap::Command` (clap has no user-data attachment).
253/// The executor is passed separately to the dispatch callback.
254///
255/// # Errors
256/// Returns `CliError::ReservedModuleId` when `module_def.name` is one of the
257/// reserved built-in command names.
258pub 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
269/// Build a clap `Command` for a single module definition with a configurable
270/// help text max length.
271pub 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    // Guard: reject reserved command names immediately.
279    if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
280        return Err(CliError::ReservedModuleId(module_id.clone()));
281    }
282
283    // Resolve $ref pointers in the input schema before generating clap args.
284    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    // Build clap args from JSON Schema properties.
289    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    // Check for schema property names that collide with built-in flags.
296    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    // Suppress unused-variable warning; executor is kept for API symmetry.
308    let _ = executor;
309
310    let mut cmd = clap::Command::new(module_id.clone())
311        // Built-in flags present on every generated command.
312        .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    // Attach schema-derived args.
345    for arg in schema_args.args {
346        cmd = cmd.arg(arg);
347    }
348
349    Ok(cmd)
350}
351
352// ---------------------------------------------------------------------------
353// collect_input
354// ---------------------------------------------------------------------------
355
356const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
357
358/// Inner implementation: accepts any `Read` source for testability.
359///
360/// # Arguments
361/// * `stdin_flag`  — `Some("-")` to read from `reader`, anything else skips STDIN
362/// * `cli_kwargs`  — map of flag name → value (`Null` values are dropped)
363/// * `large_input` — if `false`, reject payloads exceeding `STDIN_SIZE_LIMIT_BYTES`
364/// * `reader`      — byte source to read from when `stdin_flag == Some("-")`
365///
366/// # Errors
367/// Returns `CliError` on oversized input, invalid JSON, or non-object JSON.
368pub 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    // Drop Null values from CLI kwargs.
375    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    // Merge: STDIN base, CLI kwargs override on collision.
409    let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
410    merged.extend(cli_non_null);
411    Ok(merged)
412}
413
414/// Merge CLI keyword arguments with optional STDIN JSON.
415///
416/// Resolution order (highest priority first):
417/// 1. CLI flags (non-`Null` values in `cli_kwargs`)
418/// 2. STDIN JSON (when `stdin_flag` is `Some("-")`)
419///
420/// # Arguments
421/// * `stdin_flag`  — `Some("-")` to read from STDIN, `None` to skip
422/// * `cli_kwargs`  — map of flag name → value (`Null` values are ignored)
423/// * `large_input` — if `false`, reject STDIN payloads exceeding 10 MiB
424///
425/// # Errors
426/// Returns `CliError` (exit code 2) on oversized input, invalid JSON, or
427/// non-object JSON.
428pub 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
436// ---------------------------------------------------------------------------
437// validate_module_id
438// ---------------------------------------------------------------------------
439
440const MODULE_ID_MAX_LEN: usize = 128;
441
442/// Validate a module identifier.
443///
444/// # Rules
445/// * Maximum 128 characters
446/// * Matches `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`
447/// * No leading/trailing dots, no consecutive dots
448/// * Must not start with a digit or uppercase letter
449///
450/// # Errors
451/// Returns `CliError::InvalidModuleId` (exit code 2) on any violation.
452pub 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/// Hand-written validator matching `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`.
467///
468/// Does not require the `regex` crate.
469#[inline]
470fn is_valid_module_id(s: &str) -> bool {
471    if s.is_empty() {
472        return false;
473    }
474    // Split on '.' and validate each segment individually.
475    for segment in s.split('.') {
476        if segment.is_empty() {
477            // Catches leading dot, trailing dot, and consecutive dots.
478            return false;
479        }
480        let mut chars = segment.chars();
481        // First character must be a lowercase ASCII letter.
482        match chars.next() {
483            Some(c) if c.is_ascii_lowercase() => {}
484            _ => return false,
485        }
486        // Remaining characters: lowercase letter, ASCII digit, or underscore.
487        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
496// ---------------------------------------------------------------------------
497// Error code mapping
498// ---------------------------------------------------------------------------
499
500/// Map an apcore error code string to the appropriate CLI exit code.
501///
502/// Exit code table:
503/// * `MODULE_NOT_FOUND` / `MODULE_LOAD_ERROR` / `MODULE_DISABLED` → 44
504/// * `SCHEMA_VALIDATION_ERROR`                                     → 45
505/// * `APPROVAL_DENIED` / `APPROVAL_TIMEOUT` / `APPROVAL_PENDING`  → 46
506/// * `CONFIG_NOT_FOUND` / `CONFIG_INVALID`                         → 47
507/// * `SCHEMA_CIRCULAR_REF`                                         → 48
508/// * `ACL_DENIED`                                                  → 77
509/// * everything else (including `MODULE_EXECUTE_ERROR` / `MODULE_TIMEOUT`) → 1
510pub(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
526/// Map an `apcore::errors::ModuleError` directly to an exit code.
527///
528/// Converts the `ErrorCode` enum variant to its SCREAMING_SNAKE_CASE
529/// representation via serde JSON serialisation and delegates to
530/// `map_apcore_error_to_exit_code`.
531pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
532    // Serialise the ErrorCode enum to its SCREAMING_SNAKE_CASE string.
533    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
540// ---------------------------------------------------------------------------
541// Schema validation helper
542// ---------------------------------------------------------------------------
543
544/// Validate `input` against a JSON Schema object.
545///
546/// This is a lightweight inline checker sufficient until `jsonschema` crate
547/// integration lands (FE-08).  It enforces the `required` array only — if
548/// every field listed in `required` is present in `input`, the call succeeds.
549///
550/// # Errors
551/// Returns `Err(String)` describing the first missing required field.
552pub(crate) fn validate_against_schema(
553    input: &HashMap<String, Value>,
554    schema: &Value,
555) -> Result<(), String> {
556    // Extract "required" array if present.
557    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
571// ---------------------------------------------------------------------------
572// dispatch_module — full execution pipeline
573// ---------------------------------------------------------------------------
574
575/// Reconcile --flag / --no-flag boolean pairs from ArgMatches into bool values.
576///
577/// For each BoolFlagPair:
578/// - If --flag was set  → prop_name = true
579/// - If --no-flag set   → prop_name = false
580/// - If neither         → prop_name = default_val
581pub 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        // Use try_get_one to avoid panicking when the flag doesn't exist
588        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
589        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
614/// Extract schema-derived CLI kwargs from `ArgMatches` for a given module.
615///
616/// Iterates schema properties and extracts string values from clap matches.
617/// Boolean pairs are handled separately via `reconcile_bool_pairs`.
618fn 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    // Extract non-boolean schema args as strings (or Null if absent).
632    for arg in &schema_args.args {
633        let id = arg.get_id().as_str().to_string();
634        // Skip the no- counterparts of boolean args.
635        if id.starts_with("no-") {
636            continue;
637        }
638        // Use try_get_one to avoid panicking when the arg doesn't exist
639        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
640        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    // Reconcile boolean pairs.
650    let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
651    kwargs.extend(bool_vals);
652
653    // Apply enum type reconversion.
654    crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
655}
656
657/// Execute a script-based module by spawning the executable as a subprocess.
658///
659/// JSON input is written to stdin; JSON output is read from stdout.
660/// Stderr is captured and included in error messages on failure.
661async 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    // Write JSON input to child stdin then close it.
672    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
704/// Execute a module by ID: validate → collect input → validate schema
705/// → approve → execute → audit → output.
706///
707/// Calls `std::process::exit` with the appropriate code; never returns normally.
708pub 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    // 1. Validate module ID format (exit 2 on bad format).
721    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    // 2. Registry lookup (exit 44 if not found).
728    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    // 3. Extract built-in flags from matches.
737    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    // 4. Build CLI kwargs from schema-derived flags (stub: empty map).
743    let cli_kwargs = extract_cli_kwargs(matches, &module_def);
744
745    // 5. Collect and merge input (exit 2 on errors).
746    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    // 6. Schema validation (if module has input_schema with properties).
767    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    // 7. Approval gate (exit 46 on denial/timeout).
777    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    // 8. Build merged input as serde_json::Value.
784    let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
785
786    // Determine sandbox flag.
787    let use_sandbox = matches.get_flag("sandbox");
788
789    // Check if this module has a script-based executable.
790    let script_executable = EXECUTABLES
791        .get()
792        .and_then(|map| map.get(module_id))
793        .cloned();
794
795    // 9. Execute with SIGINT race (exit 130 on Ctrl-C).
796    let start = std::time::Instant::now();
797
798    // Unify the execution paths into Result<Value, (i32, String)> where
799    // the error tuple is (exit_code, display_message).
800    let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
801        // Script-based execution: spawn subprocess, pipe JSON via stdin/stdout.
802        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        // Direct in-process executor call (4-argument signature).
824        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            // 10. Audit log success.
843            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            // 11. Format and output.
849            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            // Audit log error.
855            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// ---------------------------------------------------------------------------
867// Unit tests
868// ---------------------------------------------------------------------------
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn test_validate_module_id_valid() {
876        // Valid IDs must not return an error.
877        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    // collect_input tests (TDD red → green)
903
904    #[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    // ---------------------------------------------------------------------------
995    // build_module_command tests (TDD — RED written before GREEN)
996    // ---------------------------------------------------------------------------
997
998    /// Construct a minimal `ModuleDescriptor` for use in `build_module_command`
999    /// tests. `input_schema` defaults to a JSON null (no properties) when
1000    /// `schema` is `None`.
1001    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    // ---------------------------------------------------------------------------
1099    // LazyModuleGroup tests (TDD)
1100    // ---------------------------------------------------------------------------
1101
1102    /// Mock registry that returns a fixed list of module names.
1103    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    /// Mock registry that returns an empty list (simulates unavailable registry).
1149    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    /// Mock executor (no-op).
1162    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        // Result must be sorted.
1187        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        // Must not be empty; must contain builtins.
1208        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        // First call builds and caches.
1230        let cmd1 = group.get_command("math.add");
1231        assert!(cmd1.is_some());
1232        // Second call returns from cache — registry lookup should not be called again.
1233        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        // BUILTIN_COMMANDS slice must itself be in sorted order (single source of truth).
1245        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        // If a registry module name collides with a builtin, the result must not
1257        // contain duplicates.
1258        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    // ---------------------------------------------------------------------------
1265    // map_apcore_error_to_exit_code tests (RED — written before implementation)
1266    // ---------------------------------------------------------------------------
1267
1268    #[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    // ---------------------------------------------------------------------------
1344    // set_audit_logger implementation tests (RED)
1345    // ---------------------------------------------------------------------------
1346
1347    #[test]
1348    fn test_set_audit_logger_none_clears_logger() {
1349        // Setting None must not panic and must leave AUDIT_LOGGER as None.
1350        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        // Clean up.
1362        drop(guard);
1363        set_audit_logger(None);
1364    }
1365
1366    // ---------------------------------------------------------------------------
1367    // validate_against_schema tests (RED)
1368    // ---------------------------------------------------------------------------
1369
1370    #[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        // Schema without properties must not fail.
1375        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}