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::{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// ---------------------------------------------------------------------------
17// Local trait abstractions for Executor
18// ---------------------------------------------------------------------------
19// apcore::Executor is a concrete struct, not a trait.
20// This local trait allows LazyModuleGroup to be generic over both the real
21// implementation and test mocks without depending on apcore internals.
22// Registry access uses the unified `discovery::RegistryProvider` trait.
23
24/// Minimal executor interface required by LazyModuleGroup.
25pub trait ModuleExecutor: Send + Sync {}
26
27/// Adapter that implements ModuleExecutor for the real apcore::Executor.
28pub struct ApCoreExecutorAdapter(pub apcore::Executor);
29
30impl ModuleExecutor for ApCoreExecutorAdapter {}
31
32// ---------------------------------------------------------------------------
33// Error type
34// ---------------------------------------------------------------------------
35
36/// Errors produced by CLI dispatch operations.
37#[derive(Debug, Error)]
38pub enum CliError {
39    #[error("invalid module id: {0}")]
40    InvalidModuleId(String),
41
42    #[error("reserved module id: '{0}' conflicts with a built-in command name")]
43    ReservedModuleId(String),
44
45    #[error("stdin read error: {0}")]
46    StdinRead(String),
47
48    #[error("json parse error: {0}")]
49    JsonParse(String),
50
51    #[error("input too large (limit {limit} bytes, got {actual} bytes)")]
52    InputTooLarge { limit: usize, actual: usize },
53
54    #[error("expected JSON object, got a different type")]
55    NotAnObject,
56}
57
58// ---------------------------------------------------------------------------
59// Global verbose help flag (controls built-in option visibility in help)
60// ---------------------------------------------------------------------------
61
62/// Whether --verbose was passed (controls help detail level).
63static VERBOSE_HELP: AtomicBool = AtomicBool::new(false);
64
65/// Set the verbose help flag. When false, built-in options are hidden
66/// from help.
67pub fn set_verbose_help(verbose: bool) {
68    VERBOSE_HELP.store(verbose, Ordering::Relaxed);
69}
70
71/// Check the verbose help flag.
72pub fn is_verbose_help() -> bool {
73    VERBOSE_HELP.load(Ordering::Relaxed)
74}
75
76// ---------------------------------------------------------------------------
77// Global docs URL (shown in help and man pages)
78// ---------------------------------------------------------------------------
79
80/// Base URL for online documentation. `None` means no link shown.
81static DOCS_URL: Mutex<Option<String>> = Mutex::new(None);
82
83/// Set the base URL for online documentation links in help and man
84/// pages. Pass `None` to disable. Command-level help appends
85/// `/commands/{name}` automatically.
86///
87/// # Example
88/// ```
89/// apcore_cli::cli::set_docs_url(Some("https://docs.apcore.dev/cli".into()));
90/// ```
91pub fn set_docs_url(url: Option<String>) {
92    if let Ok(mut guard) = DOCS_URL.lock() {
93        *guard = url;
94    }
95}
96
97/// Get the current docs URL (if set).
98pub fn get_docs_url() -> Option<String> {
99    match DOCS_URL.lock() {
100        Ok(guard) => guard.clone(),
101        Err(_) => None,
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Global audit logger (module-level singleton, set once at startup)
107// ---------------------------------------------------------------------------
108
109static AUDIT_LOGGER: Mutex<Option<AuditLogger>> = Mutex::new(None);
110
111// ---------------------------------------------------------------------------
112// Global executable map (module name -> script path, set once at startup)
113// ---------------------------------------------------------------------------
114
115static EXECUTABLES: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
116
117/// Store the executable map built during module discovery.
118///
119/// Must be called once before any `dispatch_module` invocation.
120pub fn set_executables(map: HashMap<String, PathBuf>) {
121    let _ = EXECUTABLES.set(map);
122}
123
124/// Set (or clear) the global audit logger used by all module commands.
125///
126/// Pass `None` to disable auditing. Typically called once during CLI
127/// initialisation, before any commands are dispatched.
128pub fn set_audit_logger(audit_logger: Option<AuditLogger>) {
129    match AUDIT_LOGGER.lock() {
130        Ok(mut guard) => {
131            *guard = audit_logger;
132        }
133        Err(_poisoned) => {
134            tracing::warn!("AUDIT_LOGGER mutex poisoned — audit logger not updated");
135        }
136    }
137}
138
139// ---------------------------------------------------------------------------
140// exec_command — clap subcommand builder for `exec`
141// ---------------------------------------------------------------------------
142
143/// Add the standard dispatch flags (--input, --yes, --large-input, --format,
144/// --sandbox) to a clap Command. Used by both `exec_command()` and the external
145/// subcommand re-parser in main.rs.
146pub fn add_dispatch_flags(cmd: clap::Command) -> clap::Command {
147    use clap::{Arg, ArgAction};
148    let hide = !is_verbose_help();
149    cmd.arg(
150        Arg::new("input")
151            .long("input")
152            .value_name("SOURCE")
153            .help(
154                "Read JSON input from a file path, \
155                 or use '-' to read from stdin pipe",
156            )
157            .hide(hide),
158    )
159    .arg(
160        Arg::new("yes")
161            .long("yes")
162            .short('y')
163            .action(ArgAction::SetTrue)
164            .help(
165                "Skip interactive approval prompts \
166                 (for scripts and CI)",
167            )
168            .hide(hide),
169    )
170    .arg(
171        Arg::new("large-input")
172            .long("large-input")
173            .action(ArgAction::SetTrue)
174            .help(
175                "Allow stdin input larger than 10MB \
176                 (default limit protects against \
177                 accidental pipes)",
178            )
179            .hide(hide),
180    )
181    .arg(
182        Arg::new("format")
183            .long("format")
184            .value_parser(["table", "json", "csv", "yaml", "jsonl"])
185            .help(
186                "Output format: json, table, csv, \
187                 yaml, jsonl.",
188            )
189            .hide(hide),
190    )
191    .arg(
192        Arg::new("fields")
193            .long("fields")
194            .value_name("FIELDS")
195            .help(
196                "Comma-separated dot-paths to select \
197                 from the result (e.g., 'status,data.count').",
198            )
199            .hide(hide),
200    )
201    .arg(
202        // --sandbox is always hidden (not yet implemented)
203        Arg::new("sandbox")
204            .long("sandbox")
205            .action(ArgAction::SetTrue)
206            .help(
207                "Run module in an isolated subprocess \
208                 with restricted filesystem and env \
209                 access",
210            )
211            .hide(true),
212    )
213    .arg(
214        Arg::new("dry-run")
215            .long("dry-run")
216            .action(ArgAction::SetTrue)
217            .help(
218                "Run preflight checks without executing \
219                 the module. Shows validation results.",
220            )
221            .hide(hide),
222    )
223    .arg(
224        Arg::new("trace")
225            .long("trace")
226            .action(ArgAction::SetTrue)
227            .help(
228                "Show execution pipeline trace with \
229                 per-step timing after the result.",
230            )
231            .hide(hide),
232    )
233    .arg(
234        Arg::new("stream")
235            .long("stream")
236            .action(ArgAction::SetTrue)
237            .help(
238                "Stream module output as JSONL (one JSON \
239                 object per line, flushed immediately).",
240            )
241            .hide(hide),
242    )
243    .arg(
244        Arg::new("strategy")
245            .long("strategy")
246            .value_parser(["standard", "internal", "testing", "performance", "minimal"])
247            .value_name("STRATEGY")
248            .help(
249                "Execution pipeline strategy: standard \
250                 (default), internal, testing, performance.",
251            )
252            .hide(hide),
253    )
254    .arg(
255        Arg::new("approval-timeout")
256            .long("approval-timeout")
257            .value_name("SECONDS")
258            .help(
259                "Override approval prompt timeout in \
260                 seconds (default: 60).",
261            )
262            .hide(hide),
263    )
264    .arg(
265        Arg::new("approval-token")
266            .long("approval-token")
267            .value_name("TOKEN")
268            .help(
269                "Resume a pending approval with the \
270                 given token (for async approval flows).",
271            )
272            .hide(hide),
273    )
274}
275
276/// Build the `exec` clap subcommand.
277///
278/// `exec` runs an apcore module by its fully-qualified module ID.
279pub fn exec_command() -> clap::Command {
280    use clap::{Arg, Command};
281
282    let cmd = Command::new("exec").about("Execute an apcore module").arg(
283        Arg::new("module_id")
284            .required(true)
285            .value_name("MODULE_ID")
286            .help("Fully-qualified module ID to execute"),
287    );
288    add_dispatch_flags(cmd)
289}
290
291// ---------------------------------------------------------------------------
292// LazyModuleGroup — lazy command builder
293// ---------------------------------------------------------------------------
294
295/// Built-in command names that are always present regardless of the registry.
296pub const BUILTIN_COMMANDS: &[&str] = &[
297    "completion",
298    "config",
299    "describe",
300    "describe-pipeline",
301    "disable",
302    "enable",
303    "exec",
304    "health",
305    "init",
306    "list",
307    "man",
308    "reload",
309    "usage",
310    "validate",
311];
312
313/// Lazy command registry: builds module subcommands on-demand from the
314/// apcore Registry, caching them after first construction.
315///
316/// This is the Rust equivalent of the Python `LazyModuleGroup` (Click group
317/// subclass with lazy `get_command` / `list_commands`).
318pub struct LazyModuleGroup {
319    registry: Arc<dyn crate::discovery::RegistryProvider>,
320    #[allow(dead_code)]
321    executor: Arc<dyn ModuleExecutor>,
322    /// Cache of module name -> name string (we store the name, not the Command,
323    /// since clap::Command is not Clone in all configurations).
324    module_cache: HashMap<String, bool>,
325    /// Count of registry descriptor lookups (test instrumentation only).
326    #[cfg(test)]
327    pub registry_lookup_count: usize,
328}
329
330impl LazyModuleGroup {
331    /// Create a new lazy module group.
332    ///
333    /// # Arguments
334    /// * `registry` — module registry (real or mock)
335    /// * `executor` — module executor (real or mock)
336    pub fn new(
337        registry: Arc<dyn crate::discovery::RegistryProvider>,
338        executor: Arc<dyn ModuleExecutor>,
339    ) -> Self {
340        Self {
341            registry,
342            executor,
343            module_cache: HashMap::new(),
344            #[cfg(test)]
345            registry_lookup_count: 0,
346        }
347    }
348
349    /// Return sorted list of all command names: built-ins + module ids.
350    pub fn list_commands(&self) -> Vec<String> {
351        let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
352        names.extend(self.registry.list());
353        // Sort and dedup in one pass.
354        names.sort_unstable();
355        names.dedup();
356        names
357    }
358
359    /// Look up a command by name. Returns `None` if the name is not a builtin
360    /// and is not found in the registry.
361    ///
362    /// For module commands, builds and caches a lightweight clap Command.
363    pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
364        if BUILTIN_COMMANDS.contains(&name) {
365            return Some(clap::Command::new(name.to_string()));
366        }
367        // Check the in-memory cache first.
368        if self.module_cache.contains_key(name) {
369            return Some(clap::Command::new(name.to_string()));
370        }
371        // Registry lookup.
372        #[cfg(test)]
373        {
374            self.registry_lookup_count += 1;
375        }
376        let _descriptor = self.registry.get_module_descriptor(name)?;
377        let cmd = clap::Command::new(name.to_string());
378        self.module_cache.insert(name.to_string(), true);
379        tracing::debug!("Loaded module command: {name}");
380        Some(cmd)
381    }
382
383    /// Return the number of times the registry was queried for a descriptor.
384    /// Available in test builds only.
385    #[cfg(test)]
386    pub fn registry_lookup_count(&self) -> usize {
387        self.registry_lookup_count
388    }
389}
390
391// ---------------------------------------------------------------------------
392// GroupedModuleGroup -- groups commands by dotted prefix or explicit
393// metadata.display.cli.group
394// ---------------------------------------------------------------------------
395
396/// Grouped command registry: organises modules into sub-command groups
397/// derived from dotted module IDs or explicit display overlay metadata.
398///
399/// This is the Rust equivalent of the Python `GroupedModuleGroup`.
400pub struct GroupedModuleGroup {
401    registry: Arc<dyn crate::discovery::RegistryProvider>,
402    #[allow(dead_code)]
403    executor: Arc<dyn ModuleExecutor>,
404    #[allow(dead_code)]
405    help_text_max_length: usize,
406    group_map: HashMap<String, HashMap<String, (String, Value)>>,
407    top_level_modules: HashMap<String, (String, Value)>,
408    alias_map: HashMap<String, String>,
409    descriptor_cache: HashMap<String, Value>,
410    group_map_built: bool,
411}
412
413impl GroupedModuleGroup {
414    /// Create a new grouped module group.
415    pub fn new(
416        registry: Arc<dyn crate::discovery::RegistryProvider>,
417        executor: Arc<dyn ModuleExecutor>,
418        help_text_max_length: usize,
419    ) -> Self {
420        Self {
421            registry,
422            executor,
423            help_text_max_length,
424            group_map: HashMap::new(),
425            top_level_modules: HashMap::new(),
426            alias_map: HashMap::new(),
427            descriptor_cache: HashMap::new(),
428            group_map_built: false,
429        }
430    }
431
432    /// Resolve the group and command name for a module.
433    ///
434    /// Returns `(Some(group), command)` for grouped modules or
435    /// `(None, command)` for top-level modules.
436    pub fn resolve_group(module_id: &str, descriptor: &Value) -> (Option<String>, String) {
437        let display = crate::display_helpers::get_display(descriptor);
438        let cli = display.get("cli").unwrap_or(&Value::Null);
439
440        // 1. Check explicit group in metadata.display.cli.group
441        if let Some(group_val) = cli.get("group") {
442            if let Some(g) = group_val.as_str() {
443                if g.is_empty() {
444                    // Explicit empty string -> top-level
445                    let alias = cli
446                        .get("alias")
447                        .and_then(|v| v.as_str())
448                        .or_else(|| display.get("alias").and_then(|v| v.as_str()))
449                        .unwrap_or(module_id);
450                    return (None, alias.to_string());
451                }
452                // Explicit non-empty group
453                let alias = cli
454                    .get("alias")
455                    .and_then(|v| v.as_str())
456                    .or_else(|| display.get("alias").and_then(|v| v.as_str()))
457                    .unwrap_or(module_id);
458                return (Some(g.to_string()), alias.to_string());
459            }
460        }
461
462        // 2. Derive alias
463        let alias = cli
464            .get("alias")
465            .and_then(|v| v.as_str())
466            .or_else(|| display.get("alias").and_then(|v| v.as_str()))
467            .unwrap_or(module_id);
468
469        // 3. If alias contains a dot, split on first dot
470        if let Some(dot_pos) = alias.find('.') {
471            let group = &alias[..dot_pos];
472            let cmd = &alias[dot_pos + 1..];
473            return (Some(group.to_string()), cmd.to_string());
474        }
475
476        // 4. No dot -> top-level
477        (None, alias.to_string())
478    }
479
480    /// Build the internal group map from registry modules.
481    pub fn build_group_map(&mut self) {
482        if self.group_map_built {
483            return;
484        }
485        self.group_map_built = true;
486
487        let module_ids = self.registry.list();
488        for mid in &module_ids {
489            let descriptor = match self.registry.get_definition(mid) {
490                Some(d) => d,
491                None => continue,
492            };
493
494            let (group, cmd_name) = Self::resolve_group(mid, &descriptor);
495            self.alias_map.insert(cmd_name.clone(), mid.clone());
496            self.descriptor_cache
497                .insert(mid.clone(), descriptor.clone());
498
499            match group {
500                Some(g) if is_valid_group_name(&g) => {
501                    let entry = self.group_map.entry(g).or_default();
502                    entry.insert(cmd_name, (mid.clone(), descriptor));
503                }
504                Some(g) => {
505                    tracing::warn!(
506                        "Module '{}': group name '{}' is not shell-safe \
507                         -- treating as top-level.",
508                        mid,
509                        g,
510                    );
511                    self.top_level_modules
512                        .insert(cmd_name, (mid.clone(), descriptor));
513                }
514                None => {
515                    self.top_level_modules
516                        .insert(cmd_name, (mid.clone(), descriptor));
517                }
518            }
519        }
520    }
521
522    /// Return sorted list of all command names: builtins + groups +
523    /// top-level modules.
524    pub fn list_commands(&mut self) -> Vec<String> {
525        self.build_group_map();
526        let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
527        for group_name in self.group_map.keys() {
528            names.push(group_name.clone());
529        }
530        for cmd_name in self.top_level_modules.keys() {
531            names.push(cmd_name.clone());
532        }
533        names.sort_unstable();
534        names.dedup();
535        names
536    }
537
538    /// Look up a command by name. Returns a clap Command for a
539    /// group (with subcommands) or a top-level module, or None.
540    pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
541        self.build_group_map();
542
543        if BUILTIN_COMMANDS.contains(&name) {
544            return Some(clap::Command::new(name.to_string()));
545        }
546
547        // Check groups
548        if let Some(members) = self.group_map.get(name) {
549            let mut group_cmd = clap::Command::new(name.to_string());
550            for (cmd_name, (_mid, _desc)) in members {
551                group_cmd = group_cmd.subcommand(clap::Command::new(cmd_name.clone()));
552            }
553            return Some(group_cmd);
554        }
555
556        // Check top-level modules
557        if self.top_level_modules.contains_key(name) {
558            return Some(clap::Command::new(name.to_string()));
559        }
560
561        None
562    }
563
564    /// Return the help text max length.
565    #[cfg(test)]
566    pub fn help_text_max_length(&self) -> usize {
567        self.help_text_max_length
568    }
569}
570
571/// Validate a group name: must match `^[a-z][a-z0-9_-]*$`.
572fn is_valid_group_name(s: &str) -> bool {
573    if s.is_empty() {
574        return false;
575    }
576    let mut chars = s.chars();
577    match chars.next() {
578        Some(c) if c.is_ascii_lowercase() => {}
579        _ => return false,
580    }
581    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
582}
583
584// ---------------------------------------------------------------------------
585// build_module_command
586// ---------------------------------------------------------------------------
587
588/// Built-in flag names added to every generated module command. A schema
589/// property that collides with one of these names will cause
590/// `std::process::exit(2)`.
591const RESERVED_FLAG_NAMES: &[&str] = &[
592    "approval-timeout",
593    "approval-token",
594    "dry-run",
595    "fields",
596    "format",
597    "input",
598    "large-input",
599    "sandbox",
600    "strategy",
601    "stream",
602    "trace",
603    "verbose",
604    "yes",
605];
606
607/// Build a clap `Command` for a single module definition.
608///
609/// The resulting subcommand has:
610/// * its `name` set to `module_def.name`
611/// * its `about` derived from the module descriptor (empty if unavailable)
612/// * the 5 built-in flags: `--input`, `--yes`/`-y`, `--large-input`,
613///   `--format`, `--sandbox`
614/// * schema-derived flags from `schema_to_clap_args` (stub: empty vec)
615///
616/// `executor` is accepted for API symmetry with the Python counterpart but is
617/// not embedded in the `clap::Command` (clap has no user-data attachment).
618/// The executor is passed separately to the dispatch callback.
619///
620/// # Errors
621/// Returns `CliError::ReservedModuleId` when `module_def.name` is one of the
622/// reserved built-in command names.
623pub fn build_module_command(
624    module_def: &apcore::registry::registry::ModuleDescriptor,
625    executor: Arc<dyn ModuleExecutor>,
626) -> Result<clap::Command, CliError> {
627    build_module_command_with_limit(
628        module_def,
629        executor,
630        crate::schema_parser::HELP_TEXT_MAX_LEN,
631    )
632}
633
634/// Build a clap `Command` for a single module definition with a configurable
635/// help text max length.
636pub fn build_module_command_with_limit(
637    module_def: &apcore::registry::registry::ModuleDescriptor,
638    executor: Arc<dyn ModuleExecutor>,
639    help_text_max_length: usize,
640) -> Result<clap::Command, CliError> {
641    let module_id = &module_def.name;
642
643    // Guard: reject reserved command names immediately.
644    if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
645        return Err(CliError::ReservedModuleId(module_id.clone()));
646    }
647
648    // Resolve $ref pointers in the input schema before generating clap args.
649    let resolved_schema =
650        crate::ref_resolver::resolve_refs(&module_def.input_schema, 32, module_id)
651            .unwrap_or_else(|_| module_def.input_schema.clone());
652
653    // Build clap args from JSON Schema properties.
654    let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
655        &resolved_schema,
656        help_text_max_length,
657    )
658    .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
659
660    // Check for schema property names that collide with built-in flags.
661    for arg in &schema_args.args {
662        if let Some(long) = arg.get_long() {
663            if RESERVED_FLAG_NAMES.contains(&long) {
664                return Err(CliError::ReservedModuleId(format!(
665                    "module '{module_id}' schema property '{long}' conflicts \
666                     with a reserved CLI option name"
667                )));
668            }
669        }
670    }
671
672    // Suppress unused-variable warning; executor is kept for API symmetry.
673    let _ = executor;
674
675    let hide = !is_verbose_help();
676
677    // Build after_help footer: verbose hint + optional docs link
678    let mut footer_parts = Vec::new();
679    if hide {
680        footer_parts.push(
681            "Use --verbose to show all options \
682             (including built-in apcore options)."
683                .to_string(),
684        );
685    }
686    if let Some(url) = get_docs_url() {
687        footer_parts.push(format!("Docs: {url}/commands/{module_id}"));
688    }
689    let footer = footer_parts.join("\n");
690
691    let mut cmd = add_dispatch_flags(clap::Command::new(module_id.clone()).after_help(footer));
692
693    // Attach schema-derived args.
694    for arg in schema_args.args {
695        cmd = cmd.arg(arg);
696    }
697
698    Ok(cmd)
699}
700
701// ---------------------------------------------------------------------------
702// collect_input
703// ---------------------------------------------------------------------------
704
705const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
706
707/// Inner implementation: accepts any `Read` source for testability.
708///
709/// # Arguments
710/// * `stdin_flag`  — `Some("-")` to read from `reader`, anything else skips STDIN
711/// * `cli_kwargs`  — map of flag name → value (`Null` values are dropped)
712/// * `large_input` — if `false`, reject payloads exceeding `STDIN_SIZE_LIMIT_BYTES`
713/// * `reader`      — byte source to read from when `stdin_flag == Some("-")`
714///
715/// # Errors
716/// Returns `CliError` on oversized input, invalid JSON, or non-object JSON.
717pub fn collect_input_from_reader<R: Read>(
718    stdin_flag: Option<&str>,
719    cli_kwargs: HashMap<String, Value>,
720    large_input: bool,
721    mut reader: R,
722) -> Result<HashMap<String, Value>, CliError> {
723    // Drop Null values from CLI kwargs.
724    let cli_non_null: HashMap<String, Value> = cli_kwargs
725        .into_iter()
726        .filter(|(_, v)| !v.is_null())
727        .collect();
728
729    if stdin_flag != Some("-") {
730        return Ok(cli_non_null);
731    }
732
733    let mut buf = Vec::new();
734    reader
735        .read_to_end(&mut buf)
736        .map_err(|e| CliError::StdinRead(e.to_string()))?;
737
738    if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
739        return Err(CliError::InputTooLarge {
740            limit: STDIN_SIZE_LIMIT_BYTES,
741            actual: buf.len(),
742        });
743    }
744
745    if buf.is_empty() {
746        return Ok(cli_non_null);
747    }
748
749    let stdin_value: Value =
750        serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
751
752    let stdin_map = match stdin_value {
753        Value::Object(m) => m,
754        _ => return Err(CliError::NotAnObject),
755    };
756
757    // Merge: STDIN base, CLI kwargs override on collision.
758    let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
759    merged.extend(cli_non_null);
760    Ok(merged)
761}
762
763/// Merge CLI keyword arguments with optional STDIN JSON.
764///
765/// Resolution order (highest priority first):
766/// 1. CLI flags (non-`Null` values in `cli_kwargs`)
767/// 2. STDIN JSON (when `stdin_flag` is `Some("-")`)
768///
769/// # Arguments
770/// * `stdin_flag`  — `Some("-")` to read from STDIN, `None` to skip
771/// * `cli_kwargs`  — map of flag name → value (`Null` values are ignored)
772/// * `large_input` — if `false`, reject STDIN payloads exceeding 10 MiB
773///
774/// # Errors
775/// Returns `CliError` (exit code 2) on oversized input, invalid JSON, or
776/// non-object JSON.
777pub fn collect_input(
778    stdin_flag: Option<&str>,
779    cli_kwargs: HashMap<String, Value>,
780    large_input: bool,
781) -> Result<HashMap<String, Value>, CliError> {
782    collect_input_from_reader(stdin_flag, cli_kwargs, large_input, std::io::stdin())
783}
784
785// ---------------------------------------------------------------------------
786// validate_module_id
787// ---------------------------------------------------------------------------
788
789const MODULE_ID_MAX_LEN: usize = 128;
790
791/// Validate a module identifier.
792///
793/// # Rules
794/// * Maximum 128 characters
795/// * Matches `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`
796/// * No leading/trailing dots, no consecutive dots
797/// * Must not start with a digit or uppercase letter
798///
799/// # Errors
800/// Returns `CliError::InvalidModuleId` (exit code 2) on any violation.
801pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
802    if module_id.len() > MODULE_ID_MAX_LEN {
803        return Err(CliError::InvalidModuleId(format!(
804            "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
805        )));
806    }
807    if !is_valid_module_id(module_id) {
808        return Err(CliError::InvalidModuleId(format!(
809            "Invalid module ID format: '{module_id}'."
810        )));
811    }
812    Ok(())
813}
814
815/// Hand-written validator matching `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`.
816///
817/// Does not require the `regex` crate.
818#[inline]
819fn is_valid_module_id(s: &str) -> bool {
820    if s.is_empty() {
821        return false;
822    }
823    // Split on '.' and validate each segment individually.
824    for segment in s.split('.') {
825        if segment.is_empty() {
826            // Catches leading dot, trailing dot, and consecutive dots.
827            return false;
828        }
829        let mut chars = segment.chars();
830        // First character must be a lowercase ASCII letter.
831        match chars.next() {
832            Some(c) if c.is_ascii_lowercase() => {}
833            _ => return false,
834        }
835        // Remaining characters: lowercase letter, ASCII digit, or underscore.
836        for c in chars {
837            if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
838                return false;
839            }
840        }
841    }
842    true
843}
844
845// ---------------------------------------------------------------------------
846// Error code mapping
847// ---------------------------------------------------------------------------
848
849/// Map an apcore error code string to the appropriate CLI exit code.
850///
851/// Exit code table:
852/// * `MODULE_NOT_FOUND` / `MODULE_LOAD_ERROR` / `MODULE_DISABLED` → 44
853/// * `SCHEMA_VALIDATION_ERROR`                                     → 45
854/// * `APPROVAL_DENIED` / `APPROVAL_TIMEOUT` / `APPROVAL_PENDING`  → 46
855/// * `CONFIG_NOT_FOUND` / `CONFIG_INVALID`                         → 47
856/// * `SCHEMA_CIRCULAR_REF`                                         → 48
857/// * `ACL_DENIED`                                                  → 77
858/// * everything else (including `MODULE_EXECUTE_ERROR` / `MODULE_TIMEOUT`) → 1
859pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
860    use crate::{
861        EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_BIND_ERROR, EXIT_CONFIG_MOUNT_ERROR,
862        EXIT_CONFIG_NAMESPACE_RESERVED, EXIT_CONFIG_NOT_FOUND, EXIT_ERROR_FORMATTER_DUPLICATE,
863        EXIT_MODULE_EXECUTE_ERROR, EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF,
864        EXIT_SCHEMA_VALIDATION_ERROR,
865    };
866    match error_code {
867        "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
868        "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
869        "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
870        "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
871        "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
872        "ACL_DENIED" => EXIT_ACL_DENIED,
873        // Config Bus errors (apcore >= 0.15.0)
874        "CONFIG_NAMESPACE_RESERVED"
875        | "CONFIG_NAMESPACE_DUPLICATE"
876        | "CONFIG_ENV_PREFIX_CONFLICT"
877        | "CONFIG_ENV_MAP_CONFLICT" => EXIT_CONFIG_NAMESPACE_RESERVED,
878        "CONFIG_MOUNT_ERROR" => EXIT_CONFIG_MOUNT_ERROR,
879        "CONFIG_BIND_ERROR" => EXIT_CONFIG_BIND_ERROR,
880        "ERROR_FORMATTER_DUPLICATE" => EXIT_ERROR_FORMATTER_DUPLICATE,
881        _ => EXIT_MODULE_EXECUTE_ERROR,
882    }
883}
884
885/// Map an `apcore::errors::ModuleError` directly to an exit code.
886///
887/// Converts the `ErrorCode` enum variant to its SCREAMING_SNAKE_CASE
888/// representation via serde JSON serialisation and delegates to
889/// `map_apcore_error_to_exit_code`.
890pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
891    // Serialise the ErrorCode enum to its SCREAMING_SNAKE_CASE string.
892    let code_str = serde_json::to_value(err.code)
893        .ok()
894        .and_then(|v| v.as_str().map(|s| s.to_string()))
895        .unwrap_or_default();
896    map_apcore_error_to_exit_code(&code_str)
897}
898
899// ---------------------------------------------------------------------------
900// Schema validation helper
901// ---------------------------------------------------------------------------
902
903/// Validate `input` against a JSON Schema object.
904///
905/// This is a lightweight inline checker sufficient until `jsonschema` crate
906/// integration lands (FE-08).  It enforces the `required` array only — if
907/// every field listed in `required` is present in `input`, the call succeeds.
908///
909/// # Errors
910/// Returns `Err(String)` describing the first missing required field.
911pub(crate) fn validate_against_schema(
912    input: &HashMap<String, Value>,
913    schema: &Value,
914) -> Result<(), String> {
915    // Extract "required" array if present.
916    let required = match schema.get("required") {
917        Some(Value::Array(arr)) => arr,
918        _ => return Ok(()),
919    };
920    for req in required {
921        if let Some(field_name) = req.as_str() {
922            if !input.contains_key(field_name) {
923                return Err(format!("required field '{}' is missing", field_name));
924            }
925        }
926    }
927    Ok(())
928}
929
930// ---------------------------------------------------------------------------
931// dispatch_module — full execution pipeline
932// ---------------------------------------------------------------------------
933
934// ---------------------------------------------------------------------------
935// F3: Enhanced Error Output
936// ---------------------------------------------------------------------------
937
938/// Emit structured JSON error to stderr for AI agents / non-TTY consumers.
939///
940/// When `error_data` is provided (from an apcore ModuleError), its fields
941/// (`code`, `details`, `suggestion`, `ai_guidance`, `retryable`,
942/// `user_fixable`) are included in the output per FE-11 spec section 3.3.
943fn emit_error_json(
944    _module_id: &str,
945    message: &str,
946    exit_code: i32,
947    error_data: Option<&serde_json::Value>,
948) {
949    let mut payload = serde_json::json!({
950        "error": true,
951        "code": "UNKNOWN",
952        "message": message,
953        "exit_code": exit_code,
954    });
955    // Overlay fields from the structured error if available.
956    if let Some(data) = error_data {
957        if let Some(obj) = data.as_object() {
958            for key in &[
959                "code",
960                "message",
961                "details",
962                "suggestion",
963                "ai_guidance",
964                "retryable",
965                "user_fixable",
966            ] {
967                if let Some(val) = obj.get(*key) {
968                    if !val.is_null() {
969                        payload[*key] = val.clone();
970                    }
971                }
972            }
973        }
974    }
975    eprintln!("{}", serde_json::to_string(&payload).unwrap_or_default());
976}
977
978/// Emit human-readable error to stderr with structured guidance fields.
979///
980/// Shows `[CODE]` header, `Details:` block, `Suggestion:`, and `Retryable:`
981/// labels. Hides `ai_guidance` and `user_fixable` (machine-oriented fields).
982fn emit_error_tty(
983    _module_id: &str,
984    message: &str,
985    exit_code: i32,
986    error_data: Option<&serde_json::Value>,
987) {
988    // Header with error code.
989    if let Some(code) = error_data
990        .and_then(|d| d.get("code"))
991        .and_then(|v| v.as_str())
992    {
993        eprintln!("Error [{code}]: {message}");
994    } else {
995        eprintln!("Error: {message}");
996    }
997
998    // Details block.
999    if let Some(details) = error_data
1000        .and_then(|d| d.get("details"))
1001        .and_then(|v| v.as_object())
1002    {
1003        eprintln!("\n  Details:");
1004        for (k, v) in details {
1005            eprintln!("    {k}: {v}");
1006        }
1007    }
1008
1009    // Suggestion.
1010    if let Some(suggestion) = error_data
1011        .and_then(|d| d.get("suggestion"))
1012        .and_then(|v| v.as_str())
1013    {
1014        eprintln!("\n  Suggestion: {suggestion}");
1015    }
1016
1017    // Retryable.
1018    if let Some(retryable) = error_data
1019        .and_then(|d| d.get("retryable"))
1020        .and_then(|v| v.as_bool())
1021    {
1022        let label = if retryable {
1023            "Yes"
1024        } else {
1025            "No (same input will fail again)"
1026        };
1027        eprintln!("  Retryable: {label}");
1028    }
1029
1030    eprintln!("\n  Exit code: {exit_code}");
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Boolean pair reconciliation
1035// ---------------------------------------------------------------------------
1036
1037/// Reconcile --flag / --no-flag boolean pairs from ArgMatches into bool values.
1038///
1039/// For each BoolFlagPair:
1040/// - If --flag was set  → prop_name = true
1041/// - If --no-flag set   → prop_name = false
1042/// - If neither         → prop_name = default_val
1043pub fn reconcile_bool_pairs(
1044    matches: &clap::ArgMatches,
1045    bool_pairs: &[crate::schema_parser::BoolFlagPair],
1046) -> HashMap<String, Value> {
1047    let mut result = HashMap::new();
1048    for pair in bool_pairs {
1049        // Use try_get_one to avoid panicking when the flag doesn't exist
1050        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
1051        let pos_set = matches
1052            .try_get_one::<bool>(&pair.prop_name)
1053            .ok()
1054            .flatten()
1055            .copied()
1056            .unwrap_or(false);
1057        let neg_id = format!("no-{}", pair.prop_name);
1058        let neg_set = matches
1059            .try_get_one::<bool>(&neg_id)
1060            .ok()
1061            .flatten()
1062            .copied()
1063            .unwrap_or(false);
1064        let val = if pos_set {
1065            true
1066        } else if neg_set {
1067            false
1068        } else {
1069            pair.default_val
1070        };
1071        result.insert(pair.prop_name.clone(), Value::Bool(val));
1072    }
1073    result
1074}
1075
1076/// Extract schema-derived CLI kwargs from `ArgMatches` for a given module.
1077///
1078/// Iterates schema properties and extracts string values from clap matches.
1079/// Boolean pairs are handled separately via `reconcile_bool_pairs`.
1080fn extract_cli_kwargs(
1081    matches: &clap::ArgMatches,
1082    module_def: &apcore::registry::registry::ModuleDescriptor,
1083) -> HashMap<String, Value> {
1084    use crate::schema_parser::schema_to_clap_args;
1085
1086    let schema_args = match schema_to_clap_args(&module_def.input_schema) {
1087        Ok(sa) => sa,
1088        Err(_) => return HashMap::new(),
1089    };
1090
1091    let mut kwargs: HashMap<String, Value> = HashMap::new();
1092
1093    // Extract non-boolean schema args as strings (or Null if absent).
1094    for arg in &schema_args.args {
1095        let id = arg.get_id().as_str().to_string();
1096        // Skip the no- counterparts of boolean args.
1097        if id.starts_with("no-") {
1098            continue;
1099        }
1100        // Use try_get_one to avoid panicking when the arg doesn't exist
1101        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
1102        if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
1103            kwargs.insert(id, Value::String(val.clone()));
1104        } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
1105            kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
1106        } else {
1107            kwargs.insert(id, Value::Null);
1108        }
1109    }
1110
1111    // Reconcile boolean pairs.
1112    let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
1113    kwargs.extend(bool_vals);
1114
1115    // Apply enum type reconversion.
1116    crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
1117}
1118
1119/// Execute a script-based module by spawning the executable as a subprocess.
1120///
1121/// JSON input is written to stdin; JSON output is read from stdout.
1122/// Stderr is captured and included in error messages on failure.
1123async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
1124    use tokio::io::AsyncWriteExt;
1125
1126    let mut child = tokio::process::Command::new(executable)
1127        .stdin(std::process::Stdio::piped())
1128        .stdout(std::process::Stdio::piped())
1129        .stderr(std::process::Stdio::piped())
1130        .spawn()
1131        .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
1132
1133    // Write JSON input to child stdin then close it.
1134    if let Some(mut stdin) = child.stdin.take() {
1135        let payload =
1136            serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
1137        stdin
1138            .write_all(&payload)
1139            .await
1140            .map_err(|e| format!("failed to write to stdin: {e}"))?;
1141        drop(stdin);
1142    }
1143
1144    let output = child
1145        .wait_with_output()
1146        .await
1147        .map_err(|e| format!("failed to read output: {e}"))?;
1148
1149    if !output.status.success() {
1150        let code = output.status.code().unwrap_or(1);
1151        let stderr_hint = String::from_utf8_lossy(&output.stderr);
1152        return Err(format!(
1153            "script exited with code {code}{}",
1154            if stderr_hint.is_empty() {
1155                String::new()
1156            } else {
1157                format!(": {}", stderr_hint.trim())
1158            }
1159        ));
1160    }
1161
1162    serde_json::from_slice(&output.stdout)
1163        .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1164}
1165
1166/// Execute a module by ID: validate → collect input → validate schema
1167/// → approve → execute → audit → output.
1168///
1169/// Calls `std::process::exit` with the appropriate code; never returns normally.
1170pub async fn dispatch_module(
1171    module_id: &str,
1172    matches: &clap::ArgMatches,
1173    registry: &Arc<dyn crate::discovery::RegistryProvider>,
1174    _executor: &Arc<dyn ModuleExecutor + 'static>,
1175    apcore_executor: &apcore::Executor,
1176) -> ! {
1177    use crate::{
1178        EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1179        EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1180    };
1181
1182    // 1. Validate module ID format (exit 2 on bad format).
1183    if let Err(e) = validate_module_id(module_id) {
1184        eprintln!("Error: Invalid module ID format: '{module_id}'.");
1185        let _ = e;
1186        std::process::exit(EXIT_INVALID_INPUT);
1187    }
1188
1189    // 2. Registry lookup (exit 44 if not found).
1190    let module_def = match registry.get_module_descriptor(module_id) {
1191        Some(def) => def,
1192        None => {
1193            eprintln!("Error: Module '{module_id}' not found in registry.");
1194            std::process::exit(EXIT_MODULE_NOT_FOUND);
1195        }
1196    };
1197
1198    // 3. Extract built-in flags from matches.
1199    let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1200    let auto_approve = matches.get_flag("yes");
1201    let large_input = matches.get_flag("large-input");
1202    let format_flag = matches.get_one::<String>("format").cloned();
1203    let fields_flag = matches.get_one::<String>("fields").cloned();
1204    let dry_run = matches.get_flag("dry-run");
1205    let trace_flag = matches.get_flag("trace");
1206    let stream_flag = matches.get_flag("stream");
1207    let strategy_name = matches.get_one::<String>("strategy").cloned();
1208    let _approval_timeout = matches.get_one::<String>("approval-timeout");
1209    let approval_token = matches.get_one::<String>("approval-token").cloned();
1210
1211    // 4. Build CLI kwargs from schema-derived flags (stub: empty map).
1212    let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1213
1214    // 5. Collect and merge input (exit 2 on errors).
1215    let mut merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1216        Ok(m) => m,
1217        Err(CliError::InputTooLarge { .. }) => {
1218            eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1219            std::process::exit(EXIT_INVALID_INPUT);
1220        }
1221        Err(CliError::JsonParse(detail)) => {
1222            eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1223            std::process::exit(EXIT_INVALID_INPUT);
1224        }
1225        Err(CliError::NotAnObject) => {
1226            eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1227            std::process::exit(EXIT_INVALID_INPUT);
1228        }
1229        Err(e) => {
1230            eprintln!("Error: {e}");
1231            std::process::exit(EXIT_INVALID_INPUT);
1232        }
1233    };
1234
1235    // -- F1: Dry-run / validate: preflight only, no execution --
1236    if dry_run {
1237        // --trace --dry-run: show pipeline preview after preflight result.
1238        let show_trace_preview = trace_flag;
1239        let print_pipeline_preview = || {
1240            if show_trace_preview {
1241                let pure_steps = [
1242                    "context_creation",
1243                    "call_chain_guard",
1244                    "module_lookup",
1245                    "acl_check",
1246                    "input_validation",
1247                ];
1248                let all_steps = [
1249                    "context_creation",
1250                    "call_chain_guard",
1251                    "module_lookup",
1252                    "acl_check",
1253                    "approval_gate",
1254                    "middleware_before",
1255                    "input_validation",
1256                    "execute",
1257                    "output_validation",
1258                    "middleware_after",
1259                    "return_result",
1260                ];
1261                eprintln!("\nPipeline preview (dry-run):");
1262                for s in &all_steps {
1263                    if pure_steps.contains(s) {
1264                        eprintln!("  v {:<24} (pure -- would execute)", s);
1265                    } else {
1266                        eprintln!("  o {:<24} (impure -- skipped in dry-run)", s);
1267                    }
1268                }
1269            }
1270        };
1271        let input_value =
1272            serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1273        let preflight_input = serde_json::json!({
1274            "module_id": module_id,
1275            "input": input_value,
1276        });
1277        let result = apcore_executor
1278            .call("system.validate", preflight_input, None, None)
1279            .await;
1280        match result {
1281            Ok(preflight_val) => {
1282                crate::validate::format_preflight_result(&preflight_val, format_flag.as_deref());
1283                print_pipeline_preview();
1284                let valid = preflight_val
1285                    .get("valid")
1286                    .and_then(|v| v.as_bool())
1287                    .unwrap_or(false);
1288                if valid {
1289                    std::process::exit(EXIT_SUCCESS);
1290                } else {
1291                    std::process::exit(crate::EXIT_MODULE_EXECUTE_ERROR);
1292                }
1293            }
1294            Err(_e) => {
1295                tracing::debug!(
1296                    "system.validate call failed: {_e}; falling back to basic schema validation"
1297                );
1298                // Fallback: perform basic schema validation only.
1299                let schema_ok = if let Some(schema) = module_def.input_schema.as_object() {
1300                    if schema.contains_key("properties") {
1301                        validate_against_schema(&merged, &module_def.input_schema).is_ok()
1302                    } else {
1303                        true
1304                    }
1305                } else {
1306                    true
1307                };
1308
1309                let checks = vec![
1310                    serde_json::json!({"check": "module_id", "passed": true}),
1311                    serde_json::json!({"check": "module_lookup", "passed": true}),
1312                    serde_json::json!({"check": "schema", "passed": schema_ok}),
1313                ];
1314                let preflight = serde_json::json!({
1315                    "valid": schema_ok,
1316                    "requires_approval": false,
1317                    "checks": checks,
1318                });
1319                crate::validate::format_preflight_result(&preflight, format_flag.as_deref());
1320                print_pipeline_preview();
1321                if schema_ok {
1322                    std::process::exit(EXIT_SUCCESS);
1323                } else {
1324                    std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1325                }
1326            }
1327        }
1328    }
1329
1330    // 6. Schema validation (if module has input_schema with properties).
1331    if let Some(schema) = module_def.input_schema.as_object() {
1332        if schema.contains_key("properties") {
1333            if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1334                eprintln!("Error: Validation failed: {detail}.");
1335                std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1336            }
1337        }
1338    }
1339
1340    // -- F5: Inject approval token if provided --
1341    if let Some(ref token) = approval_token {
1342        merged.insert("_approval_token".to_string(), Value::String(token.clone()));
1343    }
1344
1345    // 7. Approval gate (exit 46 on denial/timeout).
1346    let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1347    if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
1348        eprintln!("Error: {e}");
1349        std::process::exit(EXIT_APPROVAL_DENIED);
1350    }
1351
1352    // 8. Build merged input as serde_json::Value.
1353    let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1354
1355    // Determine sandbox flag.
1356    let use_sandbox = matches.get_flag("sandbox");
1357
1358    // Check if this module has a script-based executable.
1359    let script_executable = EXECUTABLES
1360        .get()
1361        .and_then(|map| map.get(module_id))
1362        .cloned();
1363
1364    // -- F6: Streaming execution --
1365    if stream_flag {
1366        // Streaming always outputs JSONL; --format table is ignored (spec 3.6.2).
1367        if format_flag.as_deref() == Some("table") {
1368            eprintln!("Warning: Streaming mode always outputs JSONL; --format table is ignored.");
1369        }
1370        let start = std::time::Instant::now();
1371        // Stream outputs as JSONL.
1372        if let Some(exec_path) = script_executable.as_ref() {
1373            // Script-based: fall back to regular execution, output as JSONL.
1374            let res = tokio::select! {
1375                res = execute_script(exec_path, &input_value) => res,
1376                _ = tokio::signal::ctrl_c() => {
1377                    eprintln!("Execution cancelled.");
1378                    std::process::exit(EXIT_SIGINT);
1379                }
1380            };
1381            match res {
1382                Ok(val) => {
1383                    // Output as single JSONL line.
1384                    println!("{}", serde_json::to_string(&val).unwrap_or_default());
1385                    let duration_ms = start.elapsed().as_millis() as u64;
1386                    if let Ok(guard) = AUDIT_LOGGER.lock() {
1387                        if let Some(logger) = guard.as_ref() {
1388                            logger.log_execution(
1389                                module_id,
1390                                &input_value,
1391                                "success",
1392                                0,
1393                                duration_ms,
1394                            );
1395                        }
1396                    }
1397                    std::process::exit(EXIT_SUCCESS);
1398                }
1399                Err(e) => {
1400                    eprintln!("Error: {e}");
1401                    std::process::exit(crate::EXIT_MODULE_EXECUTE_ERROR);
1402                }
1403            }
1404        }
1405        // In-process: use executor.stream() if available, else fall through.
1406        // apcore executor does not expose a stream() method in Rust yet,
1407        // so we fall back to standard call and output as single JSONL line.
1408        let res = tokio::select! {
1409            res = apcore_executor.call(
1410                module_id, input_value.clone(), None, None,
1411            ) => res,
1412            _ = tokio::signal::ctrl_c() => {
1413                eprintln!("Execution cancelled.");
1414                std::process::exit(EXIT_SIGINT);
1415            }
1416        };
1417        let duration_ms = start.elapsed().as_millis() as u64;
1418        match res {
1419            Ok(val) => {
1420                // If result is array, output each element as a JSONL line.
1421                if let Some(arr) = val.as_array() {
1422                    for item in arr {
1423                        println!("{}", serde_json::to_string(item).unwrap_or_default());
1424                    }
1425                } else {
1426                    println!("{}", serde_json::to_string(&val).unwrap_or_default());
1427                }
1428                if let Ok(guard) = AUDIT_LOGGER.lock() {
1429                    if let Some(logger) = guard.as_ref() {
1430                        logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1431                    }
1432                }
1433                std::process::exit(EXIT_SUCCESS);
1434            }
1435            Err(e) => {
1436                let code = map_module_error_to_exit_code(&e);
1437                eprintln!("Error: Module '{module_id}' execution failed: {e}.");
1438                std::process::exit(code);
1439            }
1440        }
1441    }
1442
1443    // -- F4: Traced execution --
1444    if trace_flag {
1445        let start = std::time::Instant::now();
1446        // Use standard call; trace output is simulated from timing data.
1447        // Full PipelineTrace requires call_with_trace(), which may not be
1448        // available on all executor implementations.
1449        let res = tokio::select! {
1450            res = apcore_executor.call(
1451                module_id,
1452                input_value.clone(),
1453                None,
1454                None,
1455            ) => res,
1456            _ = tokio::signal::ctrl_c() => {
1457                eprintln!("Execution cancelled.");
1458                std::process::exit(EXIT_SIGINT);
1459            }
1460        };
1461        let duration_ms = start.elapsed().as_millis() as u64;
1462        match res {
1463            Ok(output) => {
1464                if let Ok(guard) = AUDIT_LOGGER.lock() {
1465                    if let Some(logger) = guard.as_ref() {
1466                        logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1467                    }
1468                }
1469                // Print result with trace appended.
1470                let fmt = crate::output::resolve_format(format_flag.as_deref());
1471                if fmt == "json" {
1472                    // Merge trace stub into JSON output.
1473                    let trace_data = serde_json::json!({
1474                        "strategy": strategy_name.as_deref().unwrap_or("standard"),
1475                        "total_duration_ms": duration_ms,
1476                        "success": true,
1477                    });
1478                    let combined = if output.is_object() {
1479                        let mut obj = output.as_object().unwrap().clone();
1480                        obj.insert("_trace".to_string(), trace_data);
1481                        Value::Object(obj)
1482                    } else {
1483                        serde_json::json!({
1484                            "result": output,
1485                            "_trace": trace_data,
1486                        })
1487                    };
1488                    println!(
1489                        "{}",
1490                        serde_json::to_string_pretty(&combined).unwrap_or_default()
1491                    );
1492                } else {
1493                    let out_str =
1494                        crate::output::format_exec_result(&output, fmt, fields_flag.as_deref());
1495                    println!("{out_str}");
1496                    eprintln!(
1497                        "\nPipeline Trace (strategy: {}, {duration_ms}ms)",
1498                        strategy_name.as_deref().unwrap_or("standard"),
1499                    );
1500                }
1501                std::process::exit(EXIT_SUCCESS);
1502            }
1503            Err(e) => {
1504                let code = map_module_error_to_exit_code(&e);
1505                eprintln!("Error: Module '{module_id}' execution failed: {e}.");
1506                std::process::exit(code);
1507            }
1508        }
1509    }
1510
1511    // 9. Execute with SIGINT race (exit 130 on Ctrl-C).
1512    let start = std::time::Instant::now();
1513
1514    // Unify the execution paths into Result<Value, (i32, String, Option<Value>)>
1515    // where the error tuple is (exit_code, display_message, optional_structured_error).
1516    let result: Result<Value, (i32, String, Option<Value>)> =
1517        if let Some(exec_path) = script_executable {
1518            // Script-based execution: spawn subprocess, pipe JSON via stdin/stdout.
1519            tokio::select! {
1520                res = execute_script(&exec_path, &input_value) => {
1521                    res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e, None))
1522                }
1523                _ = tokio::signal::ctrl_c() => {
1524                    eprintln!("Execution cancelled.");
1525                    std::process::exit(EXIT_SIGINT);
1526                }
1527            }
1528        } else if use_sandbox {
1529            let sandbox = crate::security::Sandbox::new(true, 0);
1530            tokio::select! {
1531                res = sandbox.execute(module_id, input_value.clone()) => {
1532                    res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string(), None))
1533                }
1534                _ = tokio::signal::ctrl_c() => {
1535                    eprintln!("Execution cancelled.");
1536                    std::process::exit(EXIT_SIGINT);
1537                }
1538            }
1539        } else {
1540            // Direct in-process executor call.
1541            // Note: strategy is configured at Executor construction, not per-call.
1542            // The strategy_name flag is available for future use when the Executor
1543            // supports per-call strategy overrides.
1544            tokio::select! {
1545                res = apcore_executor.call(
1546                    module_id,
1547                    input_value.clone(),
1548                    None,
1549                    None,
1550                ) => {
1551                    res.map_err(|e| {
1552                        let code = map_module_error_to_exit_code(&e);
1553                        // Serialize the ModuleError for F3 structured error output.
1554                        let data = serde_json::to_value(&e).ok();
1555                        (code, e.to_string(), data)
1556                    })
1557                }
1558                _ = tokio::signal::ctrl_c() => {
1559                    eprintln!("Execution cancelled.");
1560                    std::process::exit(EXIT_SIGINT);
1561                }
1562            }
1563        };
1564
1565    let duration_ms = start.elapsed().as_millis() as u64;
1566
1567    match result {
1568        Ok(output) => {
1569            // 10. Audit log success.
1570            if let Ok(guard) = AUDIT_LOGGER.lock() {
1571                if let Some(logger) = guard.as_ref() {
1572                    logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1573                }
1574            }
1575            // 11. Format and output (F9: with field selection).
1576            let fmt = crate::output::resolve_format(format_flag.as_deref());
1577            println!(
1578                "{}",
1579                crate::output::format_exec_result(&output, fmt, fields_flag.as_deref(),)
1580            );
1581            std::process::exit(EXIT_SUCCESS);
1582        }
1583        Err((exit_code, msg, error_data)) => {
1584            // Audit log error.
1585            if let Ok(guard) = AUDIT_LOGGER.lock() {
1586                if let Some(logger) = guard.as_ref() {
1587                    logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1588                }
1589            }
1590            // F3: Enhanced error output with structured guidance fields.
1591            if format_flag.as_deref() == Some("json") || !std::io::stderr().is_terminal() {
1592                emit_error_json(module_id, &msg, exit_code, error_data.as_ref());
1593            } else {
1594                emit_error_tty(module_id, &msg, exit_code, error_data.as_ref());
1595            }
1596            std::process::exit(exit_code);
1597        }
1598    }
1599}
1600
1601// ---------------------------------------------------------------------------
1602// Unit tests
1603// ---------------------------------------------------------------------------
1604
1605#[cfg(test)]
1606mod tests {
1607    use super::*;
1608
1609    #[test]
1610    fn test_validate_module_id_valid() {
1611        // Valid IDs must not return an error.
1612        for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1613            let result = validate_module_id(id);
1614            assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1615        }
1616    }
1617
1618    #[test]
1619    fn test_validate_module_id_too_long() {
1620        let long_id = "a".repeat(129);
1621        assert!(validate_module_id(&long_id).is_err());
1622    }
1623
1624    #[test]
1625    fn test_validate_module_id_invalid_format() {
1626        for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1627            assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1628        }
1629    }
1630
1631    #[test]
1632    fn test_validate_module_id_max_length() {
1633        let max_id = "a".repeat(128);
1634        assert!(validate_module_id(&max_id).is_ok());
1635    }
1636
1637    // collect_input tests (TDD red → green)
1638
1639    #[test]
1640    fn test_collect_input_no_stdin_drops_null_values() {
1641        use serde_json::json;
1642        let mut kwargs = HashMap::new();
1643        kwargs.insert("a".to_string(), json!(5));
1644        kwargs.insert("b".to_string(), Value::Null);
1645
1646        let result = collect_input(None, kwargs, false).unwrap();
1647        assert_eq!(result.get("a"), Some(&json!(5)));
1648        assert!(!result.contains_key("b"), "Null values must be dropped");
1649    }
1650
1651    #[test]
1652    fn test_collect_input_stdin_valid_json() {
1653        use serde_json::json;
1654        use std::io::Cursor;
1655        let stdin_bytes = b"{\"x\": 42}";
1656        let reader = Cursor::new(stdin_bytes.to_vec());
1657        let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1658        assert_eq!(result.get("x"), Some(&json!(42)));
1659    }
1660
1661    #[test]
1662    fn test_collect_input_cli_overrides_stdin() {
1663        use serde_json::json;
1664        use std::io::Cursor;
1665        let stdin_bytes = b"{\"a\": 5}";
1666        let reader = Cursor::new(stdin_bytes.to_vec());
1667        let mut kwargs = HashMap::new();
1668        kwargs.insert("a".to_string(), json!(99));
1669        let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1670        assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1671    }
1672
1673    #[test]
1674    fn test_collect_input_oversized_stdin_rejected() {
1675        use std::io::Cursor;
1676        let big = vec![b' '; 10 * 1024 * 1024 + 1];
1677        let reader = Cursor::new(big);
1678        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1679        assert!(matches!(err, CliError::InputTooLarge { .. }));
1680    }
1681
1682    #[test]
1683    fn test_collect_input_large_input_allowed() {
1684        use std::io::Cursor;
1685        let mut payload = b"{\"k\": \"".to_vec();
1686        payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1687        payload.extend(b"\"}");
1688        let reader = Cursor::new(payload);
1689        let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1690        assert!(
1691            result.is_ok(),
1692            "large_input=true must accept oversized payload"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_collect_input_invalid_json_returns_error() {
1698        use std::io::Cursor;
1699        let reader = Cursor::new(b"not json at all".to_vec());
1700        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1701        assert!(matches!(err, CliError::JsonParse(_)));
1702    }
1703
1704    #[test]
1705    fn test_collect_input_non_object_json_returns_error() {
1706        use std::io::Cursor;
1707        let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1708        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1709        assert!(matches!(err, CliError::NotAnObject));
1710    }
1711
1712    #[test]
1713    fn test_collect_input_empty_stdin_returns_empty_map() {
1714        use std::io::Cursor;
1715        let reader = Cursor::new(b"".to_vec());
1716        let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1717        assert!(result.is_empty());
1718    }
1719
1720    #[test]
1721    fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1722        use serde_json::json;
1723        let mut kwargs = HashMap::new();
1724        kwargs.insert("foo".to_string(), json!("bar"));
1725        let result = collect_input(None, kwargs.clone(), false).unwrap();
1726        assert_eq!(result.get("foo"), Some(&json!("bar")));
1727    }
1728
1729    // ---------------------------------------------------------------------------
1730    // build_module_command tests (TDD — RED written before GREEN)
1731    // ---------------------------------------------------------------------------
1732
1733    /// Construct a minimal `ModuleDescriptor` for use in `build_module_command`
1734    /// tests. `input_schema` defaults to a JSON null (no properties) when
1735    /// `schema` is `None`.
1736    fn make_module_descriptor(
1737        name: &str,
1738        _description: &str,
1739        schema: Option<serde_json::Value>,
1740    ) -> apcore::registry::registry::ModuleDescriptor {
1741        apcore::registry::registry::ModuleDescriptor {
1742            name: name.to_string(),
1743            annotations: apcore::module::ModuleAnnotations::default(),
1744            input_schema: schema.unwrap_or(serde_json::Value::Null),
1745            output_schema: serde_json::Value::Object(Default::default()),
1746            enabled: true,
1747            tags: vec![],
1748            dependencies: vec![],
1749        }
1750    }
1751
1752    #[test]
1753    fn test_build_module_command_name_is_set() {
1754        let module = make_module_descriptor("math.add", "Add two numbers", None);
1755        let executor = mock_executor();
1756        let cmd = build_module_command(&module, executor).unwrap();
1757        assert_eq!(cmd.get_name(), "math.add");
1758    }
1759
1760    #[test]
1761    fn test_build_module_command_has_input_flag() {
1762        let module = make_module_descriptor("a.b", "desc", None);
1763        let executor = mock_executor();
1764        let cmd = build_module_command(&module, executor).unwrap();
1765        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1766        assert!(names.contains(&"input"), "must have --input flag");
1767    }
1768
1769    #[test]
1770    fn test_build_module_command_has_yes_flag() {
1771        let module = make_module_descriptor("a.b", "desc", None);
1772        let executor = mock_executor();
1773        let cmd = build_module_command(&module, executor).unwrap();
1774        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1775        assert!(names.contains(&"yes"), "must have --yes flag");
1776    }
1777
1778    #[test]
1779    fn test_build_module_command_has_large_input_flag() {
1780        let module = make_module_descriptor("a.b", "desc", None);
1781        let executor = mock_executor();
1782        let cmd = build_module_command(&module, executor).unwrap();
1783        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1784        assert!(
1785            names.contains(&"large-input"),
1786            "must have --large-input flag"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_build_module_command_has_format_flag() {
1792        let module = make_module_descriptor("a.b", "desc", None);
1793        let executor = mock_executor();
1794        let cmd = build_module_command(&module, executor).unwrap();
1795        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1796        assert!(names.contains(&"format"), "must have --format flag");
1797    }
1798
1799    #[test]
1800    fn test_build_module_command_has_sandbox_flag() {
1801        let module = make_module_descriptor("a.b", "desc", None);
1802        let executor = mock_executor();
1803        let cmd = build_module_command(&module, executor).unwrap();
1804        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1805        assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1806    }
1807
1808    #[test]
1809    fn test_build_module_command_reserved_name_returns_error() {
1810        for reserved in BUILTIN_COMMANDS {
1811            let module = make_module_descriptor(reserved, "desc", None);
1812            let executor = mock_executor();
1813            let result = build_module_command(&module, executor);
1814            assert!(
1815                matches!(result, Err(CliError::ReservedModuleId(_))),
1816                "expected ReservedModuleId for '{reserved}', got {result:?}"
1817            );
1818        }
1819    }
1820
1821    #[test]
1822    fn test_build_module_command_yes_has_short_flag() {
1823        let module = make_module_descriptor("a.b", "desc", None);
1824        let executor = mock_executor();
1825        let cmd = build_module_command(&module, executor).unwrap();
1826        let has_short_y = cmd
1827            .get_opts()
1828            .filter(|a| a.get_long() == Some("yes"))
1829            .any(|a| a.get_short() == Some('y'));
1830        assert!(has_short_y, "--yes must have short flag -y");
1831    }
1832
1833    // ---------------------------------------------------------------------------
1834    // LazyModuleGroup tests (TDD)
1835    // ---------------------------------------------------------------------------
1836
1837    /// Mock registry that returns a fixed list of module names.
1838    struct CliMockRegistry {
1839        modules: Vec<String>,
1840    }
1841
1842    impl crate::discovery::RegistryProvider for CliMockRegistry {
1843        fn list(&self) -> Vec<String> {
1844            self.modules.clone()
1845        }
1846
1847        fn get_definition(&self, name: &str) -> Option<Value> {
1848            if self.modules.iter().any(|m| m == name) {
1849                Some(serde_json::json!({
1850                    "module_id": name,
1851                    "name": name,
1852                    "input_schema": {},
1853                    "output_schema": {},
1854                    "enabled": true,
1855                    "tags": [],
1856                    "dependencies": [],
1857                }))
1858            } else {
1859                None
1860            }
1861        }
1862
1863        fn get_module_descriptor(
1864            &self,
1865            name: &str,
1866        ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1867            if self.modules.iter().any(|m| m == name) {
1868                Some(apcore::registry::registry::ModuleDescriptor {
1869                    name: name.to_string(),
1870                    annotations: apcore::module::ModuleAnnotations::default(),
1871                    input_schema: serde_json::Value::Object(Default::default()),
1872                    output_schema: serde_json::Value::Object(Default::default()),
1873                    enabled: true,
1874                    tags: vec![],
1875                    dependencies: vec![],
1876                })
1877            } else {
1878                None
1879            }
1880        }
1881    }
1882
1883    /// Mock registry that returns an empty list (simulates unavailable registry).
1884    struct EmptyRegistry;
1885
1886    impl crate::discovery::RegistryProvider for EmptyRegistry {
1887        fn list(&self) -> Vec<String> {
1888            vec![]
1889        }
1890
1891        fn get_definition(&self, _name: &str) -> Option<Value> {
1892            None
1893        }
1894    }
1895
1896    /// Mock executor (no-op).
1897    struct MockExecutor;
1898
1899    impl ModuleExecutor for MockExecutor {}
1900
1901    fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1902        Arc::new(CliMockRegistry {
1903            modules: modules.iter().map(|s| s.to_string()).collect(),
1904        })
1905    }
1906
1907    fn mock_executor() -> Arc<dyn ModuleExecutor> {
1908        Arc::new(MockExecutor)
1909    }
1910
1911    #[test]
1912    fn test_lazy_module_group_list_commands_empty_registry() {
1913        let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1914        let cmds = group.list_commands();
1915        for builtin in BUILTIN_COMMANDS {
1916            assert!(
1917                cmds.contains(&builtin.to_string()),
1918                "missing builtin: {builtin}"
1919            );
1920        }
1921        // Result must be sorted.
1922        let mut sorted = cmds.clone();
1923        sorted.sort();
1924        assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1925    }
1926
1927    #[test]
1928    fn test_lazy_module_group_list_commands_includes_modules() {
1929        let group = LazyModuleGroup::new(
1930            mock_registry(vec!["math.add", "text.summarize"]),
1931            mock_executor(),
1932        );
1933        let cmds = group.list_commands();
1934        assert!(cmds.contains(&"math.add".to_string()));
1935        assert!(cmds.contains(&"text.summarize".to_string()));
1936    }
1937
1938    #[test]
1939    fn test_lazy_module_group_list_commands_registry_error() {
1940        let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1941        let cmds = group.list_commands();
1942        // Must not be empty; must contain builtins.
1943        assert!(!cmds.is_empty());
1944        assert!(cmds.contains(&"list".to_string()));
1945    }
1946
1947    #[test]
1948    fn test_lazy_module_group_get_command_builtin() {
1949        let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1950        let cmd = group.get_command("list");
1951        assert!(cmd.is_some(), "get_command('list') must return Some");
1952    }
1953
1954    #[test]
1955    fn test_lazy_module_group_get_command_not_found() {
1956        let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1957        let cmd = group.get_command("nonexistent.module");
1958        assert!(cmd.is_none());
1959    }
1960
1961    #[test]
1962    fn test_lazy_module_group_get_command_caches_module() {
1963        let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1964        // First call builds and caches.
1965        let cmd1 = group.get_command("math.add");
1966        assert!(cmd1.is_some());
1967        // Second call returns from cache — registry lookup should not be called again.
1968        let cmd2 = group.get_command("math.add");
1969        assert!(cmd2.is_some());
1970        assert_eq!(
1971            group.registry_lookup_count(),
1972            1,
1973            "cached after first lookup"
1974        );
1975    }
1976
1977    #[test]
1978    fn test_lazy_module_group_builtin_commands_sorted() {
1979        // BUILTIN_COMMANDS slice must itself be in sorted order (single source of truth).
1980        let mut sorted = BUILTIN_COMMANDS.to_vec();
1981        sorted.sort_unstable();
1982        assert_eq!(
1983            BUILTIN_COMMANDS,
1984            sorted.as_slice(),
1985            "BUILTIN_COMMANDS must be sorted"
1986        );
1987    }
1988
1989    #[test]
1990    fn test_lazy_module_group_list_deduplicates_builtins() {
1991        // If a registry module name collides with a builtin, the result must not
1992        // contain duplicates.
1993        let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1994        let cmds = group.list_commands();
1995        let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1996        assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1997    }
1998
1999    // ---------------------------------------------------------------------------
2000    // map_apcore_error_to_exit_code tests (RED — written before implementation)
2001    // ---------------------------------------------------------------------------
2002
2003    #[test]
2004    fn test_map_error_module_not_found_is_44() {
2005        assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
2006    }
2007
2008    #[test]
2009    fn test_map_error_module_load_error_is_44() {
2010        assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
2011    }
2012
2013    #[test]
2014    fn test_map_error_module_disabled_is_44() {
2015        assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
2016    }
2017
2018    #[test]
2019    fn test_map_error_schema_validation_error_is_45() {
2020        assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
2021    }
2022
2023    #[test]
2024    fn test_map_error_approval_denied_is_46() {
2025        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
2026    }
2027
2028    #[test]
2029    fn test_map_error_approval_timeout_is_46() {
2030        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
2031    }
2032
2033    #[test]
2034    fn test_map_error_approval_pending_is_46() {
2035        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
2036    }
2037
2038    #[test]
2039    fn test_map_error_config_not_found_is_47() {
2040        assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
2041    }
2042
2043    #[test]
2044    fn test_map_error_config_invalid_is_47() {
2045        assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
2046    }
2047
2048    #[test]
2049    fn test_map_error_schema_circular_ref_is_48() {
2050        assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
2051    }
2052
2053    #[test]
2054    fn test_map_error_acl_denied_is_77() {
2055        assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
2056    }
2057
2058    #[test]
2059    fn test_map_error_module_execute_error_is_1() {
2060        assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
2061    }
2062
2063    #[test]
2064    fn test_map_error_module_timeout_is_1() {
2065        assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
2066    }
2067
2068    #[test]
2069    fn test_map_error_unknown_is_1() {
2070        assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
2071    }
2072
2073    #[test]
2074    fn test_map_error_empty_string_is_1() {
2075        assert_eq!(map_apcore_error_to_exit_code(""), 1);
2076    }
2077
2078    // ---------------------------------------------------------------------------
2079    // set_audit_logger implementation tests (RED)
2080    // ---------------------------------------------------------------------------
2081
2082    #[test]
2083    fn test_set_audit_logger_none_clears_logger() {
2084        // Setting None must not panic and must leave AUDIT_LOGGER as None.
2085        set_audit_logger(None);
2086        let guard = AUDIT_LOGGER.lock().unwrap();
2087        assert!(guard.is_none(), "setting None must clear the audit logger");
2088    }
2089
2090    #[test]
2091    fn test_set_audit_logger_some_stores_logger() {
2092        use crate::security::AuditLogger;
2093        set_audit_logger(Some(AuditLogger::new(None)));
2094        let guard = AUDIT_LOGGER.lock().unwrap();
2095        assert!(guard.is_some(), "setting Some must store the audit logger");
2096        // Clean up.
2097        drop(guard);
2098        set_audit_logger(None);
2099    }
2100
2101    // ---------------------------------------------------------------------------
2102    // validate_against_schema tests (RED)
2103    // ---------------------------------------------------------------------------
2104
2105    #[test]
2106    fn test_validate_against_schema_passes_with_no_properties() {
2107        let schema = serde_json::json!({});
2108        let input = std::collections::HashMap::new();
2109        // Schema without properties must not fail.
2110        let result = validate_against_schema(&input, &schema);
2111        assert!(result.is_ok(), "empty schema must pass: {result:?}");
2112    }
2113
2114    #[test]
2115    fn test_validate_against_schema_required_field_missing_fails() {
2116        let schema = serde_json::json!({
2117            "properties": {
2118                "a": {"type": "integer"}
2119            },
2120            "required": ["a"]
2121        });
2122        let input: std::collections::HashMap<String, serde_json::Value> =
2123            std::collections::HashMap::new();
2124        let result = validate_against_schema(&input, &schema);
2125        assert!(result.is_err(), "missing required field must fail");
2126    }
2127
2128    #[test]
2129    fn test_validate_against_schema_required_field_present_passes() {
2130        let schema = serde_json::json!({
2131            "properties": {
2132                "a": {"type": "integer"}
2133            },
2134            "required": ["a"]
2135        });
2136        let mut input = std::collections::HashMap::new();
2137        input.insert("a".to_string(), serde_json::json!(42));
2138        let result = validate_against_schema(&input, &schema);
2139        assert!(
2140            result.is_ok(),
2141            "present required field must pass: {result:?}"
2142        );
2143    }
2144
2145    #[test]
2146    fn test_validate_against_schema_no_required_any_input_passes() {
2147        let schema = serde_json::json!({
2148            "properties": {
2149                "x": {"type": "string"}
2150            }
2151        });
2152        let input: std::collections::HashMap<String, serde_json::Value> =
2153            std::collections::HashMap::new();
2154        let result = validate_against_schema(&input, &schema);
2155        assert!(result.is_ok(), "no required fields: empty input must pass");
2156    }
2157
2158    // -----------------------------------------------------------------
2159    // GroupedModuleGroup tests
2160    // -----------------------------------------------------------------
2161
2162    #[test]
2163    fn test_resolve_group_explicit_group() {
2164        let desc = serde_json::json!({
2165            "module_id": "my.thing",
2166            "metadata": {
2167                "display": {
2168                    "cli": {
2169                        "group": "tools",
2170                        "alias": "thing"
2171                    }
2172                }
2173            }
2174        });
2175        let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
2176        assert_eq!(group, Some("tools".to_string()));
2177        assert_eq!(cmd, "thing");
2178    }
2179
2180    #[test]
2181    fn test_resolve_group_explicit_empty_is_top_level() {
2182        let desc = serde_json::json!({
2183            "module_id": "my.thing",
2184            "metadata": {
2185                "display": {
2186                    "cli": {
2187                        "group": "",
2188                        "alias": "thing"
2189                    }
2190                }
2191            }
2192        });
2193        let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
2194        assert!(group.is_none());
2195        assert_eq!(cmd, "thing");
2196    }
2197
2198    #[test]
2199    fn test_resolve_group_dotted_alias() {
2200        let desc = serde_json::json!({
2201            "module_id": "math.add",
2202            "metadata": {
2203                "display": {
2204                    "cli": { "alias": "math.add" }
2205                }
2206            }
2207        });
2208        let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
2209        assert_eq!(group, Some("math".to_string()));
2210        assert_eq!(cmd, "add");
2211    }
2212
2213    #[test]
2214    fn test_resolve_group_no_dot_is_top_level() {
2215        let desc = serde_json::json!({"module_id": "greet"});
2216        let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
2217        assert!(group.is_none());
2218        assert_eq!(cmd, "greet");
2219    }
2220
2221    #[test]
2222    fn test_resolve_group_dotted_module_id_default() {
2223        // No display metadata -- falls back to module_id with dot
2224        let desc = serde_json::json!({"module_id": "text.upper"});
2225        let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
2226        assert_eq!(group, Some("text".to_string()));
2227        assert_eq!(cmd, "upper");
2228    }
2229
2230    #[test]
2231    fn test_resolve_group_invalid_group_name_top_level() {
2232        // resolve_group returns the raw group name without validation;
2233        // build_group_map validates and falls back to top-level for
2234        // invalid names.
2235        let desc = serde_json::json!({
2236            "module_id": "x",
2237            "metadata": {
2238                "display": {
2239                    "cli": { "group": "123Invalid" }
2240                }
2241            }
2242        });
2243        let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
2244        assert_eq!(group, Some("123Invalid".to_string()));
2245    }
2246
2247    #[test]
2248    fn test_grouped_module_group_list_commands_includes_groups() {
2249        let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
2250        let executor = mock_executor();
2251        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2252        let cmds = gmg.list_commands();
2253        assert!(
2254            cmds.contains(&"math".to_string()),
2255            "must contain group 'math'"
2256        );
2257        // greet has no dot -> top-level
2258        assert!(
2259            cmds.contains(&"greet".to_string()),
2260            "must contain top-level 'greet'"
2261        );
2262    }
2263
2264    #[test]
2265    fn test_grouped_module_group_get_command_group() {
2266        let registry = mock_registry(vec!["math.add", "math.mul"]);
2267        let executor = mock_executor();
2268        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2269        let cmd = gmg.get_command("math");
2270        assert!(cmd.is_some(), "must find group 'math'");
2271        let group_cmd = cmd.unwrap();
2272        let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
2273        assert!(subs.contains(&"add"));
2274        assert!(subs.contains(&"mul"));
2275    }
2276
2277    #[test]
2278    fn test_grouped_module_group_get_command_top_level() {
2279        let registry = mock_registry(vec!["greet"]);
2280        let executor = mock_executor();
2281        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2282        let cmd = gmg.get_command("greet");
2283        assert!(cmd.is_some(), "must find top-level 'greet'");
2284    }
2285
2286    #[test]
2287    fn test_grouped_module_group_get_command_not_found() {
2288        let registry = mock_registry(vec![]);
2289        let executor = mock_executor();
2290        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
2291        assert!(gmg.get_command("nonexistent").is_none());
2292    }
2293
2294    #[test]
2295    fn test_grouped_module_group_help_max_length() {
2296        let registry = mock_registry(vec![]);
2297        let executor = mock_executor();
2298        let gmg = GroupedModuleGroup::new(registry, executor, 42);
2299        assert_eq!(gmg.help_text_max_length(), 42);
2300    }
2301
2302    #[test]
2303    fn test_is_valid_group_name_valid() {
2304        assert!(is_valid_group_name("math"));
2305        assert!(is_valid_group_name("my-group"));
2306        assert!(is_valid_group_name("g1"));
2307        assert!(is_valid_group_name("a_b"));
2308    }
2309
2310    #[test]
2311    fn test_is_valid_group_name_invalid() {
2312        assert!(!is_valid_group_name(""));
2313        assert!(!is_valid_group_name("1abc"));
2314        assert!(!is_valid_group_name("ABC"));
2315        assert!(!is_valid_group_name("a b"));
2316    }
2317}