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_NOT_FOUND, EXIT_MODULE_EXECUTE_ERROR,
827        EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR,
828    };
829    match error_code {
830        "MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
831        "SCHEMA_VALIDATION_ERROR" => EXIT_SCHEMA_VALIDATION_ERROR,
832        "APPROVAL_DENIED" | "APPROVAL_TIMEOUT" | "APPROVAL_PENDING" => EXIT_APPROVAL_DENIED,
833        "CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
834        "SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
835        "ACL_DENIED" => EXIT_ACL_DENIED,
836        _ => EXIT_MODULE_EXECUTE_ERROR,
837    }
838}
839
840/// Map an `apcore::errors::ModuleError` directly to an exit code.
841///
842/// Converts the `ErrorCode` enum variant to its SCREAMING_SNAKE_CASE
843/// representation via serde JSON serialisation and delegates to
844/// `map_apcore_error_to_exit_code`.
845pub(crate) fn map_module_error_to_exit_code(err: &apcore::errors::ModuleError) -> i32 {
846    // Serialise the ErrorCode enum to its SCREAMING_SNAKE_CASE string.
847    let code_str = serde_json::to_value(err.code)
848        .ok()
849        .and_then(|v| v.as_str().map(|s| s.to_string()))
850        .unwrap_or_default();
851    map_apcore_error_to_exit_code(&code_str)
852}
853
854// ---------------------------------------------------------------------------
855// Schema validation helper
856// ---------------------------------------------------------------------------
857
858/// Validate `input` against a JSON Schema object.
859///
860/// This is a lightweight inline checker sufficient until `jsonschema` crate
861/// integration lands (FE-08).  It enforces the `required` array only — if
862/// every field listed in `required` is present in `input`, the call succeeds.
863///
864/// # Errors
865/// Returns `Err(String)` describing the first missing required field.
866pub(crate) fn validate_against_schema(
867    input: &HashMap<String, Value>,
868    schema: &Value,
869) -> Result<(), String> {
870    // Extract "required" array if present.
871    let required = match schema.get("required") {
872        Some(Value::Array(arr)) => arr,
873        _ => return Ok(()),
874    };
875    for req in required {
876        if let Some(field_name) = req.as_str() {
877            if !input.contains_key(field_name) {
878                return Err(format!("required field '{}' is missing", field_name));
879            }
880        }
881    }
882    Ok(())
883}
884
885// ---------------------------------------------------------------------------
886// dispatch_module — full execution pipeline
887// ---------------------------------------------------------------------------
888
889/// Reconcile --flag / --no-flag boolean pairs from ArgMatches into bool values.
890///
891/// For each BoolFlagPair:
892/// - If --flag was set  → prop_name = true
893/// - If --no-flag set   → prop_name = false
894/// - If neither         → prop_name = default_val
895pub fn reconcile_bool_pairs(
896    matches: &clap::ArgMatches,
897    bool_pairs: &[crate::schema_parser::BoolFlagPair],
898) -> HashMap<String, Value> {
899    let mut result = HashMap::new();
900    for pair in bool_pairs {
901        // Use try_get_one to avoid panicking when the flag doesn't exist
902        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
903        let pos_set = matches
904            .try_get_one::<bool>(&pair.prop_name)
905            .ok()
906            .flatten()
907            .copied()
908            .unwrap_or(false);
909        let neg_id = format!("no-{}", pair.prop_name);
910        let neg_set = matches
911            .try_get_one::<bool>(&neg_id)
912            .ok()
913            .flatten()
914            .copied()
915            .unwrap_or(false);
916        let val = if pos_set {
917            true
918        } else if neg_set {
919            false
920        } else {
921            pair.default_val
922        };
923        result.insert(pair.prop_name.clone(), Value::Bool(val));
924    }
925    result
926}
927
928/// Extract schema-derived CLI kwargs from `ArgMatches` for a given module.
929///
930/// Iterates schema properties and extracts string values from clap matches.
931/// Boolean pairs are handled separately via `reconcile_bool_pairs`.
932fn extract_cli_kwargs(
933    matches: &clap::ArgMatches,
934    module_def: &apcore::registry::registry::ModuleDescriptor,
935) -> HashMap<String, Value> {
936    use crate::schema_parser::schema_to_clap_args;
937
938    let schema_args = match schema_to_clap_args(&module_def.input_schema) {
939        Ok(sa) => sa,
940        Err(_) => return HashMap::new(),
941    };
942
943    let mut kwargs: HashMap<String, Value> = HashMap::new();
944
945    // Extract non-boolean schema args as strings (or Null if absent).
946    for arg in &schema_args.args {
947        let id = arg.get_id().as_str().to_string();
948        // Skip the no- counterparts of boolean args.
949        if id.starts_with("no-") {
950            continue;
951        }
952        // Use try_get_one to avoid panicking when the arg doesn't exist
953        // in ArgMatches (e.g. exec subcommand doesn't have schema-derived flags).
954        if let Ok(Some(val)) = matches.try_get_one::<String>(&id) {
955            kwargs.insert(id, Value::String(val.clone()));
956        } else if let Ok(Some(val)) = matches.try_get_one::<std::path::PathBuf>(&id) {
957            kwargs.insert(id, Value::String(val.to_string_lossy().to_string()));
958        } else {
959            kwargs.insert(id, Value::Null);
960        }
961    }
962
963    // Reconcile boolean pairs.
964    let bool_vals = reconcile_bool_pairs(matches, &schema_args.bool_pairs);
965    kwargs.extend(bool_vals);
966
967    // Apply enum type reconversion.
968    crate::schema_parser::reconvert_enum_values(kwargs, &schema_args)
969}
970
971/// Execute a script-based module by spawning the executable as a subprocess.
972///
973/// JSON input is written to stdin; JSON output is read from stdout.
974/// Stderr is captured and included in error messages on failure.
975async fn execute_script(executable: &std::path::Path, input: &Value) -> Result<Value, String> {
976    use tokio::io::AsyncWriteExt;
977
978    let mut child = tokio::process::Command::new(executable)
979        .stdin(std::process::Stdio::piped())
980        .stdout(std::process::Stdio::piped())
981        .stderr(std::process::Stdio::piped())
982        .spawn()
983        .map_err(|e| format!("failed to spawn {}: {}", executable.display(), e))?;
984
985    // Write JSON input to child stdin then close it.
986    if let Some(mut stdin) = child.stdin.take() {
987        let payload =
988            serde_json::to_vec(input).map_err(|e| format!("failed to serialize input: {e}"))?;
989        stdin
990            .write_all(&payload)
991            .await
992            .map_err(|e| format!("failed to write to stdin: {e}"))?;
993        drop(stdin);
994    }
995
996    let output = child
997        .wait_with_output()
998        .await
999        .map_err(|e| format!("failed to read output: {e}"))?;
1000
1001    if !output.status.success() {
1002        let code = output.status.code().unwrap_or(1);
1003        let stderr_hint = String::from_utf8_lossy(&output.stderr);
1004        return Err(format!(
1005            "script exited with code {code}{}",
1006            if stderr_hint.is_empty() {
1007                String::new()
1008            } else {
1009                format!(": {}", stderr_hint.trim())
1010            }
1011        ));
1012    }
1013
1014    serde_json::from_slice(&output.stdout)
1015        .map_err(|e| format!("script stdout is not valid JSON: {e}"))
1016}
1017
1018/// Execute a module by ID: validate → collect input → validate schema
1019/// → approve → execute → audit → output.
1020///
1021/// Calls `std::process::exit` with the appropriate code; never returns normally.
1022pub async fn dispatch_module(
1023    module_id: &str,
1024    matches: &clap::ArgMatches,
1025    registry: &Arc<dyn crate::discovery::RegistryProvider>,
1026    _executor: &Arc<dyn ModuleExecutor + 'static>,
1027    apcore_executor: &apcore::Executor,
1028) -> ! {
1029    use crate::{
1030        EXIT_APPROVAL_DENIED, EXIT_INVALID_INPUT, EXIT_MODULE_NOT_FOUND,
1031        EXIT_SCHEMA_VALIDATION_ERROR, EXIT_SIGINT, EXIT_SUCCESS,
1032    };
1033
1034    // 1. Validate module ID format (exit 2 on bad format).
1035    if let Err(e) = validate_module_id(module_id) {
1036        eprintln!("Error: Invalid module ID format: '{module_id}'.");
1037        let _ = e;
1038        std::process::exit(EXIT_INVALID_INPUT);
1039    }
1040
1041    // 2. Registry lookup (exit 44 if not found).
1042    let module_def = match registry.get_module_descriptor(module_id) {
1043        Some(def) => def,
1044        None => {
1045            eprintln!("Error: Module '{module_id}' not found in registry.");
1046            std::process::exit(EXIT_MODULE_NOT_FOUND);
1047        }
1048    };
1049
1050    // 3. Extract built-in flags from matches.
1051    let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
1052    let auto_approve = matches.get_flag("yes");
1053    let large_input = matches.get_flag("large-input");
1054    let format_flag = matches.get_one::<String>("format").cloned();
1055
1056    // 4. Build CLI kwargs from schema-derived flags (stub: empty map).
1057    let cli_kwargs = extract_cli_kwargs(matches, &module_def);
1058
1059    // 5. Collect and merge input (exit 2 on errors).
1060    let merged = match collect_input(stdin_flag, cli_kwargs, large_input) {
1061        Ok(m) => m,
1062        Err(CliError::InputTooLarge { .. }) => {
1063            eprintln!("Error: STDIN input exceeds 10MB limit. Use --large-input to override.");
1064            std::process::exit(EXIT_INVALID_INPUT);
1065        }
1066        Err(CliError::JsonParse(detail)) => {
1067            eprintln!("Error: STDIN does not contain valid JSON: {detail}.");
1068            std::process::exit(EXIT_INVALID_INPUT);
1069        }
1070        Err(CliError::NotAnObject) => {
1071            eprintln!("Error: STDIN JSON must be an object, got array or scalar.");
1072            std::process::exit(EXIT_INVALID_INPUT);
1073        }
1074        Err(e) => {
1075            eprintln!("Error: {e}");
1076            std::process::exit(EXIT_INVALID_INPUT);
1077        }
1078    };
1079
1080    // 6. Schema validation (if module has input_schema with properties).
1081    if let Some(schema) = module_def.input_schema.as_object() {
1082        if schema.contains_key("properties") {
1083            if let Err(detail) = validate_against_schema(&merged, &module_def.input_schema) {
1084                eprintln!("Error: Validation failed: {detail}.");
1085                std::process::exit(EXIT_SCHEMA_VALIDATION_ERROR);
1086            }
1087        }
1088    }
1089
1090    // 7. Approval gate (exit 46 on denial/timeout).
1091    let module_json = serde_json::to_value(&module_def).unwrap_or_default();
1092    if let Err(e) = crate::approval::check_approval(&module_json, auto_approve).await {
1093        eprintln!("Error: {e}");
1094        std::process::exit(EXIT_APPROVAL_DENIED);
1095    }
1096
1097    // 8. Build merged input as serde_json::Value.
1098    let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
1099
1100    // Determine sandbox flag.
1101    let use_sandbox = matches.get_flag("sandbox");
1102
1103    // Check if this module has a script-based executable.
1104    let script_executable = EXECUTABLES
1105        .get()
1106        .and_then(|map| map.get(module_id))
1107        .cloned();
1108
1109    // 9. Execute with SIGINT race (exit 130 on Ctrl-C).
1110    let start = std::time::Instant::now();
1111
1112    // Unify the execution paths into Result<Value, (i32, String)> where
1113    // the error tuple is (exit_code, display_message).
1114    let result: Result<Value, (i32, String)> = if let Some(exec_path) = script_executable {
1115        // Script-based execution: spawn subprocess, pipe JSON via stdin/stdout.
1116        tokio::select! {
1117            res = execute_script(&exec_path, &input_value) => {
1118                res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e))
1119            }
1120            _ = tokio::signal::ctrl_c() => {
1121                eprintln!("Execution cancelled.");
1122                std::process::exit(EXIT_SIGINT);
1123            }
1124        }
1125    } else if use_sandbox {
1126        let sandbox = crate::security::Sandbox::new(true, 0);
1127        tokio::select! {
1128            res = sandbox.execute(module_id, input_value.clone()) => {
1129                res.map_err(|e| (crate::EXIT_MODULE_EXECUTE_ERROR, e.to_string()))
1130            }
1131            _ = tokio::signal::ctrl_c() => {
1132                eprintln!("Execution cancelled.");
1133                std::process::exit(EXIT_SIGINT);
1134            }
1135        }
1136    } else {
1137        // Direct in-process executor call (4-argument signature).
1138        tokio::select! {
1139            res = apcore_executor.call(module_id, input_value.clone(), None, None) => {
1140                res.map_err(|e| {
1141                    let code = map_module_error_to_exit_code(&e);
1142                    (code, e.to_string())
1143                })
1144            }
1145            _ = tokio::signal::ctrl_c() => {
1146                eprintln!("Execution cancelled.");
1147                std::process::exit(EXIT_SIGINT);
1148            }
1149        }
1150    };
1151
1152    let duration_ms = start.elapsed().as_millis() as u64;
1153
1154    match result {
1155        Ok(output) => {
1156            // 10. Audit log success.
1157            if let Ok(guard) = AUDIT_LOGGER.lock() {
1158                if let Some(logger) = guard.as_ref() {
1159                    logger.log_execution(module_id, &input_value, "success", 0, duration_ms);
1160                }
1161            }
1162            // 11. Format and output.
1163            let fmt = crate::output::resolve_format(format_flag.as_deref());
1164            println!("{}", crate::output::format_exec_result(&output, fmt));
1165            std::process::exit(EXIT_SUCCESS);
1166        }
1167        Err((exit_code, msg)) => {
1168            // Audit log error.
1169            if let Ok(guard) = AUDIT_LOGGER.lock() {
1170                if let Some(logger) = guard.as_ref() {
1171                    logger.log_execution(module_id, &input_value, "error", exit_code, duration_ms);
1172                }
1173            }
1174            eprintln!("Error: Module '{module_id}' execution failed: {msg}.");
1175            std::process::exit(exit_code);
1176        }
1177    }
1178}
1179
1180// ---------------------------------------------------------------------------
1181// Unit tests
1182// ---------------------------------------------------------------------------
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187
1188    #[test]
1189    fn test_validate_module_id_valid() {
1190        // Valid IDs must not return an error.
1191        for id in ["math.add", "text.summarize", "a", "a.b.c"] {
1192            let result = validate_module_id(id);
1193            assert!(result.is_ok(), "expected ok for '{id}': {result:?}");
1194        }
1195    }
1196
1197    #[test]
1198    fn test_validate_module_id_too_long() {
1199        let long_id = "a".repeat(129);
1200        assert!(validate_module_id(&long_id).is_err());
1201    }
1202
1203    #[test]
1204    fn test_validate_module_id_invalid_format() {
1205        for id in ["INVALID!ID", "123abc", ".leading.dot", "a..b", "a."] {
1206            assert!(validate_module_id(id).is_err(), "expected error for '{id}'");
1207        }
1208    }
1209
1210    #[test]
1211    fn test_validate_module_id_max_length() {
1212        let max_id = "a".repeat(128);
1213        assert!(validate_module_id(&max_id).is_ok());
1214    }
1215
1216    // collect_input tests (TDD red → green)
1217
1218    #[test]
1219    fn test_collect_input_no_stdin_drops_null_values() {
1220        use serde_json::json;
1221        let mut kwargs = HashMap::new();
1222        kwargs.insert("a".to_string(), json!(5));
1223        kwargs.insert("b".to_string(), Value::Null);
1224
1225        let result = collect_input(None, kwargs, false).unwrap();
1226        assert_eq!(result.get("a"), Some(&json!(5)));
1227        assert!(!result.contains_key("b"), "Null values must be dropped");
1228    }
1229
1230    #[test]
1231    fn test_collect_input_stdin_valid_json() {
1232        use serde_json::json;
1233        use std::io::Cursor;
1234        let stdin_bytes = b"{\"x\": 42}";
1235        let reader = Cursor::new(stdin_bytes.to_vec());
1236        let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1237        assert_eq!(result.get("x"), Some(&json!(42)));
1238    }
1239
1240    #[test]
1241    fn test_collect_input_cli_overrides_stdin() {
1242        use serde_json::json;
1243        use std::io::Cursor;
1244        let stdin_bytes = b"{\"a\": 5}";
1245        let reader = Cursor::new(stdin_bytes.to_vec());
1246        let mut kwargs = HashMap::new();
1247        kwargs.insert("a".to_string(), json!(99));
1248        let result = collect_input_from_reader(Some("-"), kwargs, false, reader).unwrap();
1249        assert_eq!(result.get("a"), Some(&json!(99)), "CLI must override STDIN");
1250    }
1251
1252    #[test]
1253    fn test_collect_input_oversized_stdin_rejected() {
1254        use std::io::Cursor;
1255        let big = vec![b' '; 10 * 1024 * 1024 + 1];
1256        let reader = Cursor::new(big);
1257        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1258        assert!(matches!(err, CliError::InputTooLarge { .. }));
1259    }
1260
1261    #[test]
1262    fn test_collect_input_large_input_allowed() {
1263        use std::io::Cursor;
1264        let mut payload = b"{\"k\": \"".to_vec();
1265        payload.extend(vec![b'x'; 11 * 1024 * 1024]);
1266        payload.extend(b"\"}");
1267        let reader = Cursor::new(payload);
1268        let result = collect_input_from_reader(Some("-"), HashMap::new(), true, reader);
1269        assert!(
1270            result.is_ok(),
1271            "large_input=true must accept oversized payload"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_collect_input_invalid_json_returns_error() {
1277        use std::io::Cursor;
1278        let reader = Cursor::new(b"not json at all".to_vec());
1279        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1280        assert!(matches!(err, CliError::JsonParse(_)));
1281    }
1282
1283    #[test]
1284    fn test_collect_input_non_object_json_returns_error() {
1285        use std::io::Cursor;
1286        let reader = Cursor::new(b"[1, 2, 3]".to_vec());
1287        let err = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap_err();
1288        assert!(matches!(err, CliError::NotAnObject));
1289    }
1290
1291    #[test]
1292    fn test_collect_input_empty_stdin_returns_empty_map() {
1293        use std::io::Cursor;
1294        let reader = Cursor::new(b"".to_vec());
1295        let result = collect_input_from_reader(Some("-"), HashMap::new(), false, reader).unwrap();
1296        assert!(result.is_empty());
1297    }
1298
1299    #[test]
1300    fn test_collect_input_no_stdin_flag_returns_cli_kwargs() {
1301        use serde_json::json;
1302        let mut kwargs = HashMap::new();
1303        kwargs.insert("foo".to_string(), json!("bar"));
1304        let result = collect_input(None, kwargs.clone(), false).unwrap();
1305        assert_eq!(result.get("foo"), Some(&json!("bar")));
1306    }
1307
1308    // ---------------------------------------------------------------------------
1309    // build_module_command tests (TDD — RED written before GREEN)
1310    // ---------------------------------------------------------------------------
1311
1312    /// Construct a minimal `ModuleDescriptor` for use in `build_module_command`
1313    /// tests. `input_schema` defaults to a JSON null (no properties) when
1314    /// `schema` is `None`.
1315    fn make_module_descriptor(
1316        name: &str,
1317        _description: &str,
1318        schema: Option<serde_json::Value>,
1319    ) -> apcore::registry::registry::ModuleDescriptor {
1320        apcore::registry::registry::ModuleDescriptor {
1321            name: name.to_string(),
1322            annotations: apcore::module::ModuleAnnotations::default(),
1323            input_schema: schema.unwrap_or(serde_json::Value::Null),
1324            output_schema: serde_json::Value::Object(Default::default()),
1325            enabled: true,
1326            tags: vec![],
1327            dependencies: vec![],
1328        }
1329    }
1330
1331    #[test]
1332    fn test_build_module_command_name_is_set() {
1333        let module = make_module_descriptor("math.add", "Add two numbers", None);
1334        let executor = mock_executor();
1335        let cmd = build_module_command(&module, executor).unwrap();
1336        assert_eq!(cmd.get_name(), "math.add");
1337    }
1338
1339    #[test]
1340    fn test_build_module_command_has_input_flag() {
1341        let module = make_module_descriptor("a.b", "desc", None);
1342        let executor = mock_executor();
1343        let cmd = build_module_command(&module, executor).unwrap();
1344        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1345        assert!(names.contains(&"input"), "must have --input flag");
1346    }
1347
1348    #[test]
1349    fn test_build_module_command_has_yes_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(&"yes"), "must have --yes flag");
1355    }
1356
1357    #[test]
1358    fn test_build_module_command_has_large_input_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!(
1364            names.contains(&"large-input"),
1365            "must have --large-input flag"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_build_module_command_has_format_flag() {
1371        let module = make_module_descriptor("a.b", "desc", None);
1372        let executor = mock_executor();
1373        let cmd = build_module_command(&module, executor).unwrap();
1374        let names: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
1375        assert!(names.contains(&"format"), "must have --format flag");
1376    }
1377
1378    #[test]
1379    fn test_build_module_command_has_sandbox_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(&"sandbox"), "must have --sandbox flag");
1385    }
1386
1387    #[test]
1388    fn test_build_module_command_reserved_name_returns_error() {
1389        for reserved in BUILTIN_COMMANDS {
1390            let module = make_module_descriptor(reserved, "desc", None);
1391            let executor = mock_executor();
1392            let result = build_module_command(&module, executor);
1393            assert!(
1394                matches!(result, Err(CliError::ReservedModuleId(_))),
1395                "expected ReservedModuleId for '{reserved}', got {result:?}"
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn test_build_module_command_yes_has_short_flag() {
1402        let module = make_module_descriptor("a.b", "desc", None);
1403        let executor = mock_executor();
1404        let cmd = build_module_command(&module, executor).unwrap();
1405        let has_short_y = cmd
1406            .get_opts()
1407            .filter(|a| a.get_long() == Some("yes"))
1408            .any(|a| a.get_short() == Some('y'));
1409        assert!(has_short_y, "--yes must have short flag -y");
1410    }
1411
1412    // ---------------------------------------------------------------------------
1413    // LazyModuleGroup tests (TDD)
1414    // ---------------------------------------------------------------------------
1415
1416    /// Mock registry that returns a fixed list of module names.
1417    struct CliMockRegistry {
1418        modules: Vec<String>,
1419    }
1420
1421    impl crate::discovery::RegistryProvider for CliMockRegistry {
1422        fn list(&self) -> Vec<String> {
1423            self.modules.clone()
1424        }
1425
1426        fn get_definition(&self, name: &str) -> Option<Value> {
1427            if self.modules.iter().any(|m| m == name) {
1428                Some(serde_json::json!({
1429                    "module_id": name,
1430                    "name": name,
1431                    "input_schema": {},
1432                    "output_schema": {},
1433                    "enabled": true,
1434                    "tags": [],
1435                    "dependencies": [],
1436                }))
1437            } else {
1438                None
1439            }
1440        }
1441
1442        fn get_module_descriptor(
1443            &self,
1444            name: &str,
1445        ) -> Option<apcore::registry::registry::ModuleDescriptor> {
1446            if self.modules.iter().any(|m| m == name) {
1447                Some(apcore::registry::registry::ModuleDescriptor {
1448                    name: name.to_string(),
1449                    annotations: apcore::module::ModuleAnnotations::default(),
1450                    input_schema: serde_json::Value::Object(Default::default()),
1451                    output_schema: serde_json::Value::Object(Default::default()),
1452                    enabled: true,
1453                    tags: vec![],
1454                    dependencies: vec![],
1455                })
1456            } else {
1457                None
1458            }
1459        }
1460    }
1461
1462    /// Mock registry that returns an empty list (simulates unavailable registry).
1463    struct EmptyRegistry;
1464
1465    impl crate::discovery::RegistryProvider for EmptyRegistry {
1466        fn list(&self) -> Vec<String> {
1467            vec![]
1468        }
1469
1470        fn get_definition(&self, _name: &str) -> Option<Value> {
1471            None
1472        }
1473    }
1474
1475    /// Mock executor (no-op).
1476    struct MockExecutor;
1477
1478    impl ModuleExecutor for MockExecutor {}
1479
1480    fn mock_registry(modules: Vec<&str>) -> Arc<dyn crate::discovery::RegistryProvider> {
1481        Arc::new(CliMockRegistry {
1482            modules: modules.iter().map(|s| s.to_string()).collect(),
1483        })
1484    }
1485
1486    fn mock_executor() -> Arc<dyn ModuleExecutor> {
1487        Arc::new(MockExecutor)
1488    }
1489
1490    #[test]
1491    fn test_lazy_module_group_list_commands_empty_registry() {
1492        let group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1493        let cmds = group.list_commands();
1494        for builtin in ["exec", "list", "describe", "completion", "init", "man"] {
1495            assert!(
1496                cmds.contains(&builtin.to_string()),
1497                "missing builtin: {builtin}"
1498            );
1499        }
1500        // Result must be sorted.
1501        let mut sorted = cmds.clone();
1502        sorted.sort();
1503        assert_eq!(cmds, sorted, "list_commands must return a sorted list");
1504    }
1505
1506    #[test]
1507    fn test_lazy_module_group_list_commands_includes_modules() {
1508        let group = LazyModuleGroup::new(
1509            mock_registry(vec!["math.add", "text.summarize"]),
1510            mock_executor(),
1511        );
1512        let cmds = group.list_commands();
1513        assert!(cmds.contains(&"math.add".to_string()));
1514        assert!(cmds.contains(&"text.summarize".to_string()));
1515    }
1516
1517    #[test]
1518    fn test_lazy_module_group_list_commands_registry_error() {
1519        let group = LazyModuleGroup::new(Arc::new(EmptyRegistry), mock_executor());
1520        let cmds = group.list_commands();
1521        // Must not be empty; must contain builtins.
1522        assert!(!cmds.is_empty());
1523        assert!(cmds.contains(&"list".to_string()));
1524    }
1525
1526    #[test]
1527    fn test_lazy_module_group_get_command_builtin() {
1528        let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1529        let cmd = group.get_command("list");
1530        assert!(cmd.is_some(), "get_command('list') must return Some");
1531    }
1532
1533    #[test]
1534    fn test_lazy_module_group_get_command_not_found() {
1535        let mut group = LazyModuleGroup::new(mock_registry(vec![]), mock_executor());
1536        let cmd = group.get_command("nonexistent.module");
1537        assert!(cmd.is_none());
1538    }
1539
1540    #[test]
1541    fn test_lazy_module_group_get_command_caches_module() {
1542        let mut group = LazyModuleGroup::new(mock_registry(vec!["math.add"]), mock_executor());
1543        // First call builds and caches.
1544        let cmd1 = group.get_command("math.add");
1545        assert!(cmd1.is_some());
1546        // Second call returns from cache — registry lookup should not be called again.
1547        let cmd2 = group.get_command("math.add");
1548        assert!(cmd2.is_some());
1549        assert_eq!(
1550            group.registry_lookup_count(),
1551            1,
1552            "cached after first lookup"
1553        );
1554    }
1555
1556    #[test]
1557    fn test_lazy_module_group_builtin_commands_sorted() {
1558        // BUILTIN_COMMANDS slice must itself be in sorted order (single source of truth).
1559        let mut sorted = BUILTIN_COMMANDS.to_vec();
1560        sorted.sort_unstable();
1561        assert_eq!(
1562            BUILTIN_COMMANDS,
1563            sorted.as_slice(),
1564            "BUILTIN_COMMANDS must be sorted"
1565        );
1566    }
1567
1568    #[test]
1569    fn test_lazy_module_group_list_deduplicates_builtins() {
1570        // If a registry module name collides with a builtin, the result must not
1571        // contain duplicates.
1572        let group = LazyModuleGroup::new(mock_registry(vec!["list", "exec"]), mock_executor());
1573        let cmds = group.list_commands();
1574        let list_count = cmds.iter().filter(|c| c.as_str() == "list").count();
1575        assert_eq!(list_count, 1, "duplicate 'list' entry in list_commands");
1576    }
1577
1578    // ---------------------------------------------------------------------------
1579    // map_apcore_error_to_exit_code tests (RED — written before implementation)
1580    // ---------------------------------------------------------------------------
1581
1582    #[test]
1583    fn test_map_error_module_not_found_is_44() {
1584        assert_eq!(map_apcore_error_to_exit_code("MODULE_NOT_FOUND"), 44);
1585    }
1586
1587    #[test]
1588    fn test_map_error_module_load_error_is_44() {
1589        assert_eq!(map_apcore_error_to_exit_code("MODULE_LOAD_ERROR"), 44);
1590    }
1591
1592    #[test]
1593    fn test_map_error_module_disabled_is_44() {
1594        assert_eq!(map_apcore_error_to_exit_code("MODULE_DISABLED"), 44);
1595    }
1596
1597    #[test]
1598    fn test_map_error_schema_validation_error_is_45() {
1599        assert_eq!(map_apcore_error_to_exit_code("SCHEMA_VALIDATION_ERROR"), 45);
1600    }
1601
1602    #[test]
1603    fn test_map_error_approval_denied_is_46() {
1604        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_DENIED"), 46);
1605    }
1606
1607    #[test]
1608    fn test_map_error_approval_timeout_is_46() {
1609        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_TIMEOUT"), 46);
1610    }
1611
1612    #[test]
1613    fn test_map_error_approval_pending_is_46() {
1614        assert_eq!(map_apcore_error_to_exit_code("APPROVAL_PENDING"), 46);
1615    }
1616
1617    #[test]
1618    fn test_map_error_config_not_found_is_47() {
1619        assert_eq!(map_apcore_error_to_exit_code("CONFIG_NOT_FOUND"), 47);
1620    }
1621
1622    #[test]
1623    fn test_map_error_config_invalid_is_47() {
1624        assert_eq!(map_apcore_error_to_exit_code("CONFIG_INVALID"), 47);
1625    }
1626
1627    #[test]
1628    fn test_map_error_schema_circular_ref_is_48() {
1629        assert_eq!(map_apcore_error_to_exit_code("SCHEMA_CIRCULAR_REF"), 48);
1630    }
1631
1632    #[test]
1633    fn test_map_error_acl_denied_is_77() {
1634        assert_eq!(map_apcore_error_to_exit_code("ACL_DENIED"), 77);
1635    }
1636
1637    #[test]
1638    fn test_map_error_module_execute_error_is_1() {
1639        assert_eq!(map_apcore_error_to_exit_code("MODULE_EXECUTE_ERROR"), 1);
1640    }
1641
1642    #[test]
1643    fn test_map_error_module_timeout_is_1() {
1644        assert_eq!(map_apcore_error_to_exit_code("MODULE_TIMEOUT"), 1);
1645    }
1646
1647    #[test]
1648    fn test_map_error_unknown_is_1() {
1649        assert_eq!(map_apcore_error_to_exit_code("SOMETHING_UNEXPECTED"), 1);
1650    }
1651
1652    #[test]
1653    fn test_map_error_empty_string_is_1() {
1654        assert_eq!(map_apcore_error_to_exit_code(""), 1);
1655    }
1656
1657    // ---------------------------------------------------------------------------
1658    // set_audit_logger implementation tests (RED)
1659    // ---------------------------------------------------------------------------
1660
1661    #[test]
1662    fn test_set_audit_logger_none_clears_logger() {
1663        // Setting None must not panic and must leave AUDIT_LOGGER as None.
1664        set_audit_logger(None);
1665        let guard = AUDIT_LOGGER.lock().unwrap();
1666        assert!(guard.is_none(), "setting None must clear the audit logger");
1667    }
1668
1669    #[test]
1670    fn test_set_audit_logger_some_stores_logger() {
1671        use crate::security::AuditLogger;
1672        set_audit_logger(Some(AuditLogger::new(None)));
1673        let guard = AUDIT_LOGGER.lock().unwrap();
1674        assert!(guard.is_some(), "setting Some must store the audit logger");
1675        // Clean up.
1676        drop(guard);
1677        set_audit_logger(None);
1678    }
1679
1680    // ---------------------------------------------------------------------------
1681    // validate_against_schema tests (RED)
1682    // ---------------------------------------------------------------------------
1683
1684    #[test]
1685    fn test_validate_against_schema_passes_with_no_properties() {
1686        let schema = serde_json::json!({});
1687        let input = std::collections::HashMap::new();
1688        // Schema without properties must not fail.
1689        let result = validate_against_schema(&input, &schema);
1690        assert!(result.is_ok(), "empty schema must pass: {result:?}");
1691    }
1692
1693    #[test]
1694    fn test_validate_against_schema_required_field_missing_fails() {
1695        let schema = serde_json::json!({
1696            "properties": {
1697                "a": {"type": "integer"}
1698            },
1699            "required": ["a"]
1700        });
1701        let input: std::collections::HashMap<String, serde_json::Value> =
1702            std::collections::HashMap::new();
1703        let result = validate_against_schema(&input, &schema);
1704        assert!(result.is_err(), "missing required field must fail");
1705    }
1706
1707    #[test]
1708    fn test_validate_against_schema_required_field_present_passes() {
1709        let schema = serde_json::json!({
1710            "properties": {
1711                "a": {"type": "integer"}
1712            },
1713            "required": ["a"]
1714        });
1715        let mut input = std::collections::HashMap::new();
1716        input.insert("a".to_string(), serde_json::json!(42));
1717        let result = validate_against_schema(&input, &schema);
1718        assert!(
1719            result.is_ok(),
1720            "present required field must pass: {result:?}"
1721        );
1722    }
1723
1724    #[test]
1725    fn test_validate_against_schema_no_required_any_input_passes() {
1726        let schema = serde_json::json!({
1727            "properties": {
1728                "x": {"type": "string"}
1729            }
1730        });
1731        let input: std::collections::HashMap<String, serde_json::Value> =
1732            std::collections::HashMap::new();
1733        let result = validate_against_schema(&input, &schema);
1734        assert!(result.is_ok(), "no required fields: empty input must pass");
1735    }
1736
1737    // -----------------------------------------------------------------
1738    // GroupedModuleGroup tests
1739    // -----------------------------------------------------------------
1740
1741    #[test]
1742    fn test_resolve_group_explicit_group() {
1743        let desc = serde_json::json!({
1744            "module_id": "my.thing",
1745            "metadata": {
1746                "display": {
1747                    "cli": {
1748                        "group": "tools",
1749                        "alias": "thing"
1750                    }
1751                }
1752            }
1753        });
1754        let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1755        assert_eq!(group, Some("tools".to_string()));
1756        assert_eq!(cmd, "thing");
1757    }
1758
1759    #[test]
1760    fn test_resolve_group_explicit_empty_is_top_level() {
1761        let desc = serde_json::json!({
1762            "module_id": "my.thing",
1763            "metadata": {
1764                "display": {
1765                    "cli": {
1766                        "group": "",
1767                        "alias": "thing"
1768                    }
1769                }
1770            }
1771        });
1772        let (group, cmd) = GroupedModuleGroup::resolve_group("my.thing", &desc);
1773        assert!(group.is_none());
1774        assert_eq!(cmd, "thing");
1775    }
1776
1777    #[test]
1778    fn test_resolve_group_dotted_alias() {
1779        let desc = serde_json::json!({
1780            "module_id": "math.add",
1781            "metadata": {
1782                "display": {
1783                    "cli": { "alias": "math.add" }
1784                }
1785            }
1786        });
1787        let (group, cmd) = GroupedModuleGroup::resolve_group("math.add", &desc);
1788        assert_eq!(group, Some("math".to_string()));
1789        assert_eq!(cmd, "add");
1790    }
1791
1792    #[test]
1793    fn test_resolve_group_no_dot_is_top_level() {
1794        let desc = serde_json::json!({"module_id": "greet"});
1795        let (group, cmd) = GroupedModuleGroup::resolve_group("greet", &desc);
1796        assert!(group.is_none());
1797        assert_eq!(cmd, "greet");
1798    }
1799
1800    #[test]
1801    fn test_resolve_group_dotted_module_id_default() {
1802        // No display metadata -- falls back to module_id with dot
1803        let desc = serde_json::json!({"module_id": "text.upper"});
1804        let (group, cmd) = GroupedModuleGroup::resolve_group("text.upper", &desc);
1805        assert_eq!(group, Some("text".to_string()));
1806        assert_eq!(cmd, "upper");
1807    }
1808
1809    #[test]
1810    fn test_resolve_group_invalid_group_name_top_level() {
1811        // resolve_group returns the raw group name without validation;
1812        // build_group_map validates and falls back to top-level for
1813        // invalid names.
1814        let desc = serde_json::json!({
1815            "module_id": "x",
1816            "metadata": {
1817                "display": {
1818                    "cli": { "group": "123Invalid" }
1819                }
1820            }
1821        });
1822        let (group, _cmd) = GroupedModuleGroup::resolve_group("x", &desc);
1823        assert_eq!(group, Some("123Invalid".to_string()));
1824    }
1825
1826    #[test]
1827    fn test_grouped_module_group_list_commands_includes_groups() {
1828        let registry = mock_registry(vec!["math.add", "math.mul", "greet"]);
1829        let executor = mock_executor();
1830        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1831        let cmds = gmg.list_commands();
1832        assert!(
1833            cmds.contains(&"math".to_string()),
1834            "must contain group 'math'"
1835        );
1836        // greet has no dot -> top-level
1837        assert!(
1838            cmds.contains(&"greet".to_string()),
1839            "must contain top-level 'greet'"
1840        );
1841    }
1842
1843    #[test]
1844    fn test_grouped_module_group_get_command_group() {
1845        let registry = mock_registry(vec!["math.add", "math.mul"]);
1846        let executor = mock_executor();
1847        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1848        let cmd = gmg.get_command("math");
1849        assert!(cmd.is_some(), "must find group 'math'");
1850        let group_cmd = cmd.unwrap();
1851        let subs: Vec<&str> = group_cmd.get_subcommands().map(|c| c.get_name()).collect();
1852        assert!(subs.contains(&"add"));
1853        assert!(subs.contains(&"mul"));
1854    }
1855
1856    #[test]
1857    fn test_grouped_module_group_get_command_top_level() {
1858        let registry = mock_registry(vec!["greet"]);
1859        let executor = mock_executor();
1860        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1861        let cmd = gmg.get_command("greet");
1862        assert!(cmd.is_some(), "must find top-level 'greet'");
1863    }
1864
1865    #[test]
1866    fn test_grouped_module_group_get_command_not_found() {
1867        let registry = mock_registry(vec![]);
1868        let executor = mock_executor();
1869        let mut gmg = GroupedModuleGroup::new(registry, executor, 80);
1870        assert!(gmg.get_command("nonexistent").is_none());
1871    }
1872
1873    #[test]
1874    fn test_grouped_module_group_help_max_length() {
1875        let registry = mock_registry(vec![]);
1876        let executor = mock_executor();
1877        let gmg = GroupedModuleGroup::new(registry, executor, 42);
1878        assert_eq!(gmg.help_text_max_length(), 42);
1879    }
1880
1881    #[test]
1882    fn test_is_valid_group_name_valid() {
1883        assert!(is_valid_group_name("math"));
1884        assert!(is_valid_group_name("my-group"));
1885        assert!(is_valid_group_name("g1"));
1886        assert!(is_valid_group_name("a_b"));
1887    }
1888
1889    #[test]
1890    fn test_is_valid_group_name_invalid() {
1891        assert!(!is_valid_group_name(""));
1892        assert!(!is_valid_group_name("1abc"));
1893        assert!(!is_valid_group_name("ABC"));
1894        assert!(!is_valid_group_name("a b"));
1895    }
1896}