Skip to main content

apcore_cli/
cli.rs

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