Skip to main content

apcore_cli/
cli.rs

1// apcore-cli — Core CLI dispatcher.
2// Protocol spec: FE-01 (build_module_command, collect_input,
3//                        validate_module_id, set_audit_logger, dispatch_module)
4
5use 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// NOTE: LazyModuleGroup, GroupedModuleGroup, ModuleExecutor trait, and
17// ApCoreExecutorAdapter were deleted per audit findings D9-001..004. They
18// were Python Click-hierarchy ports that did not fit clap's model. Actual
19// dispatch is handled directly by dispatch_module() below; the dispatcher
20// calls the concrete `apcore::Executor` without a trait-object indirection.
21
22// ---------------------------------------------------------------------------
23// Error type
24// ---------------------------------------------------------------------------
25
26/// Errors produced by CLI dispatch operations.
27#[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    /// Schema $ref resolution failed (circular, missing target, max depth).
48    /// Routed to `EXIT_SCHEMA_CIRCULAR_REF` (48) by `cli_error_exit_code`.
49    #[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    /// Map a `CliError` to the protocol-spec exit code so callers don't have
58    /// to switch on the variant inline.
59    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
67// ---------------------------------------------------------------------------
68// Global verbose help flag (controls built-in option visibility in help)
69// ---------------------------------------------------------------------------
70
71/// Whether --verbose was passed (controls help detail level).
72static VERBOSE_HELP: AtomicBool = AtomicBool::new(false);
73
74/// Set the verbose help flag. When false, built-in options are hidden
75/// from help.
76pub fn set_verbose_help(verbose: bool) {
77    VERBOSE_HELP.store(verbose, Ordering::Relaxed);
78}
79
80/// Check the verbose help flag.
81pub fn is_verbose_help() -> bool {
82    VERBOSE_HELP.load(Ordering::Relaxed)
83}
84
85// ---------------------------------------------------------------------------
86// Global docs URL (shown in help and man pages)
87// ---------------------------------------------------------------------------
88
89/// Base URL for online documentation. `None` means no link shown.
90static DOCS_URL: Mutex<Option<String>> = Mutex::new(None);
91
92/// Set the base URL for online documentation links in help and man
93/// pages. Pass `None` to disable. Command-level help appends
94/// `/commands/{name}` automatically.
95///
96/// # Example
97/// ```
98/// apcore_cli::cli::set_docs_url(Some("https://docs.apcore.dev/cli".into()));
99/// ```
100pub fn set_docs_url(url: Option<String>) {
101    if let Ok(mut guard) = DOCS_URL.lock() {
102        *guard = url;
103    }
104}
105
106/// Get the current docs URL (if set).
107pub fn get_docs_url() -> Option<String> {
108    match DOCS_URL.lock() {
109        Ok(guard) => guard.clone(),
110        Err(_) => None,
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Global audit logger (module-level singleton, set once at startup)
116// ---------------------------------------------------------------------------
117
118static AUDIT_LOGGER: Mutex<Option<AuditLogger>> = Mutex::new(None);
119
120// ---------------------------------------------------------------------------
121// Global executable map (module name -> script path, set once at startup)
122// ---------------------------------------------------------------------------
123
124static EXECUTABLES: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
125
126/// Store the executable map built during module discovery.
127///
128/// Must be called once before any `dispatch_module` invocation.
129pub fn set_executables(map: HashMap<String, PathBuf>) {
130    let _ = EXECUTABLES.set(map);
131}
132
133/// Set (or clear) the global audit logger used by all module commands.
134///
135/// Pass `None` to disable auditing. Typically called once during CLI
136/// initialisation, before any commands are dispatched.
137pub 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
148/// Centralised audit-log entry point. Acquires the global lock once, calls
149/// `log_execution`, and silently no-ops when the logger is disabled or the
150/// mutex is poisoned. Both helpers below delegate here so every dispatch_module
151/// exit path goes through the same shape — closes the gap where the standard
152/// Err path called `log_execution` but the stream/trace Err paths did not
153/// (review #7).
154fn 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
162/// Record a successful module execution. Exit code is 0.
163fn audit_success(module_id: &str, input: &Value, duration_ms: u64) {
164    audit_log_entry(module_id, input, "success", 0, duration_ms);
165}
166
167/// Record a failed module execution. `exit_code` is the protocol-spec code
168/// returned to the caller.
169fn 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
173// ---------------------------------------------------------------------------
174// exec_command — clap subcommand builder for `exec`
175// ---------------------------------------------------------------------------
176
177/// Add the standard dispatch flags (--input, --yes, --large-input, --format,
178/// --sandbox) to a clap Command. Used by both `exec_command()` and the external
179/// subcommand re-parser in main.rs.
180pub 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        // --sandbox is always hidden (not yet implemented)
237        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
310/// Build the `exec` clap subcommand.
311///
312/// `exec` runs an apcore module by its fully-qualified module ID.
313pub 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
325// ---------------------------------------------------------------------------
326// Reserved root-level names (FE-13)
327// ---------------------------------------------------------------------------
328//
329// Pre-v0.7, apcore-cli maintained a flat list of built-in command names
330// (`BUILTIN_COMMANDS`) that were reserved against business-module collisions.
331// FE-13 collapses every former built-in under the reserved `apcli` group, so
332// the only collision surface at the root is the `apcli` name itself. See
333// `crate::builtin_group::RESERVED_GROUP_NAMES` for the canonical list.
334//
335// BUILTIN_COMMANDS deprecated alias removed in v0.7.x — use RESERVED_GROUP_NAMES.
336
337// LazyModuleGroup / GroupedModuleGroup / ModuleExecutor / ApCoreExecutorAdapter
338// were deleted per audit findings D9-001..004. See the module-level comment at
339// the top of this file. Multi-level grouping now happens at the clap::Command
340// build time in main.rs (via schema_parser + dispatch flags), not via a
341// separate Lazy/Grouped struct hierarchy.
342
343// ---------------------------------------------------------------------------
344// build_module_command
345// ---------------------------------------------------------------------------
346
347/// Built-in flag names added to every generated module command. A schema
348/// property that collides with one of these names will cause
349/// `std::process::exit(2)`.
350const 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
366/// Build a clap `Command` for a single module definition.
367///
368/// The resulting subcommand has:
369/// * its `name` set to `module_def.name`
370/// * its `about` derived from the module descriptor (empty if unavailable)
371/// * the built-in dispatch flags (`--input`, `--yes`/`-y`, `--large-input`,
372///   `--format`, `--sandbox`, `--dry-run`, `--trace`, `--stream`, `--strategy`,
373///   `--fields`, `--approval-timeout`, `--approval-token`)
374/// * schema-derived flags from `schema_to_clap_args`
375///
376/// The executor is NOT embedded in the `clap::Command` — clap has no
377/// user-data attachment. Dispatch is handled separately by `dispatch_module`
378/// which receives the executor as a parameter.
379///
380/// # Errors
381/// Returns `CliError::ReservedModuleId` when `module_def.name` is one of the
382/// reserved built-in command names.
383///
384/// **Design note (audit D9):** This is a convenience wrapper over
385/// [`build_module_command_with_limit`] that supplies the default
386/// `HELP_TEXT_MAX_LEN`. Audit D9 flagged the pair as bloat, but the wrapper
387/// is consumed by `main.rs:624` and 8+ unit tests as the ergonomic default
388/// form. Migrating those callers to construct an explicit limit at every site
389/// would add ~15 lines of churn for a 1-line save. Retained intentionally.
390pub 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
396/// Build a clap `Command` for a single module definition with a configurable
397/// help text max length.
398pub 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    // Guard: reject reserved command names immediately (FE-13 §4.10).
405    if crate::builtin_group::RESERVED_GROUP_NAMES.contains(&module_id.as_str()) {
406        return Err(CliError::ReservedModuleId(module_id.clone()));
407    }
408
409    // Resolve $ref pointers in the input schema before generating clap args.
410    // Failures (circular ref, missing target, max-depth exceeded) propagate
411    // as SchemaRefResolution so the user sees EXIT_SCHEMA_CIRCULAR_REF (48)
412    // — previously the error was swallowed via .unwrap_or_else and the user
413    // got a downstream clap parse error built from un-resolved $refs (review #8).
414    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    // Build clap args from JSON Schema properties.
425    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    // Check for schema property names that collide with built-in flags.
432    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    // Build after_help footer: verbose hint + optional docs link
446    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    // Attach schema-derived args.
462    for arg in schema_args.args {
463        cmd = cmd.arg(arg);
464    }
465
466    Ok(cmd)
467}
468
469// ---------------------------------------------------------------------------
470// collect_input
471// ---------------------------------------------------------------------------
472
473const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
474
475/// Inner implementation: accepts any `Read` source for testability.
476///
477/// # Arguments
478/// * `stdin_flag`  — `Some("-")` to read from `reader`, anything else skips STDIN
479/// * `cli_kwargs`  — map of flag name → value (`Null` values are dropped)
480/// * `large_input` — if `false`, reject payloads exceeding `STDIN_SIZE_LIMIT_BYTES`
481/// * `reader`      — byte source to read from when `stdin_flag == Some("-")`
482///
483/// # Errors
484/// Returns `CliError` on oversized input, invalid JSON, or non-object JSON.
485pub 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    // Drop Null values from CLI kwargs.
492    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    // Merge: STDIN base, CLI kwargs override on collision.
526    let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
527    merged.extend(cli_non_null);
528    Ok(merged)
529}
530
531/// Merge CLI keyword arguments with optional JSON input.
532///
533/// Resolution order (highest priority first):
534/// 1. CLI flags (non-`Null` values in `cli_kwargs`)
535/// 2. JSON from `stdin_flag`:
536///    - `Some("-")` → read from stdin
537///    - `Some(path)` → read from file at `path`
538///    - `None` → no JSON input, return CLI kwargs only
539///
540/// # Arguments
541/// * `stdin_flag`  — `Some("-")` for stdin, `Some(path)` for a file, `None` to skip
542/// * `cli_kwargs`  — map of flag name → value (`Null` values are ignored)
543/// * `large_input` — if `false`, reject payloads exceeding 10 MiB
544///
545/// # Errors
546/// Returns `CliError` (exit code 2) on oversized input, invalid JSON, non-object
547/// JSON, or file open failures.
548pub 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
569// ---------------------------------------------------------------------------
570// validate_module_id
571// ---------------------------------------------------------------------------
572
573/// Maximum allowed length for a CLI-supplied module ID.
574///
575/// Tracks PROTOCOL_SPEC §2.7 EBNF constraint #1 — bumped from 128 to 192 in
576/// spec 1.6.0-draft to accommodate Java/.NET deep-namespace FQN-derived IDs.
577/// Filesystem-safe: `192 + ".binding.yaml".len() = 205 < 255`-byte filename
578/// limit on ext4/xfs/NTFS/APFS/btrfs.
579const MODULE_ID_MAX_LEN: usize = 192;
580
581/// Validate a module identifier.
582///
583/// # Rules
584/// * Maximum 192 characters (PROTOCOL_SPEC §2.7)
585/// * Matches `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`
586/// * No leading/trailing dots, no consecutive dots
587/// * Must not start with a digit or uppercase letter
588///
589/// # Errors
590/// Returns `CliError::InvalidModuleId` on any violation. Top-level CLI
591/// dispatch maps that to **exit code 2** (`EXIT_INVALID_INPUT`).
592///
593/// Cross-SDK note (D10-004): Python `validate_module_id` calls `sys.exit(2)`
594/// directly; TypeScript calls `process.exit(2)`. Rust keeps the Result form
595/// so callers can compose with other validation (and so this function is
596/// testable). Production code SHOULD prefer
597/// [`validate_module_id_or_exit`] which mirrors the Python/TS observable
598/// behavior — callers that ignore a `Result` here would silently pass
599/// invalid IDs downstream.
600pub 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
614/// Validate a module identifier, exiting on failure.
615///
616/// On a valid id, returns `()`. On any violation, writes the error message
617/// to stderr and calls `std::process::exit(2)` — matching Python's
618/// `sys.exit(2)` and TypeScript's `process.exit(2)` observable behavior
619/// (D10-004 cross-SDK parity). Use this from production dispatch paths
620/// where the Result form's only sensible Err handling is the exit pattern;
621/// use the underlying [`validate_module_id`] from tests and from callers
622/// that need to chain the validation with other checks.
623pub 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/// Hand-written validator matching `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`.
631///
632/// Does not require the `regex` crate.
633#[inline]
634fn is_valid_module_id(s: &str) -> bool {
635    if s.is_empty() {
636        return false;
637    }
638    // Split on '.' and validate each segment individually.
639    for segment in s.split('.') {
640        if segment.is_empty() {
641            // Catches leading dot, trailing dot, and consecutive dots.
642            return false;
643        }
644        let mut chars = segment.chars();
645        // First character must be a lowercase ASCII letter.
646        match chars.next() {
647            Some(c) if c.is_ascii_lowercase() => {}
648            _ => return false,
649        }
650        // Remaining characters: lowercase letter, ASCII digit, or underscore.
651        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
660// ---------------------------------------------------------------------------
661// Error code mapping
662// ---------------------------------------------------------------------------
663
664/// Map an apcore error code string to the appropriate CLI exit code.
665///
666/// Exit code table:
667/// * `MODULE_NOT_FOUND` / `MODULE_LOAD_ERROR` / `MODULE_DISABLED` → 44
668/// * `SCHEMA_VALIDATION_ERROR`                                     → 45
669/// * `APPROVAL_DENIED` / `APPROVAL_TIMEOUT` / `APPROVAL_PENDING`  → 46
670/// * `CONFIG_NOT_FOUND` / `CONFIG_INVALID`                         → 47
671/// * `SCHEMA_CIRCULAR_REF`                                         → 48
672/// * `ACL_DENIED`                                                  → 77
673/// * everything else (including `MODULE_EXECUTE_ERROR` / `MODULE_TIMEOUT`) → 1
674pub(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 Bus errors (apcore >= 0.15.0)
689        "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
700/// Map an `apcore::errors::ModuleError` directly to an exit code.
701///
702/// Converts the `ErrorCode` enum variant to its SCREAMING_SNAKE_CASE
703/// representation via serde JSON serialisation and delegates to
704/// `map_apcore_error_to_exit_code`.
705pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
706    // Serialise the ErrorCode enum to its SCREAMING_SNAKE_CASE string.
707    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
714// ---------------------------------------------------------------------------
715// Schema validation helper
716// ---------------------------------------------------------------------------
717
718/// Validate `input` against a JSON Schema object using the `jsonschema` crate.
719///
720/// Enforces all JSON Schema keywords present in `schema` (type, format, enum,
721/// pattern, minimum, maximum, required, etc.).  Returns the first validation
722/// error message on failure.
723///
724/// clap delivers all flag values as strings.  Before running jsonschema this
725/// function coerces string values to integer/number where the schema declares
726/// those types, so `--port 8080` (string "8080") satisfies `{type: "integer"}`.
727///
728/// # Errors
729/// Returns `Err(String)` with the first schema violation found.
730pub(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    // Coerce string values to their schema-declared numeric types.
738    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
774// ---------------------------------------------------------------------------
775// dispatch_module — full execution pipeline
776// ---------------------------------------------------------------------------
777
778// ---------------------------------------------------------------------------
779// F3: Enhanced Error Output
780// ---------------------------------------------------------------------------
781
782/// Emit structured JSON error to stderr for AI agents / non-TTY consumers.
783///
784/// When `error_data` is provided (from an apcore ModuleError), its fields
785/// (`code`, `details`, `suggestion`, `ai_guidance`, `retryable`,
786/// `user_fixable`) are included in the output per FE-11 spec section 3.3.
787fn 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    // Overlay fields from the structured error if available.
800    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
822/// Emit human-readable error to stderr with structured guidance fields.
823///
824/// Shows `[CODE]` header, `Details:` block, `Suggestion:`, and `Retryable:`
825/// labels. Hides `ai_guidance` and `user_fixable` (machine-oriented fields).
826fn emit_error_tty(
827    _module_id: &str,
828    message: &str,
829    exit_code: i32,
830    error_data: Option<&serde_json::Value>,
831) {
832    // Header with error code.
833    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    // Details block.
843    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    // Suggestion.
854    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    // Retryable.
862    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
877// ---------------------------------------------------------------------------
878// Boolean pair reconciliation
879// ---------------------------------------------------------------------------
880
881/// Reconcile --flag / --no-flag boolean pairs from ArgMatches into bool values.
882///
883/// For each BoolFlagPair:
884/// - If --flag was set  → prop_name = true
885/// - If --no-flag set   → prop_name = false
886/// - If neither         → prop_name = default_val
887pub 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        // Use try_get_one to avoid panicking when the flag doesn't exist
894        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
895        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
920/// Extract schema-derived CLI kwargs from `ArgMatches` for a given module.
921///
922/// Iterates schema properties and extracts string values from clap matches.
923/// Boolean pairs are handled separately via `reconcile_bool_pairs`.
924fn 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    // Extract non-boolean schema args as strings (or Null if absent).
938    for arg in &schema_args.args {
939        let id = arg.get_id().as_str().to_string();
940        // Skip the no- counterparts of boolean args.
941        if id.starts_with("no-") {
942            continue;
943        }
944        // Use try_get_one to avoid panicking when the arg doesn't exist
945        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
946        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    // Reconcile boolean pairs.
956    let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
957    kwargs.extend(bool_vals);
958
959    // Apply enum type reconversion.
960    crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
961}
962
963/// Execute a script-based module by spawning the executable as a subprocess.
964///
965/// JSON input is written to stdin; JSON output is read from stdout.
966/// Stderr is captured and included in error messages on failure.
967async 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        // Ensure the child is killed if this future is dropped (e.g. on
975        // SIGINT via the tokio::select! race at the call site) — tokio's
976        // default is kill_on_drop=false, which would leak the subprocess.
977        .kill_on_drop(true)
978        .spawn()
979        .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
980
981    // Write JSON input to child stdin then close it.
982    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
1014/// Execute a module by ID: validate → collect input → validate schema
1015/// → approve → execute → audit → output.
1016///
1017/// Calls `std::process::exit` with the appropriate code; never returns normally.
1018pub 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    // 1. Validate module ID format (exit 2 on bad format).
1030    validate_module_id_or_exit(module_id);
1031
1032    // 2. Registry lookup (exit 44 if not found).
1033    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    // 3. Extract built-in flags from matches.
1042    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    // 4. Build CLI kwargs from schema-derived flags (stub: empty map).
1055    let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1056
1057    // 5. Collect and merge input (exit 2 on errors).
1058    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    // -- F1: Dry-run / validate: preflight only, no execution --
1079    if dry_run {
1080        // --trace --dry-run: show pipeline preview after preflight result.
1081        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        // Delegate to the shared preflight builder (D9-004) so the
1118        // dry-run path emits the same shape as the standalone `validate`
1119        // subcommand. Both call sites share validate::build_preflight_result
1120        // — was previously a parallel implementation here.
1121        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            // Honor the granular check-level exit code mapping owned by
1134            // validate::first_failed_exit_code so the dry-run path agrees
1135            // with the standalone `validate` path on schema-vs-other
1136            // failures.
1137            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    // 6. Schema validation (if module has input_schema with properties).
1147    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    // -- F5: Inject approval token if provided --
1157    if let Some(ref token) = approval_token {
1158        merged.insert("_approval_token".to_string(), Value::String(token.clone()));
1159    }
1160
1161    // 7. Approval gate (exit 46 on denial/timeout).
1162    // Resolve the timeout: --approval-timeout flag > cli.approval_timeout
1163    // config default > hardcoded 60s. Non-numeric values fall back to the
1164    // next tier rather than failing the dispatch.
1165    let approval_timeout_secs = approval_timeout_arg
1166        .as_deref()
1167        .and_then(|s| s.parse::<u64>().ok())
1168        .or_else(|| {
1169            // Tier 2: read cli.approval_timeout from apcore.yaml when the flag
1170            // was not supplied, honouring the documented precedence order.
1171            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    // 8. Build merged input as serde_json::Value.
1193    let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1194
1195    // Determine sandbox flag.
1196    let use_sandbox = matches.get_flag("sandbox");
1197
1198    // Check if this module has a script-based executable.
1199    let script_executable = EXECUTABLES
1200        .get()
1201        .and_then(|map| map.get(module_id))
1202        .cloned();
1203
1204    // -- F6: Streaming execution --
1205    if stream_flag {
1206        // Streaming always outputs JSONL; --format table is ignored (spec 3.6.2).
1207        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        // Stream outputs as JSONL.
1212        if let Some(exec_path) = script_executable.as_ref() {
1213            // Script-based: fall back to regular execution, output as JSONL.
1214            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        // In-process: use executor.stream() if available, else fall through.
1241        // apcore executor does not expose a stream() method in Rust yet,
1242        // so we fall back to standard call and output as single JSONL line.
1243        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    // -- F4: Traced execution --
1275    if trace_flag {
1276        let start = std::time::Instant::now();
1277        // Use standard call; trace output is simulated from timing data.
1278        // Full PipelineTrace requires call_with_trace(), which may not be
1279        // available on all executor implementations.
1280        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                // Print result with trace appended (format BEFORE audit — canonical order).
1296                let fmt = crate::output::resolve_format(format_flag.as_deref());
1297                if fmt == "json" {
1298                    // Merge trace stub into JSON output.
1299                    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 AFTER format (canonical order: format -> audit).
1328                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    // 9. Execute with SIGINT race (exit 130 on Ctrl-C).
1341    let start = std::time::Instant::now();
1342
1343    // Unify the execution paths into Result<Value, (i32, String, Option<Value>)>
1344    // where the error tuple is (exit_code, display_message, optional_structured_error).
1345    let result: Result<Value, (i32, String, Option<Value>)> =
1346        if let Some(exec_path) = script_executable {
1347            // Script-based execution: spawn subprocess, pipe JSON via stdin/stdout.
1348            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                        // Preserve protocol exit-code semantics when the
1363                        // disabled passthrough surfaces an apcore ModuleError;
1364                        // other sandbox failures (NonZeroExit, Timeout,
1365                        // OutputParseFailed, SpawnFailed) map to the generic
1366                        // execute-error code.
1367                        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            // Direct in-process executor call.
1384            // Note: strategy is configured at Executor construction, not per-call.
1385            // The strategy_name flag is available for future use when the Executor
1386            // supports per-call strategy overrides.
1387            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                        // Serialize the ModuleError for F3 structured error output.
1397                        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            // 10. Format and output first (canonical order: format -> audit).
1413            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            // 11. Audit log success AFTER format.
1419            audit_success(module_id, &input_value, duration_ms);
1420            std::process::exit(EXIT_SUCCESS);
1421        }
1422        Err((exit_code, msg, error_data)) => {
1423            // Audit log error.
1424            audit_error(module_id, &input_value, exit_code, duration_ms);
1425            // F3: Enhanced error output with structured guidance fields.
1426            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// ---------------------------------------------------------------------------
1437// Unit tests
1438// ---------------------------------------------------------------------------
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443
1444    #[test]
1445    fn test_validate_module_id_valid() {
1446        // Valid IDs must not return an error.
1447        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        // PROTOCOL_SPEC §2.7 — bumped from 128 to 192 in spec 1.6.0-draft.
1456        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        // PROTOCOL_SPEC §2.7 — bumped from 128 to 192 in spec 1.6.0-draft.
1470        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    // collect_input tests (TDD red → green)
1482
1483    #[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    // ---------------------------------------------------------------------------
1606    // build_module_command tests (TDD — RED written before GREEN)
1607    // ---------------------------------------------------------------------------
1608
1609    /// Construct a minimal `ModuleDescriptor` for use in `build_module_command`
1610    /// tests. `input_schema` defaults to a JSON null (no properties) when
1611    /// `schema` is `None`.
1612    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        // FE-13: only the `apcli` group name is reserved now. Former built-ins
1689        // (`list`, `describe`, etc.) live under the apcli group and no longer
1690        // collide at the root.
1691        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        // Regression guard: `list`, `describe`, `health` etc. used to be
1704        // reserved; FE-13 retires that list. They must build cleanly now.
1705        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    // ---------------------------------------------------------------------------
1727    // Reserved name invariants (FE-13)
1728    // ---------------------------------------------------------------------------
1729
1730    #[test]
1731    fn test_reserved_group_names_single_entry() {
1732        // FE-13: all former built-ins now live under `apcli`, so the only
1733        // reserved root-level name is `apcli` itself.
1734        assert_eq!(crate::builtin_group::RESERVED_GROUP_NAMES, &["apcli"]);
1735    }
1736
1737    #[test]
1738    fn test_apcli_subcommand_names_matches_spec() {
1739        // Spec §4.1 subcommand table — 13 entries registered under `apcli`.
1740        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    // ---------------------------------------------------------------------------
1759    // map_apcore_error_to_exit_code tests (RED — written before implementation)
1760    // ---------------------------------------------------------------------------
1761
1762    #[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    // ---------------------------------------------------------------------------
1838    // set_audit_logger implementation tests (RED)
1839    // ---------------------------------------------------------------------------
1840
1841    #[test]
1842    fn test_set_audit_logger_none_clears_logger() {
1843        // Setting None must not panic and must leave AUDIT_LOGGER as None.
1844        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        // Clean up.
1856        drop(guard);
1857        set_audit_logger(None);
1858    }
1859
1860    // ---------------------------------------------------------------------------
1861    // validate_against_schema tests (RED)
1862    // ---------------------------------------------------------------------------
1863
1864    #[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        // Schema without properties must not fail.
1869        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    // GroupedModuleGroup / is_valid_group_name tests deleted along with the
1946    // corresponding implementations per audit findings D9-002 / D9-005.
1947}