Skip to main content

apcore_cli/
cli.rs

1// apcore-cli — Core CLI dispatcher.
2// Protocol spec: FE-01 (LazyModuleGroup equivalent, build_module_command,
3//                        collect_input, validate_module_id, set_audit_logger)
4
5use std::collections::HashMap;
6use std::io::Read;
7use std::path::PathBuf;
8use std::sync::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"])
185            .help(
186                "Set output format: 'json' for \
187                 machine-readable, 'table' for \
188                 human-readable",
189            )
190            .hide(hide),
191    )
192    .arg(
193        // --sandbox is always hidden (not yet implemented)
194        Arg::new("sandbox")
195            .long("sandbox")
196            .action(ArgAction::SetTrue)
197            .help(
198                "Run module in an isolated subprocess \
199                 with restricted filesystem and env \
200                 access",
201            )
202            .hide(true),
203    )
204}
205
206/// Build the `exec` clap subcommand.
207///
208/// `exec` runs an apcore module by its fully-qualified module ID.
209pub fn exec_command() -> clap::Command {
210    use clap::{Arg, Command};
211
212    let cmd = Command::new("exec").about("Execute an apcore module").arg(
213        Arg::new("module_id")
214            .required(true)
215            .value_name("MODULE_ID")
216            .help("Fully-qualified module ID to execute"),
217    );
218    add_dispatch_flags(cmd)
219}
220
221// ---------------------------------------------------------------------------
222// LazyModuleGroup — lazy command builder
223// ---------------------------------------------------------------------------
224
225/// Built-in command names that are always present regardless of the registry.
226pub const BUILTIN_COMMANDS: &[&str] = &["completion", "describe", "exec", "init", "list", "man"];
227
228/// Lazy command registry: builds module subcommands on-demand from the
229/// apcore Registry, caching them after first construction.
230///
231/// This is the Rust equivalent of the Python `LazyModuleGroup` (Click group
232/// subclass with lazy `get_command` / `list_commands`).
233pub struct LazyModuleGroup {
234    registry: Arc<dyn crate::discovery::RegistryProvider>,
235    #[allow(dead_code)]
236    executor: Arc<dyn ModuleExecutor>,
237    /// Cache of module name -> name string (we store the name, not the Command,
238    /// since clap::Command is not Clone in all configurations).
239    module_cache: HashMap<String, bool>,
240    /// Count of registry descriptor lookups (test instrumentation only).
241    #[cfg(test)]
242    pub registry_lookup_count: usize,
243}
244
245impl LazyModuleGroup {
246    /// Create a new lazy module group.
247    ///
248    /// # Arguments
249    /// * `registry` — module registry (real or mock)
250    /// * `executor` — module executor (real or mock)
251    pub fn new(
252        registry: Arc<dyn crate::discovery::RegistryProvider>,
253        executor: Arc<dyn ModuleExecutor>,
254    ) -> Self {
255        Self {
256            registry,
257            executor,
258            module_cache: HashMap::new(),
259            #[cfg(test)]
260            registry_lookup_count: 0,
261        }
262    }
263
264    /// Return sorted list of all command names: built-ins + module ids.
265    pub fn list_commands(&self) -> Vec<String> {
266        let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
267        names.extend(self.registry.list());
268        // Sort and dedup in one pass.
269        names.sort_unstable();
270        names.dedup();
271        names
272    }
273
274    /// Look up a command by name. Returns `None` if the name is not a builtin
275    /// and is not found in the registry.
276    ///
277    /// For module commands, builds and caches a lightweight clap Command.
278    pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
279        if BUILTIN_COMMANDS.contains(&name) {
280            return Some(clap::Command::new(name.to_string()));
281        }
282        // Check the in-memory cache first.
283        if self.module_cache.contains_key(name) {
284            return Some(clap::Command::new(name.to_string()));
285        }
286        // Registry lookup.
287        #[cfg(test)]
288        {
289            self.registry_lookup_count += 1;
290        }
291        let _descriptor = self.registry.get_module_descriptor(name)?;
292        let cmd = clap::Command::new(name.to_string());
293        self.module_cache.insert(name.to_string(), true);
294        tracing::debug!("Loaded module command: {name}");
295        Some(cmd)
296    }
297
298    /// Return the number of times the registry was queried for a descriptor.
299    /// Available in test builds only.
300    #[cfg(test)]
301    pub fn registry_lookup_count(&self) -> usize {
302        self.registry_lookup_count
303    }
304}
305
306// ---------------------------------------------------------------------------
307// GroupedModuleGroup -- groups commands by dotted prefix or explicit
308// metadata.display.cli.group
309// ---------------------------------------------------------------------------
310
311/// Grouped command registry: organises modules into sub-command groups
312/// derived from dotted module IDs or explicit display overlay metadata.
313///
314/// This is the Rust equivalent of the Python `GroupedModuleGroup`.
315pub struct GroupedModuleGroup {
316    registry: Arc<dyn crate::discovery::RegistryProvider>,
317    #[allow(dead_code)]
318    executor: Arc<dyn ModuleExecutor>,
319    #[allow(dead_code)]
320    help_text_max_length: usize,
321    group_map: HashMap<String, HashMap<String, (String, Value)>>,
322    top_level_modules: HashMap<String, (String, Value)>,
323    alias_map: HashMap<String, String>,
324    descriptor_cache: HashMap<String, Value>,
325    group_map_built: bool,
326}
327
328impl GroupedModuleGroup {
329    /// Create a new grouped module group.
330    pub fn new(
331        registry: Arc<dyn crate::discovery::RegistryProvider>,
332        executor: Arc<dyn ModuleExecutor>,
333        help_text_max_length: usize,
334    ) -> Self {
335        Self {
336            registry,
337            executor,
338            help_text_max_length,
339            group_map: HashMap::new(),
340            top_level_modules: HashMap::new(),
341            alias_map: HashMap::new(),
342            descriptor_cache: HashMap::new(),
343            group_map_built: false,
344        }
345    }
346
347    /// Resolve the group and command name for a module.
348    ///
349    /// Returns `(Some(group), command)` for grouped modules or
350    /// `(None, command)` for top-level modules.
351    pub fn resolve_group(module_id: &str, descriptor: &Value) -> (Option<String>, String) {
352        let display = crate::display_helpers::get_display(descriptor);
353        let cli = display.get("cli").unwrap_or(&Value::Null);
354
355        // 1. Check explicit group in metadata.display.cli.group
356        if let Some(group_val) = cli.get("group") {
357            if let Some(g) = group_val.as_str() {
358                if g.is_empty() {
359                    // Explicit empty string -> top-level
360                    let alias = cli
361                        .get("alias")
362                        .and_then(|v| v.as_str())
363                        .or_else(|| display.get("alias").and_then(|v| v.as_str()))
364                        .unwrap_or(module_id);
365                    return (None, alias.to_string());
366                }
367                // Explicit non-empty group
368                let alias = cli
369                    .get("alias")
370                    .and_then(|v| v.as_str())
371                    .or_else(|| display.get("alias").and_then(|v| v.as_str()))
372                    .unwrap_or(module_id);
373                return (Some(g.to_string()), alias.to_string());
374            }
375        }
376
377        // 2. Derive alias
378        let alias = cli
379            .get("alias")
380            .and_then(|v| v.as_str())
381            .or_else(|| display.get("alias").and_then(|v| v.as_str()))
382            .unwrap_or(module_id);
383
384        // 3. If alias contains a dot, split on first dot
385        if let Some(dot_pos) = alias.find('.') {
386            let group = &alias[..dot_pos];
387            let cmd = &alias[dot_pos + 1..];
388            return (Some(group.to_string()), cmd.to_string());
389        }
390
391        // 4. No dot -> top-level
392        (None, alias.to_string())
393    }
394
395    /// Build the internal group map from registry modules.
396    pub fn build_group_map(&mut self) {
397        if self.group_map_built {
398            return;
399        }
400        self.group_map_built = true;
401
402        let module_ids = self.registry.list();
403        for mid in &module_ids {
404            let descriptor = match self.registry.get_definition(mid) {
405                Some(d) => d,
406                None => continue,
407            };
408
409            let (group, cmd_name) = Self::resolve_group(mid, &descriptor);
410            self.alias_map.insert(cmd_name.clone(), mid.clone());
411            self.descriptor_cache
412                .insert(mid.clone(), descriptor.clone());
413
414            match group {
415                Some(g) if is_valid_group_name(&g) => {
416                    let entry = self.group_map.entry(g).or_default();
417                    entry.insert(cmd_name, (mid.clone(), descriptor));
418                }
419                Some(g) => {
420                    tracing::warn!(
421                        "Module '{}': group name '{}' is not shell-safe \
422                         -- treating as top-level.",
423                        mid,
424                        g,
425                    );
426                    self.top_level_modules
427                        .insert(cmd_name, (mid.clone(), descriptor));
428                }
429                None => {
430                    self.top_level_modules
431                        .insert(cmd_name, (mid.clone(), descriptor));
432                }
433            }
434        }
435    }
436
437    /// Return sorted list of all command names: builtins + groups +
438    /// top-level modules.
439    pub fn list_commands(&mut self) -> Vec<String> {
440        self.build_group_map();
441        let mut names: Vec<String> = BUILTIN_COMMANDS.iter().map(|s| s.to_string()).collect();
442        for group_name in self.group_map.keys() {
443            names.push(group_name.clone());
444        }
445        for cmd_name in self.top_level_modules.keys() {
446            names.push(cmd_name.clone());
447        }
448        names.sort_unstable();
449        names.dedup();
450        names
451    }
452
453    /// Look up a command by name. Returns a clap Command for a
454    /// group (with subcommands) or a top-level module, or None.
455    pub fn get_command(&mut self, name: &str) -> Option<clap::Command> {
456        self.build_group_map();
457
458        if BUILTIN_COMMANDS.contains(&name) {
459            return Some(clap::Command::new(name.to_string()));
460        }
461
462        // Check groups
463        if let Some(members) = self.group_map.get(name) {
464            let mut group_cmd = clap::Command::new(name.to_string());
465            for (cmd_name, (_mid, _desc)) in members {
466                group_cmd = group_cmd.subcommand(clap::Command::new(cmd_name.clone()));
467            }
468            return Some(group_cmd);
469        }
470
471        // Check top-level modules
472        if self.top_level_modules.contains_key(name) {
473            return Some(clap::Command::new(name.to_string()));
474        }
475
476        None
477    }
478
479    /// Return the help text max length.
480    #[cfg(test)]
481    pub fn help_text_max_length(&self) -> usize {
482        self.help_text_max_length
483    }
484}
485
486/// Validate a group name: must match `^[a-z][a-z0-9_-]*$`.
487fn is_valid_group_name(s: &str) -> bool {
488    if s.is_empty() {
489        return false;
490    }
491    let mut chars = s.chars();
492    match chars.next() {
493        Some(c) if c.is_ascii_lowercase() => {}
494        _ => return false,
495    }
496    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
497}
498
499// ---------------------------------------------------------------------------
500// build_module_command
501// ---------------------------------------------------------------------------
502
503/// Built-in flag names added to every generated module command. A schema
504/// property that collides with one of these names will cause
505/// `std::process::exit(2)`.
506const RESERVED_FLAG_NAMES: &[&str] = &[
507    "input",
508    "yes",
509    "large-input",
510    "format",
511    "sandbox",
512    "verbose",
513];
514
515/// Build a clap `Command` for a single module definition.
516///
517/// The resulting subcommand has:
518/// * its `name` set to `module_def.name`
519/// * its `about` derived from the module descriptor (empty if unavailable)
520/// * the 5 built-in flags: `--input`, `--yes`/`-y`, `--large-input`,
521///   `--format`, `--sandbox`
522/// * schema-derived flags from `schema_to_clap_args` (stub: empty vec)
523///
524/// `executor` is accepted for API symmetry with the Python counterpart but is
525/// not embedded in the `clap::Command` (clap has no user-data attachment).
526/// The executor is passed separately to the dispatch callback.
527///
528/// # Errors
529/// Returns `CliError::ReservedModuleId` when `module_def.name` is one of the
530/// reserved built-in command names.
531pub fn build_module_command(
532    module_def: &apcore::registry::registry::ModuleDescriptor,
533    executor: Arc<dyn ModuleExecutor>,
534) -> Result<clap::Command, CliError> {
535    build_module_command_with_limit(
536        module_def,
537        executor,
538        crate::schema_parser::HELP_TEXT_MAX_LEN,
539    )
540}
541
542/// Build a clap `Command` for a single module definition with a configurable
543/// help text max length.
544pub fn build_module_command_with_limit(
545    module_def: &apcore::registry::registry::ModuleDescriptor,
546    executor: Arc<dyn ModuleExecutor>,
547    help_text_max_length: usize,
548) -> Result<clap::Command, CliError> {
549    let module_id = &module_def.name;
550
551    // Guard: reject reserved command names immediately.
552    if BUILTIN_COMMANDS.contains(&module_id.as_str()) {
553        return Err(CliError::ReservedModuleId(module_id.clone()));
554    }
555
556    // Resolve $ref pointers in the input schema before generating clap args.
557    let resolved_schema =
558        crate::ref_resolver::resolve_refs(&module_def.input_schema, 32, module_id)
559            .unwrap_or_else(|_| module_def.input_schema.clone());
560
561    // Build clap args from JSON Schema properties.
562    let schema_args = crate::schema_parser::schema_to_clap_args_with_limit(
563        &resolved_schema,
564        help_text_max_length,
565    )
566    .map_err(|e| CliError::InvalidModuleId(format!("schema parse error: {e}")))?;
567
568    // Check for schema property names that collide with built-in flags.
569    for arg in &schema_args.args {
570        if let Some(long) = arg.get_long() {
571            if RESERVED_FLAG_NAMES.contains(&long) {
572                return Err(CliError::ReservedModuleId(format!(
573                    "module '{module_id}' schema property '{long}' conflicts \
574                     with a reserved CLI option name"
575                )));
576            }
577        }
578    }
579
580    // Suppress unused-variable warning; executor is kept for API symmetry.
581    let _ = executor;
582
583    let hide = !is_verbose_help();
584
585    // Build after_help footer: verbose hint + optional docs link
586    let mut footer_parts = Vec::new();
587    if hide {
588        footer_parts.push(
589            "Use --verbose to show all options \
590             (including built-in apcore options)."
591                .to_string(),
592        );
593    }
594    if let Some(url) = get_docs_url() {
595        footer_parts.push(format!("Docs: {url}/commands/{module_id}"));
596    }
597    let footer = footer_parts.join("\n");
598
599    let mut cmd = clap::Command::new(module_id.clone())
600        .after_help(footer)
601        // Built-in flags present on every generated command.
602        .arg(
603            clap::Arg::new("input")
604                .long("input")
605                .value_name("SOURCE")
606                .help(
607                    "Read JSON input from a file path, \
608                     or use '-' to read from stdin pipe.",
609                )
610                .hide(hide),
611        )
612        .arg(
613            clap::Arg::new("yes")
614                .long("yes")
615                .short('y')
616                .action(clap::ArgAction::SetTrue)
617                .help(
618                    "Skip interactive approval prompts \
619                     (for scripts and CI).",
620                )
621                .hide(hide),
622        )
623        .arg(
624            clap::Arg::new("large-input")
625                .long("large-input")
626                .action(clap::ArgAction::SetTrue)
627                .help(
628                    "Allow stdin input larger than 10MB \
629                     (default limit protects against \
630                     accidental pipes).",
631                )
632                .hide(hide),
633        )
634        .arg(
635            clap::Arg::new("format")
636                .long("format")
637                .value_parser(["json", "table"])
638                .help(
639                    "Set output format: 'json' for \
640                     machine-readable, 'table' for \
641                     human-readable.",
642                )
643                .hide(hide),
644        )
645        // --sandbox is always hidden (not yet implemented)
646        .arg(
647            clap::Arg::new("sandbox")
648                .long("sandbox")
649                .action(clap::ArgAction::SetTrue)
650                .help(
651                    "Run module in an isolated subprocess \
652                     with restricted filesystem and env \
653                     access.",
654                )
655                .hide(true),
656        );
657
658    // Attach schema-derived args.
659    for arg in schema_args.args {
660        cmd = cmd.arg(arg);
661    }
662
663    Ok(cmd)
664}
665
666// ---------------------------------------------------------------------------
667// collect_input
668// ---------------------------------------------------------------------------
669
670const STDIN_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
671
672/// Inner implementation: accepts any `Read` source for testability.
673///
674/// # Arguments
675/// * `stdin_flag`  — `Some("-")` to read from `reader`, anything else skips STDIN
676/// * `cli_kwargs`  — map of flag name → value (`Null` values are dropped)
677/// * `large_input` — if `false`, reject payloads exceeding `STDIN_SIZE_LIMIT_BYTES`
678/// * `reader`      — byte source to read from when `stdin_flag == Some("-")`
679///
680/// # Errors
681/// Returns `CliError` on oversized input, invalid JSON, or non-object JSON.
682pub fn collect_input_from_reader<R: Read>(
683    stdin_flag: Option<&str>,
684    cli_kwargs: HashMap<String, Value>,
685    large_input: bool,
686    mut reader: R,
687) -> Result<HashMap<String, Value>, CliError> {
688    // Drop Null values from CLI kwargs.
689    let cli_non_null: HashMap<String, Value> = cli_kwargs
690        .into_iter()
691        .filter(|(_, v)| !v.is_null())
692        .collect();
693
694    if stdin_flag != Some("-") {
695        return Ok(cli_non_null);
696    }
697
698    let mut buf = Vec::new();
699    reader
700        .read_to_end(&mut buf)
701        .map_err(|e| CliError::StdinRead(e.to_string()))?;
702
703    if !large_input && buf.len() > STDIN_SIZE_LIMIT_BYTES {
704        return Err(CliError::InputTooLarge {
705            limit: STDIN_SIZE_LIMIT_BYTES,
706            actual: buf.len(),
707        });
708    }
709
710    if buf.is_empty() {
711        return Ok(cli_non_null);
712    }
713
714    let stdin_value: Value =
715        serde_json::from_slice(&buf).map_err(|e| CliError::JsonParse(e.to_string()))?;
716
717    let stdin_map = match stdin_value {
718        Value::Object(m) => m,
719        _ => return Err(CliError::NotAnObject),
720    };
721
722    // Merge: STDIN base, CLI kwargs override on collision.
723    let mut merged: HashMap<String, Value> = stdin_map.into_iter().collect();
724    merged.extend(cli_non_null);
725    Ok(merged)
726}
727
728/// Merge CLI keyword arguments with optional STDIN JSON.
729///
730/// Resolution order (highest priority first):
731/// 1. CLI flags (non-`Null` values in `cli_kwargs`)
732/// 2. STDIN JSON (when `stdin_flag` is `Some("-")`)
733///
734/// # Arguments
735/// * `stdin_flag`  — `Some("-")` to read from STDIN, `None` to skip
736/// * `cli_kwargs`  — map of flag name → value (`Null` values are ignored)
737/// * `large_input` — if `false`, reject STDIN payloads exceeding 10 MiB
738///
739/// # Errors
740/// Returns `CliError` (exit code 2) on oversized input, invalid JSON, or
741/// non-object JSON.
742pub fn collect_input(
743    stdin_flag: Option<&str>,
744    cli_kwargs: HashMap<String, Value>,
745    large_input: bool,
746) -> Result<HashMap<String, Value>, CliError> {
747    collect_input_from_reader(stdin_flag, cli_kwargs, large_input, std::io::stdin())
748}
749
750// ---------------------------------------------------------------------------
751// validate_module_id
752// ---------------------------------------------------------------------------
753
754const MODULE_ID_MAX_LEN: usize = 128;
755
756/// Validate a module identifier.
757///
758/// # Rules
759/// * Maximum 128 characters
760/// * Matches `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`
761/// * No leading/trailing dots, no consecutive dots
762/// * Must not start with a digit or uppercase letter
763///
764/// # Errors
765/// Returns `CliError::InvalidModuleId` (exit code 2) on any violation.
766pub fn validate_module_id(module_id: &str) -> Result<(), CliError> {
767    if module_id.len() > MODULE_ID_MAX_LEN {
768        return Err(CliError::InvalidModuleId(format!(
769            "Invalid module ID format: '{module_id}'. Maximum length is {MODULE_ID_MAX_LEN} characters."
770        )));
771    }
772    if !is_valid_module_id(module_id) {
773        return Err(CliError::InvalidModuleId(format!(
774            "Invalid module ID format: '{module_id}'."
775        )));
776    }
777    Ok(())
778}
779
780/// Hand-written validator matching `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`.
781///
782/// Does not require the `regex` crate.
783#[inline]
784fn is_valid_module_id(s: &str) -> bool {
785    if s.is_empty() {
786        return false;
787    }
788    // Split on '.' and validate each segment individually.
789    for segment in s.split('.') {
790        if segment.is_empty() {
791            // Catches leading dot, trailing dot, and consecutive dots.
792            return false;
793        }
794        let mut chars = segment.chars();
795        // First character must be a lowercase ASCII letter.
796        match chars.next() {
797            Some(c) if c.is_ascii_lowercase() => {}
798            _ => return false,
799        }
800        // Remaining characters: lowercase letter, ASCII digit, or underscore.
801        for c in chars {
802            if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
803                return false;
804            }
805        }
806    }
807    true
808}
809
810// ---------------------------------------------------------------------------
811// Error code mapping
812// ---------------------------------------------------------------------------
813
814/// Map an apcore error code string to the appropriate CLI exit code.
815///
816/// Exit code table:
817/// * `MODULE_NOT_FOUND` / `MODULE_LOAD_ERROR` / `MODULE_DISABLED` → 44
818/// * `SCHEMA_VALIDATION_ERROR`                                     → 45
819/// * `APPROVAL_DENIED` / `APPROVAL_TIMEOUT` / `APPROVAL_PENDING`  → 46
820/// * `CONFIG_NOT_FOUND` / `CONFIG_INVALID`                         → 47
821/// * `SCHEMA_CIRCULAR_REF`                                         → 48
822/// * `ACL_DENIED`                                                  → 77
823/// * everything else (including `MODULE_EXECUTE_ERROR` / `MODULE_TIMEOUT`) → 1
824pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
825    use crate::{
826        EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_BIND_ERROR, EXIT_CONFIG_MOUNT_ERROR,
827        EXIT_CONFIG_NAMESPACE_RESERVED, EXIT_CONFIG_NOT_FOUND, EXIT_ERROR_FORMATTER_DUPLICATE,
828        EXIT_MODULE_EXECUTE_ERROR, EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF,
829        EXIT_SCHEMA_VALIDATION_ERROR,
830    };
831    match error_code {
832        "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
833        "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
834        "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
835        "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
836        "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
837        "ACL_DENIED" => EXIT_ACL_DENIED,
838        // Config Bus errors (apcore >= 0.15.0)
839        "CONFIG_NAMESPACE_RESERVED"
840        | "CONFIG_NAMESPACE_DUPLICATE"
841        | "CONFIG_ENV_PREFIX_CONFLICT" => EXIT_CONFIG_NAMESPACE_RESERVED,
842        "CONFIG_MOUNT_ERROR" => EXIT_CONFIG_MOUNT_ERROR,
843        "CONFIG_BIND_ERROR" => EXIT_CONFIG_BIND_ERROR,
844        "ERROR_FORMATTER_DUPLICATE" => EXIT_ERROR_FORMATTER_DUPLICATE,
845        _ => EXIT_MODULE_EXECUTE_ERROR,
846    }
847}
848
849/// Map an `apcore::errors::ModuleError` directly to an exit code.
850///
851/// Converts the `ErrorCode` enum variant to its SCREAMING_SNAKE_CASE
852/// representation via serde JSON serialisation and delegates to
853/// `map_apcore_error_to_exit_code`.
854pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
855    // Serialise the ErrorCode enum to its SCREAMING_SNAKE_CASE string.
856    let code_str = serde_json::to_value(err.code)
857        .ok()
858        .and_then(|v| v.as_str().map(|s| s.to_string()))
859        .unwrap_or_default();
860    map_apcore_error_to_exit_code(&code_str)
861}
862
863// ---------------------------------------------------------------------------
864// Schema validation helper
865// ---------------------------------------------------------------------------
866
867/// Validate `input` against a JSON Schema object.
868///
869/// This is a lightweight inline checker sufficient until `jsonschema` crate
870/// integration lands (FE-08).  It enforces the `required` array only — if
871/// every field listed in `required` is present in `input`, the call succeeds.
872///
873/// # Errors
874/// Returns `Err(String)` describing the first missing required field.
875pub(crate) fn validate_against_schema(
876    input: &HashMap<String, Value>,
877    schema: &Value,
878) -> Result<(), String> {
879    // Extract "required" array if present.
880    let required = match schema.get("required") {
881        Some(Value::Array(arr)) => arr,
882        _ => return Ok(()),
883    };
884    for req in required {
885        if let Some(field_name) = req.as_str() {
886            if !input.contains_key(field_name) {
887                return Err(format!("required field '{}' is missing", field_name));
888            }
889        }
890    }
891    Ok(())
892}
893
894// ---------------------------------------------------------------------------
895// dispatch_module — full execution pipeline
896// ---------------------------------------------------------------------------
897
898/// Reconcile --flag / --no-flag boolean pairs from ArgMatches into bool values.
899///
900/// For each BoolFlagPair:
901/// - If --flag was set  → prop_name = true
902/// - If --no-flag set   → prop_name = false
903/// - If neither         → prop_name = default_val
904pub fn reconcile_bool_pairs(
905    matches: &clap::ArgMatches,
906    bool_pairs: &[crate::schema_parser::BoolFlagPair],
907) -> HashMap<String, Value> {
908    let mut result = HashMap::new();
909    for pair in bool_pairs {
910        // Use try_get_one to avoid panicking when the flag doesn't exist
911        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
912        let pos_set = matches
913            .try_get_one::<bool>(&pair.prop_name)
914            .ok()
915            .flatten()
916            .copied()
917            .unwrap_or(false);
918        let neg_id = format!("no-{}", pair.prop_name);
919        let neg_set = matches
920            .try_get_one::<bool>(&neg_id)
921            .ok()
922            .flatten()
923            .copied()
924            .unwrap_or(false);
925        let val = if pos_set {
926            true
927        } else if neg_set {
928            false
929        } else {
930            pair.default_val
931        };
932        result.insert(pair.prop_name.clone(), Value::Bool(val));
933    }
934    result
935}
936
937/// Extract schema-derived CLI kwargs from `ArgMatches` for a given module.
938///
939/// Iterates schema properties and extracts string values from clap matches.
940/// Boolean pairs are handled separately via `reconcile_bool_pairs`.
941fn extract_cli_kwargs(
942    matches: &clap::ArgMatches,
943    module_def: &apcore::registry::registry::ModuleDescriptor,
944) -> HashMap<String, Value> {
945    use crate::schema_parser::schema_to_clap_args;
946
947    let schema_args = match schema_to_clap_args(&module_def.input_schema) {
948        Ok(sa) => sa,
949        Err(_) => return HashMap::new(),
950    };
951
952    let mut kwargs: HashMap<String, Value> = HashMap::new();
953
954    // Extract non-boolean schema args as strings (or Null if absent).
955    for arg in &schema_args.args {
956        let id = arg.get_id().as_str().to_string();
957        // Skip the no- counterparts of boolean args.
958        if id.starts_with("no-") {
959            continue;
960        }
961        // Use try_get_one to avoid panicking when the arg doesn't exist
962        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
963        if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
964            kwargs.insert(id, Value::String(val.clone()));
965        } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
966            kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
967        } else {
968            kwargs.insert(id, Value::Null);
969        }
970    }
971
972    // Reconcile boolean pairs.
973    let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
974    kwargs.extend(bool_vals);
975
976    // Apply enum type reconversion.
977    crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
978}
979
980/// Execute a script-based module by spawning the executable as a subprocess.
981///
982/// JSON input is written to stdin; JSON output is read from stdout.
983/// Stderr is captured and included in error messages on failure.
984async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
985    use tokio::io::AsyncWriteExt;
986
987    let mut child = tokio::process::Command::new(executable)
988        .stdin(std::process::Stdio::piped())
989        .stdout(std::process::Stdio::piped())
990        .stderr(std::process::Stdio::piped())
991        .spawn()
992        .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
993
994    // Write JSON input to child stdin then close it.
995    if let Some(mut stdin) = child.stdin.take() {
996        let payload =
997            serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
998        stdin
999            .write_all(&payload)
1000            .await
1001            .map_err(|e| format!("failed to write to stdin: {e}"))?;
1002        drop(stdin);
1003    }
1004
1005    let output = child
1006        .wait_with_output()
1007        .await
1008        .map_err(|e| format!("failed to read output: {e}"))?;
1009
1010    if !output.status.success() {
1011        let code = output.status.code().unwrap_or(1);
1012        let stderr_hint = String::from_utf8_lossy(&output.stderr);
1013        return Err(format!(
1014            "script exited with code {code}{}",
1015            if stderr_hint.is_empty() {
1016                String::new()
1017            } else {
1018                format!(": {}", stderr_hint.trim())
1019            }
1020        ));
1021    }
1022
1023    serde_json::from_slice(&output.stdout)
1024        .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1025}
1026
1027/// Execute a module by ID: validate → collect input → validate schema
1028/// → approve → execute → audit → output.
1029///
1030/// Calls `std::process::exit` with the appropriate code; never returns normally.
1031pub async fn dispatch_module(
1032    module_id: &str,
1033    matches: &clap::ArgMatches,
1034    registry: &Arc<dyn crate::discovery::RegistryProvider>,
1035    _executor: &Arc<dyn ModuleExecutor + 'static>,
1036    apcore_executor: &apcore::Executor,
1037) -> ! {
1038    use crate::{
1039        EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1040        EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1041    };
1042
1043    // 1. Validate module ID format (exit 2 on bad format).
1044    if let Err(e) = validate_module_id(module_id) {
1045        eprintln!("Error: Invalid module ID format: '{module_id}'.");
1046        let _ = e;
1047        std::process::exit(EXIT_INVALID_INPUT);
1048    }
1049
1050    // 2. Registry lookup (exit 44 if not found).
1051    let module_def = match registry.get_module_descriptor(module_id) {
1052        Some(def) => def,
1053        None => {
1054            eprintln!("Error: Module '{module_id}' not found in registry.");
1055            std::process::exit(EXIT_MODULE_NOT_FOUND);
1056        }
1057    };
1058
1059    // 3. Extract built-in flags from matches.
1060    let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1061    let auto_approve = matches.get_flag("yes");
1062    let large_input = matches.get_flag("large-input");
1063    let format_flag = matches.get_one::<String>("format").cloned();
1064
1065    // 4. Build CLI kwargs from schema-derived flags (stub: empty map).
1066    let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1067
1068    // 5. Collect and merge input (exit 2 on errors).
1069    let merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1070        Ok(m) => m,
1071        Err(CliError::InputTooLarge { .. }) => {
1072            eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1073            std::process::exit(EXIT_INVALID_INPUT);
1074        }
1075        Err(CliError::JsonParse(detail)) => {
1076            eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1077            std::process::exit(EXIT_INVALID_INPUT);
1078        }
1079        Err(CliError::NotAnObject) => {
1080            eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1081            std::process::exit(EXIT_INVALID_INPUT);
1082        }
1083        Err(e) => {
1084            eprintln!("Error: {e}");
1085            std::process::exit(EXIT_INVALID_INPUT);
1086        }
1087    };
1088
1089    // 6. Schema validation (if module has input_schema with properties).
1090    if let Some(schema) = module_def.input_schema.as_object() {
1091        if schema.contains_key("properties") {
1092            if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1093                eprintln!("Error: Validation failed: {detail}.");
1094                std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1095            }
1096        }
1097    }
1098
1099    // 7. Approval gate (exit 46 on denial/timeout).
1100    let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1101    if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
1102        eprintln!("Error: {e}");
1103        std::process::exit(EXIT_APPROVAL_DENIED);
1104    }
1105
1106    // 8. Build merged input as serde_json::Value.
1107    let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1108
1109    // Determine sandbox flag.
1110    let use_sandbox = matches.get_flag("sandbox");
1111
1112    // Check if this module has a script-based executable.
1113    let script_executable = EXECUTABLES
1114        .get()
1115        .and_then(|map| map.get(module_id))
1116        .cloned();
1117
1118    // 9. Execute with SIGINT race (exit 130 on Ctrl-C).
1119    let start = std::time::Instant::now();
1120
1121    // Unify the execution paths into Result<Value, (i32, String)> where
1122    // the error tuple is (exit_code, display_message).
1123    let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
1124        // Script-based execution: spawn subprocess, pipe JSON via stdin/stdout.
1125        tokio::select! {
1126            res = execute_script(&exec_path, &input_value) => {
1127                res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e))
1128            }
1129            _ = tokio::signal::ctrl_c() => {
1130                eprintln!("Execution cancelled.");
1131                std::process::exit(EXIT_SIGINT);
1132            }
1133        }
1134    } else if use_sandbox {
1135        let sandbox = crate::security::Sandbox::new(true, 0);
1136        tokio::select! {
1137            res = sandbox.execute(module_id, input_value.clone()) => {
1138                res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string()))
1139            }
1140            _ = tokio::signal::ctrl_c() => {
1141                eprintln!("Execution cancelled.");
1142                std::process::exit(EXIT_SIGINT);
1143            }
1144        }
1145    } else {
1146        // Direct in-process executor call (4-argument signature).
1147        tokio::select! {
1148            res = apcore_executor.call(module_id, input_value.clone(), None, None) => {
1149                res.map_err(|e| {
1150                    let code = map_module_error_to_exit_code(&e);
1151                    (code, e.to_string())
1152                })
1153            }
1154            _ = tokio::signal::ctrl_c() => {
1155                eprintln!("Execution cancelled.");
1156                std::process::exit(EXIT_SIGINT);
1157            }
1158        }
1159    };
1160
1161    let duration_ms = start.elapsed().as_millis() as u64;
1162
1163    match result {
1164        Ok(output) => {
1165            // 10. Audit log success.
1166            if let Ok(guard) = AUDIT_LOGGER.lock() {
1167                if let Some(logger) = guard.as_ref() {
1168                    logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1169                }
1170            }
1171            // 11. Format and output.
1172            let fmt = crate::output::resolve_format(format_flag.as_deref());
1173            println!("{}", crate::output::format_exec_result(&output, fmt));
1174            std::process::exit(EXIT_SUCCESS);
1175        }
1176        Err((exit_code, msg)) => {
1177            // Audit log error.
1178            if let Ok(guard) = AUDIT_LOGGER.lock() {
1179                if let Some(logger) = guard.as_ref() {
1180                    logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1181                }
1182            }
1183            eprintln!("Error: Module '{module_id}' execution failed: {msg}.");
1184            std::process::exit(exit_code);
1185        }
1186    }
1187}
1188
1189// ---------------------------------------------------------------------------
1190// Unit tests
1191// ---------------------------------------------------------------------------
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    #[test]
1198    fn test_validate_module_id_valid() {
1199        // Valid IDs must not return an error.
1200        for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1201            let result = validate_module_id(id);
1202            assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1203        }
1204    }
1205
1206    #[test]
1207    fn test_validate_module_id_too_long() {
1208        let long_id = "a".repeat(129);
1209        assert!(validate_module_id(&long_id).is_err());
1210    }
1211
1212    #[test]
1213    fn test_validate_module_id_invalid_format() {
1214        for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1215            assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1216        }
1217    }
1218
1219    #[test]
1220    fn test_validate_module_id_max_length() {
1221        let max_id = "a".repeat(128);
1222        assert!(validate_module_id(&max_id).is_ok());
1223    }
1224
1225    // collect_input tests (TDD red → green)
1226
1227    #[test]
1228    fn test_collect_input_no_stdin_drops_null_values() {
1229        use serde_json::json;
1230        let mut kwargs = HashMap::new();
1231        kwargs.insert("a".to_string(), json!(5));
1232        kwargs.insert("b".to_string(), Value::Null);
1233
1234        let result = collect_input(None, kwargs, false).unwrap();
1235        assert_eq!(result.get("a"), Some(&json!(5)));
1236        assert!(!result.contains_key("b"), "Null values must be dropped");
1237    }
1238
1239    #[test]
1240    fn test_collect_input_stdin_valid_json() {
1241        use serde_json::json;
1242        use std::io::Cursor;
1243        let stdin_bytes = b"{\"x\": 42}";
1244        let reader = Cursor::new(stdin_bytes.to_vec());
1245        let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1246        assert_eq!(result.get("x"), Some(&json!(42)));
1247    }
1248
1249    #[test]
1250    fn test_collect_input_cli_overrides_stdin() {
1251        use serde_json::json;
1252        use std::io::Cursor;
1253        let stdin_bytes = b"{\"a\": 5}";
1254        let reader = Cursor::new(stdin_bytes.to_vec());
1255        let mut kwargs = HashMap::new();
1256        kwargs.insert("a".to_string(), json!(99));
1257        let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1258        assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1259    }
1260
1261    #[test]
1262    fn test_collect_input_oversized_stdin_rejected() {
1263        use std::io::Cursor;
1264        let big = vec![b' '; 10 * 1024 * 1024 + 1];
1265        let reader = Cursor::new(big);
1266        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1267        assert!(matches!(err, CliError::InputTooLarge { .. }));
1268    }
1269
1270    #[test]
1271    fn test_collect_input_large_input_allowed() {
1272        use std::io::Cursor;
1273        let mut payload = b"{\"k\": \"".to_vec();
1274        payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1275        payload.extend(b"\"}");
1276        let reader = Cursor::new(payload);
1277        let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1278        assert!(
1279            result.is_ok(),
1280            "large_input=true must accept oversized payload"
1281        );
1282    }
1283
1284    #[test]
1285    fn test_collect_input_invalid_json_returns_error() {
1286        use std::io::Cursor;
1287        let reader = Cursor::new(b"not json at all".to_vec());
1288        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1289        assert!(matches!(err, CliError::JsonParse(_)));
1290    }
1291
1292    #[test]
1293    fn test_collect_input_non_object_json_returns_error() {
1294        use std::io::Cursor;
1295        let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1296        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1297        assert!(matches!(err, CliError::NotAnObject));
1298    }
1299
1300    #[test]
1301    fn test_collect_input_empty_stdin_returns_empty_map() {
1302        use std::io::Cursor;
1303        let reader = Cursor::new(b"".to_vec());
1304        let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1305        assert!(result.is_empty());
1306    }
1307
1308    #[test]
1309    fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1310        use serde_json::json;
1311        let mut kwargs = HashMap::new();
1312        kwargs.insert("foo".to_string(), json!("bar"));
1313        let result = collect_input(None, kwargs.clone(), false).unwrap();
1314        assert_eq!(result.get("foo"), Some(&json!("bar")));
1315    }
1316
1317    // ---------------------------------------------------------------------------
1318    // build_module_command tests (TDD — RED written before GREEN)
1319    // ---------------------------------------------------------------------------
1320
1321    /// Construct a minimal `ModuleDescriptor` for use in `build_module_command`
1322    /// tests. `input_schema` defaults to a JSON null (no properties) when
1323    /// `schema` is `None`.
1324    fn make_module_descriptor(
1325        name: &str,
1326        _description: &str,
1327        schema: Option<serde_json::Value>,
1328    ) -> apcore::registry::registry::ModuleDescriptor {
1329        apcore::registry::registry::ModuleDescriptor {
1330            name: name.to_string(),
1331            annotations: apcore::module::ModuleAnnotations::default(),
1332            input_schema: schema.unwrap_or(serde_json::Value::Null),
1333            output_schema: serde_json::Value::Object(Default::default()),
1334            enabled: true,
1335            tags: vec![],
1336            dependencies: vec![],
1337        }
1338    }
1339
1340    #[test]
1341    fn test_build_module_command_name_is_set() {
1342        let module = make_module_descriptor("math.add", "Add two numbers", None);
1343        let executor = mock_executor();
1344        let cmd = build_module_command(&module, executor).unwrap();
1345        assert_eq!(cmd.get_name(), "math.add");
1346    }
1347
1348    #[test]
1349    fn test_build_module_command_has_input_flag() {
1350        let module = make_module_descriptor("a.b", "desc", None);
1351        let executor = mock_executor();
1352        let cmd = build_module_command(&module, executor).unwrap();
1353        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1354        assert!(names.contains(&"input"), "must have --input flag");
1355    }
1356
1357    #[test]
1358    fn test_build_module_command_has_yes_flag() {
1359        let module = make_module_descriptor("a.b", "desc", None);
1360        let executor = mock_executor();
1361        let cmd = build_module_command(&module, executor).unwrap();
1362        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1363        assert!(names.contains(&"yes"), "must have --yes flag");
1364    }
1365
1366    #[test]
1367    fn test_build_module_command_has_large_input_flag() {
1368        let module = make_module_descriptor("a.b", "desc", None);
1369        let executor = mock_executor();
1370        let cmd = build_module_command(&module, executor).unwrap();
1371        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1372        assert!(
1373            names.contains(&"large-input"),
1374            "must have --large-input flag"
1375        );
1376    }
1377
1378    #[test]
1379    fn test_build_module_command_has_format_flag() {
1380        let module = make_module_descriptor("a.b", "desc", None);
1381        let executor = mock_executor();
1382        let cmd = build_module_command(&module, executor).unwrap();
1383        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1384        assert!(names.contains(&"format"), "must have --format flag");
1385    }
1386
1387    #[test]
1388    fn test_build_module_command_has_sandbox_flag() {
1389        let module = make_module_descriptor("a.b", "desc", None);
1390        let executor = mock_executor();
1391        let cmd = build_module_command(&module, executor).unwrap();
1392        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1393        assert!(names.contains(&"sandbox"), "must have --sandbox flag");
1394    }
1395
1396    #[test]
1397    fn test_build_module_command_reserved_name_returns_error() {
1398        for reserved in BUILTIN_COMMANDS {
1399            let module = make_module_descriptor(reserved, "desc", None);
1400            let executor = mock_executor();
1401            let result = build_module_command(&module, executor);
1402            assert!(
1403                matches!(result, Err(CliError::ReservedModuleId(_))),
1404                "expected ReservedModuleId for '{reserved}', got {result:?}"
1405            );
1406        }
1407    }
1408
1409    #[test]
1410    fn test_build_module_command_yes_has_short_flag() {
1411        let module = make_module_descriptor("a.b", "desc", None);
1412        let executor = mock_executor();
1413        let cmd = build_module_command(&module, executor).unwrap();
1414        let has_short_y = cmd
1415            .get_opts()
1416            .filter(|a| a.get_long() == Some("yes"))
1417            .any(|a| a.get_short() == Some('y'));
1418        assert!(has_short_y, "--yes must have short flag -y");
1419    }
1420
1421    // ---------------------------------------------------------------------------
1422    // LazyModuleGroup tests (TDD)
1423    // ---------------------------------------------------------------------------
1424
1425    /// Mock registry that returns a fixed list of module names.
1426    struct CliMockRegistry {
1427        modules: Vec<String>,
1428    }
1429
1430    impl crate::discovery::RegistryProvider for CliMockRegistry {
1431        fn list(&self) -> Vec<String> {
1432            self.modules.clone()
1433        }
1434
1435        fn get_definition(&self, name: &str) -> Option<Value> {
1436            if self.modules.iter().any(|m| m == name) {
1437                Some(serde_json::json!({
1438                    "module_id": name,
1439                    "name": name,
1440                    "input_schema": {},
1441                    "output_schema": {},
1442                    "enabled": true,
1443                    "tags": [],
1444                    "dependencies": [],
1445                }))
1446            } else {
1447                None
1448            }
1449        }
1450
1451        fn get_module_descriptor(
1452            &self,
1453            name: &str,
1454        ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1455            if self.modules.iter().any(|m| m == name) {
1456                Some(apcore::registry::registry::ModuleDescriptor {
1457                    name: name.to_string(),
1458                    annotations: apcore::module::ModuleAnnotations::default(),
1459                    input_schema: serde_json::Value::Object(Default::default()),
1460                    output_schema: serde_json::Value::Object(Default::default()),
1461                    enabled: true,
1462                    tags: vec![],
1463                    dependencies: vec![],
1464                })
1465            } else {
1466                None
1467            }
1468        }
1469    }
1470
1471    /// Mock registry that returns an empty list (simulates unavailable registry).
1472    struct EmptyRegistry;
1473
1474    impl crate::discovery::RegistryProvider for EmptyRegistry {
1475        fn list(&self) -> Vec<String> {
1476            vec![]
1477        }
1478
1479        fn get_definition(&self, _name: &str) -> Option<Value> {
1480            None
1481        }
1482    }
1483
1484    /// Mock executor (no-op).
1485    struct MockExecutor;
1486
1487    impl ModuleExecutor for MockExecutor {}
1488
1489    fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1490        Arc::new(CliMockRegistry {
1491            modules: modules.iter().map(|s| s.to_string()).collect(),
1492        })
1493    }
1494
1495    fn mock_executor() -> Arc<dyn ModuleExecutor> {
1496        Arc::new(MockExecutor)
1497    }
1498
1499    #[test]
1500    fn test_lazy_module_group_list_commands_empty_registry() {
1501        let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1502        let cmds = group.list_commands();
1503        for builtin in ["exec", "list", "describe", "completion", "init", "man"] {
1504            assert!(
1505                cmds.contains(&builtin.to_string()),
1506                "missing builtin: {builtin}"
1507            );
1508        }
1509        // Result must be sorted.
1510        let mut sorted = cmds.clone();
1511        sorted.sort();
1512        assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1513    }
1514
1515    #[test]
1516    fn test_lazy_module_group_list_commands_includes_modules() {
1517        let group = LazyModuleGroup::new(
1518            mock_registry(vec!["math.add", "text.summarize"]),
1519            mock_executor(),
1520        );
1521        let cmds = group.list_commands();
1522        assert!(cmds.contains(&"math.add".to_string()));
1523        assert!(cmds.contains(&"text.summarize".to_string()));
1524    }
1525
1526    #[test]
1527    fn test_lazy_module_group_list_commands_registry_error() {
1528        let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1529        let cmds = group.list_commands();
1530        // Must not be empty; must contain builtins.
1531        assert!(!cmds.is_empty());
1532        assert!(cmds.contains(&"list".to_string()));
1533    }
1534
1535    #[test]
1536    fn test_lazy_module_group_get_command_builtin() {
1537        let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1538        let cmd = group.get_command("list");
1539        assert!(cmd.is_some(), "get_command('list') must return Some");
1540    }
1541
1542    #[test]
1543    fn test_lazy_module_group_get_command_not_found() {
1544        let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1545        let cmd = group.get_command("nonexistent.module");
1546        assert!(cmd.is_none());
1547    }
1548
1549    #[test]
1550    fn test_lazy_module_group_get_command_caches_module() {
1551        let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1552        // First call builds and caches.
1553        let cmd1 = group.get_command("math.add");
1554        assert!(cmd1.is_some());
1555        // Second call returns from cache — registry lookup should not be called again.
1556        let cmd2 = group.get_command("math.add");
1557        assert!(cmd2.is_some());
1558        assert_eq!(
1559            group.registry_lookup_count(),
1560            1,
1561            "cached after first lookup"
1562        );
1563    }
1564
1565    #[test]
1566    fn test_lazy_module_group_builtin_commands_sorted() {
1567        // BUILTIN_COMMANDS slice must itself be in sorted order (single source of truth).
1568        let mut sorted = BUILTIN_COMMANDS.to_vec();
1569        sorted.sort_unstable();
1570        assert_eq!(
1571            BUILTIN_COMMANDS,
1572            sorted.as_slice(),
1573            "BUILTIN_COMMANDS must be sorted"
1574        );
1575    }
1576
1577    #[test]
1578    fn test_lazy_module_group_list_deduplicates_builtins() {
1579        // If a registry module name collides with a builtin, the result must not
1580        // contain duplicates.
1581        let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1582        let cmds = group.list_commands();
1583        let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1584        assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1585    }
1586
1587    // ---------------------------------------------------------------------------
1588    // map_apcore_error_to_exit_code tests (RED — written before implementation)
1589    // ---------------------------------------------------------------------------
1590
1591    #[test]
1592    fn test_map_error_module_not_found_is_44() {
1593        assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1594    }
1595
1596    #[test]
1597    fn test_map_error_module_load_error_is_44() {
1598        assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1599    }
1600
1601    #[test]
1602    fn test_map_error_module_disabled_is_44() {
1603        assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1604    }
1605
1606    #[test]
1607    fn test_map_error_schema_validation_error_is_45() {
1608        assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1609    }
1610
1611    #[test]
1612    fn test_map_error_approval_denied_is_46() {
1613        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1614    }
1615
1616    #[test]
1617    fn test_map_error_approval_timeout_is_46() {
1618        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1619    }
1620
1621    #[test]
1622    fn test_map_error_approval_pending_is_46() {
1623        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1624    }
1625
1626    #[test]
1627    fn test_map_error_config_not_found_is_47() {
1628        assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1629    }
1630
1631    #[test]
1632    fn test_map_error_config_invalid_is_47() {
1633        assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1634    }
1635
1636    #[test]
1637    fn test_map_error_schema_circular_ref_is_48() {
1638        assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1639    }
1640
1641    #[test]
1642    fn test_map_error_acl_denied_is_77() {
1643        assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1644    }
1645
1646    #[test]
1647    fn test_map_error_module_execute_error_is_1() {
1648        assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1649    }
1650
1651    #[test]
1652    fn test_map_error_module_timeout_is_1() {
1653        assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1654    }
1655
1656    #[test]
1657    fn test_map_error_unknown_is_1() {
1658        assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1659    }
1660
1661    #[test]
1662    fn test_map_error_empty_string_is_1() {
1663        assert_eq!(map_apcore_error_to_exit_code(""), 1);
1664    }
1665
1666    // ---------------------------------------------------------------------------
1667    // set_audit_logger implementation tests (RED)
1668    // ---------------------------------------------------------------------------
1669
1670    #[test]
1671    fn test_set_audit_logger_none_clears_logger() {
1672        // Setting None must not panic and must leave AUDIT_LOGGER as None.
1673        set_audit_logger(None);
1674        let guard = AUDIT_LOGGER.lock().unwrap();
1675        assert!(guard.is_none(), "setting None must clear the audit logger");
1676    }
1677
1678    #[test]
1679    fn test_set_audit_logger_some_stores_logger() {
1680        use crate::security::AuditLogger;
1681        set_audit_logger(Some(AuditLogger::new(None)));
1682        let guard = AUDIT_LOGGER.lock().unwrap();
1683        assert!(guard.is_some(), "setting Some must store the audit logger");
1684        // Clean up.
1685        drop(guard);
1686        set_audit_logger(None);
1687    }
1688
1689    // ---------------------------------------------------------------------------
1690    // validate_against_schema tests (RED)
1691    // ---------------------------------------------------------------------------
1692
1693    #[test]
1694    fn test_validate_against_schema_passes_with_no_properties() {
1695        let schema = serde_json::json!({});
1696        let input = std::collections::HashMap::new();
1697        // Schema without properties must not fail.
1698        let result = validate_against_schema(&input, &schema);
1699        assert!(result.is_ok(), "empty schema must pass: {result:?}");
1700    }
1701
1702    #[test]
1703    fn test_validate_against_schema_required_field_missing_fails() {
1704        let schema = serde_json::json!({
1705            "properties": {
1706                "a": {"type": "integer"}
1707            },
1708            "required": ["a"]
1709        });
1710        let input: std::collections::HashMap<String, serde_json::Value> =
1711            std::collections::HashMap::new();
1712        let result = validate_against_schema(&input, &schema);
1713        assert!(result.is_err(), "missing required field must fail");
1714    }
1715
1716    #[test]
1717    fn test_validate_against_schema_required_field_present_passes() {
1718        let schema = serde_json::json!({
1719            "properties": {
1720                "a": {"type": "integer"}
1721            },
1722            "required": ["a"]
1723        });
1724        let mut input = std::collections::HashMap::new();
1725        input.insert("a".to_string(), serde_json::json!(42));
1726        let result = validate_against_schema(&input, &schema);
1727        assert!(
1728            result.is_ok(),
1729            "present required field must pass: {result:?}"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_validate_against_schema_no_required_any_input_passes() {
1735        let schema = serde_json::json!({
1736            "properties": {
1737                "x": {"type": "string"}
1738            }
1739        });
1740        let input: std::collections::HashMap<String, serde_json::Value> =
1741            std::collections::HashMap::new();
1742        let result = validate_against_schema(&input, &schema);
1743        assert!(result.is_ok(), "no required fields: empty input must pass");
1744    }
1745
1746    // -----------------------------------------------------------------
1747    // GroupedModuleGroup tests
1748    // -----------------------------------------------------------------
1749
1750    #[test]
1751    fn test_resolve_group_explicit_group() {
1752        let desc = serde_json::json!({
1753            "module_id": "my.thing",
1754            "metadata": {
1755                "display": {
1756                    "cli": {
1757                        "group": "tools",
1758                        "alias": "thing"
1759                    }
1760                }
1761            }
1762        });
1763        let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1764        assert_eq!(group, Some("tools".to_string()));
1765        assert_eq!(cmd, "thing");
1766    }
1767
1768    #[test]
1769    fn test_resolve_group_explicit_empty_is_top_level() {
1770        let desc = serde_json::json!({
1771            "module_id": "my.thing",
1772            "metadata": {
1773                "display": {
1774                    "cli": {
1775                        "group": "",
1776                        "alias": "thing"
1777                    }
1778                }
1779            }
1780        });
1781        let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1782        assert!(group.is_none());
1783        assert_eq!(cmd, "thing");
1784    }
1785
1786    #[test]
1787    fn test_resolve_group_dotted_alias() {
1788        let desc = serde_json::json!({
1789            "module_id": "math.add",
1790            "metadata": {
1791                "display": {
1792                    "cli": { "alias": "math.add" }
1793                }
1794            }
1795        });
1796        let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
1797        assert_eq!(group, Some("math".to_string()));
1798        assert_eq!(cmd, "add");
1799    }
1800
1801    #[test]
1802    fn test_resolve_group_no_dot_is_top_level() {
1803        let desc = serde_json::json!({"module_id": "greet"});
1804        let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
1805        assert!(group.is_none());
1806        assert_eq!(cmd, "greet");
1807    }
1808
1809    #[test]
1810    fn test_resolve_group_dotted_module_id_default() {
1811        // No display metadata -- falls back to module_id with dot
1812        let desc = serde_json::json!({"module_id": "text.upper"});
1813        let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
1814        assert_eq!(group, Some("text".to_string()));
1815        assert_eq!(cmd, "upper");
1816    }
1817
1818    #[test]
1819    fn test_resolve_group_invalid_group_name_top_level() {
1820        // resolve_group returns the raw group name without validation;
1821        // build_group_map validates and falls back to top-level for
1822        // invalid names.
1823        let desc = serde_json::json!({
1824            "module_id": "x",
1825            "metadata": {
1826                "display": {
1827                    "cli": { "group": "123Invalid" }
1828                }
1829            }
1830        });
1831        let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
1832        assert_eq!(group, Some("123Invalid".to_string()));
1833    }
1834
1835    #[test]
1836    fn test_grouped_module_group_list_commands_includes_groups() {
1837        let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
1838        let executor = mock_executor();
1839        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1840        let cmds = gmg.list_commands();
1841        assert!(
1842            cmds.contains(&"math".to_string()),
1843            "must contain group 'math'"
1844        );
1845        // greet has no dot -> top-level
1846        assert!(
1847            cmds.contains(&"greet".to_string()),
1848            "must contain top-level 'greet'"
1849        );
1850    }
1851
1852    #[test]
1853    fn test_grouped_module_group_get_command_group() {
1854        let registry = mock_registry(vec!["math.add", "math.mul"]);
1855        let executor = mock_executor();
1856        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1857        let cmd = gmg.get_command("math");
1858        assert!(cmd.is_some(), "must find group 'math'");
1859        let group_cmd = cmd.unwrap();
1860        let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
1861        assert!(subs.contains(&"add"));
1862        assert!(subs.contains(&"mul"));
1863    }
1864
1865    #[test]
1866    fn test_grouped_module_group_get_command_top_level() {
1867        let registry = mock_registry(vec!["greet"]);
1868        let executor = mock_executor();
1869        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1870        let cmd = gmg.get_command("greet");
1871        assert!(cmd.is_some(), "must find top-level 'greet'");
1872    }
1873
1874    #[test]
1875    fn test_grouped_module_group_get_command_not_found() {
1876        let registry = mock_registry(vec![]);
1877        let executor = mock_executor();
1878        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1879        assert!(gmg.get_command("nonexistent").is_none());
1880    }
1881
1882    #[test]
1883    fn test_grouped_module_group_help_max_length() {
1884        let registry = mock_registry(vec![]);
1885        let executor = mock_executor();
1886        let gmg = GroupedModuleGroup::new(registry, executor, 42);
1887        assert_eq!(gmg.help_text_max_length(), 42);
1888    }
1889
1890    #[test]
1891    fn test_is_valid_group_name_valid() {
1892        assert!(is_valid_group_name("math"));
1893        assert!(is_valid_group_name("my-group"));
1894        assert!(is_valid_group_name("g1"));
1895        assert!(is_valid_group_name("a_b"));
1896    }
1897
1898    #[test]
1899    fn test_is_valid_group_name_invalid() {
1900        assert!(!is_valid_group_name(""));
1901        assert!(!is_valid_group_name("1abc"));
1902        assert!(!is_valid_group_name("ABC"));
1903        assert!(!is_valid_group_name("a b"));
1904    }
1905}