Skip to main content

dynamic_cli/
builder.rs

1//! Fluent builder API for creating CLI/REPL applications
2//!
3//! This module provides a builder pattern for easily constructing
4//! CLI and REPL applications with minimal boilerplate.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use dynamic_cli::prelude::*;
10//!
11//! // Define context
12//! #[derive(Default)]
13//! struct MyContext;
14//!
15//! impl ExecutionContext for MyContext {
16//!     fn as_any(&self) -> &dyn std::any::Any { self }
17//!     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
18//! }
19//!
20//! // Define handler
21//! struct HelloCommand;
22//!
23//! impl CommandHandler for HelloCommand {
24//!     fn execute(
25//!         &self,
26//!         _context: &mut dyn ExecutionContext,
27//!         args: &ParsedArgs,
28//!     ) -> dynamic_cli::Result<()> {
29//!         println!("Hello!");
30//!         Ok(())
31//!     }
32//! }
33//!
34//! # fn main() -> dynamic_cli::Result<()> {
35//! // Build and run
36//! CliBuilder::new()
37//!     .config_file("commands.yaml")
38//!     .context(Box::new(MyContext::default()))
39//!     .register_sync_handler("hello_handler", Box::new(HelloCommand))
40//!     .build()?
41//!     .run()
42//! # }
43//! ```
44
45use crate::config::loader::load_config;
46use crate::config::schema::CommandsConfig;
47use crate::config::validator::validate_config;
48use crate::context::ExecutionContext;
49use crate::error::{ConfigError, DynamicCliError, Result};
50use crate::executor::{AsyncCommandHandler, CommandHandler};
51use crate::help::{command_help_footer, DefaultHelpFormatter, HelpFormatter};
52use crate::interface::{CliInterface, ReplInterface};
53use crate::plugin::Plugin;
54use crate::registry::CommandRegistry;
55use std::collections::HashMap;
56use std::path::PathBuf;
57
58/// Fluent builder for creating CLI/REPL applications
59///
60/// Provides a chainable API for configuring and building applications.
61/// Automatically loads configuration, registers handlers, and creates
62/// the appropriate interface (CLI or REPL).
63///
64/// # Builder Pattern
65///
66/// The builder follows the standard Rust builder pattern:
67/// - Methods consume `self` and return `Self`
68/// - Final `build()` method consumes the builder and returns the app
69///
70/// # Example
71///
72/// ```no_run
73/// use dynamic_cli::prelude::*;
74///
75/// # #[derive(Default)]
76/// # struct MyContext;
77/// # impl ExecutionContext for MyContext {
78/// #     fn as_any(&self) -> &dyn std::any::Any { self }
79/// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
80/// # }
81/// # struct MyHandler;
82/// # impl CommandHandler for MyHandler {
83/// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
84/// # }
85/// # fn main() -> dynamic_cli::Result<()> {
86/// let app = CliBuilder::new()
87///     .config_file("commands.yaml")
88///     .context(Box::new(MyContext::default()))
89///     .register_sync_handler("my_handler", Box::new(MyHandler))
90///     .prompt("myapp")
91///     .build()?;
92/// # Ok(())
93/// # }
94/// ```
95pub struct CliBuilder {
96    /// Path to configuration file
97    config_path: Option<PathBuf>,
98
99    /// Loaded configuration
100    config: Option<CommandsConfig>,
101
102    /// Execution context
103    context: Option<Box<dyn ExecutionContext>>,
104
105    /// Registered command handlers (name -> handler)
106    handlers: HashMap<String, Box<dyn CommandHandler>>,
107
108    /// Registered asynchronous command handler (name -> async_handler)
109    async_handlers: HashMap<String, Box<dyn AsyncCommandHandler>>,
110
111    /// Registered plugins, expanded into `handlers` during `build()`
112    plugins: Vec<Box<dyn Plugin>>,
113
114    /// REPL prompt (if None, will use config default or "cli")
115    prompt: Option<String>,
116
117    /// Base override for the REPL's `\`-continuation prompt (#68).
118    /// Forwarded verbatim to [`ReplInterface::with_prompt_multiline`] when
119    /// set; `None` keeps the "..." default. No effect in CLI one-shot mode.
120    prompt_multiline: Option<String>,
121
122    /// Custom help formatter. None = DefaultHelpFormatter used lazily.
123    help_formatter: Option<Box<dyn HelpFormatter>>,
124}
125
126impl CliBuilder {
127    /// Create a new builder
128    ///
129    /// # Example
130    ///
131    /// ```
132    /// use dynamic_cli::CliBuilder;
133    ///
134    /// let builder = CliBuilder::new();
135    /// ```
136    pub fn new() -> Self {
137        Self {
138            config_path: None,
139            config: None,
140            context: None,
141            handlers: HashMap::new(),
142            async_handlers: HashMap::new(),
143            plugins: Vec::new(),
144            prompt: None,
145            prompt_multiline: None,
146            help_formatter: None,
147        }
148    }
149
150    /// Specify the configuration file
151    ///
152    /// The file will be loaded and validated during `build()`. Supports YAML
153    /// and JSON formats.
154    ///
155    /// # Arguments
156    ///
157    /// * `path` - Path to the configuration file (`.yaml`, `.yml`, or `.json`)
158    ///
159    /// # Example
160    ///
161    /// ```
162    /// use dynamic_cli::CliBuilder;
163    ///
164    /// let builder = CliBuilder::new()
165    ///     .config_file("commands.yaml");
166    /// ```
167    pub fn config_file<P: Into<PathBuf>>(mut self, path: P) -> Self {
168        self.config_path = Some(path.into());
169        self
170    }
171
172    /// Provide a pre-loaded configuration
173    ///
174    /// Use this instead of `config_file()` if you want to load and potentially
175    /// modify the configuration before building.
176    ///
177    /// The configuration does not need to be validated beforehand: `build()`
178    /// runs [`validate_config`] on it.
179    ///
180    /// # Arguments
181    ///
182    /// * `config` - Loaded configuration
183    ///
184    /// # Example
185    ///
186    /// ```no_run
187    /// use dynamic_cli::{CliBuilder, config::loader::load_config};
188    ///
189    /// # fn main() -> dynamic_cli::Result<()> {
190    /// let mut config = load_config("commands.yaml")?;
191    /// // Modify config if needed...
192    ///
193    /// let builder = CliBuilder::new()
194    ///     .config(config);
195    /// # Ok(())
196    /// # }
197    /// ```
198    pub fn config(mut self, config: CommandsConfig) -> Self {
199        self.config = Some(config);
200        self
201    }
202
203    /// Set the execution context
204    ///
205    /// The context will be passed to all command handlers and can store
206    /// application state.
207    ///
208    /// # Arguments
209    ///
210    /// * `context` - Boxed execution context implementing `ExecutionContext`
211    ///
212    /// # Example
213    ///
214    /// ```
215    /// use dynamic_cli::prelude::*;
216    ///
217    /// #[derive(Default)]
218    /// struct MyContext {
219    ///     count: u32,
220    /// }
221    ///
222    /// impl ExecutionContext for MyContext {
223    ///     fn as_any(&self) -> &dyn std::any::Any { self }
224    ///     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
225    /// }
226    ///
227    /// let builder = CliBuilder::new()
228    ///     .context(Box::new(MyContext::default()));
229    /// ```
230    pub fn context(mut self, context: Box<dyn ExecutionContext>) -> Self {
231        self.context = Some(context);
232        self
233    }
234
235    /// Register a (sync) command handler
236    ///
237    /// Associates a handler with the command's implementation name from the config.
238    /// The name must match the `implementation` field in the command definition.
239    ///
240    /// # Arguments
241    ///
242    /// * `name` - Implementation name from the configuration
243    /// * `handler` - Boxed command handler implementing `CommandHandler`
244    ///
245    /// # Example
246    ///
247    /// ```
248    /// use dynamic_cli::prelude::*;
249    ///
250    /// struct MyCommand;
251    ///
252    /// impl CommandHandler for MyCommand {
253    ///     fn execute(
254    ///         &self,
255    ///         _ctx: &mut dyn ExecutionContext,
256    ///         _args: &ParsedArgs,
257    ///     ) -> dynamic_cli::Result<()> {
258    ///         println!("Executed!");
259    ///         Ok(())
260    ///     }
261    /// }
262    ///
263    /// let builder = CliBuilder::new()
264    ///     .register_sync_handler("my_command", Box::new(MyCommand));
265    /// ```
266    pub fn register_sync_handler(
267        mut self,
268        name: impl Into<String>,
269        handler: Box<dyn CommandHandler>,
270    ) -> Self {
271        self.handlers.insert(name.into(), handler);
272        self
273    }
274
275    /// Register an async command handler
276    ///
277    /// Additive counterpart of [`register_sync_handler`][Self::register_sync_handler].
278    /// Associates an async handler with the command's implementation name
279    /// from the config, exactly like its sync counterpart. Registering both
280    /// a sync and an async handler for the *same* implementation name is
281    /// detected as an error at [`build()`][Self::build] time, not here —
282    /// this method, like `register_sync_handler`, always succeeds (it just
283    /// stores the handler for `build()` to consume).
284    ///
285    /// # Arguments
286    ///
287    /// * `name` - Implementation name from the configuration
288    /// * `handler` - Boxed async command handler implementing `AsyncCommandHandler`
289    ///
290    /// # Example
291    ///
292    /// ```
293    /// use dynamic_cli::prelude::*;
294    /// use dynamic_cli::executor::AsyncCommandHandler;
295    /// use async_trait::async_trait;
296    ///
297    /// struct FetchCommand;
298    ///
299    /// #[async_trait]
300    /// impl AsyncCommandHandler for FetchCommand {
301    ///     async fn execute(
302    ///         &self,
303    ///         _ctx: &mut dyn ExecutionContext,
304    ///         _args: &ParsedArgs,
305    ///     ) -> dynamic_cli::Result<()> {
306    ///         println!("Fetched!");
307    ///         Ok(())
308    ///     }
309    /// }
310    ///
311    /// let builder = CliBuilder::new()
312    ///     .register_async_handler("fetch_command", Box::new(FetchCommand));
313    /// ```
314    pub fn register_async_handler(
315        mut self,
316        name: impl Into<String>,
317        handler: Box<dyn AsyncCommandHandler>,
318    ) -> Self {
319        self.async_handlers.insert(name.into(), handler);
320        self
321    }
322
323    /// Register a plugin
324    ///
325    /// A plugin groups related handlers under a single unit. During
326    /// [`build()`][Self::build], each handler declared by the plugin is
327    /// merged into the handler map. Conflicts with already-registered
328    /// handler names (whether from another plugin or from
329    /// [`register_sync_handler`][Self::register_sync_handler]) produce a
330    /// build-time error.
331    ///
332    /// The YAML config remains the sole source of truth for command
333    /// definitions. Plugin handlers are matched by their `implementation`
334    /// name, exactly as with [`register_sync_handler`][Self::register_sync_handler].
335    /// Plugins remain sync-only for now: `Plugin::handlers()` returns sync
336    /// handlers only.
337    ///
338    /// # Example
339    ///
340    /// ```
341    /// use dynamic_cli::CliBuilder;
342    /// use dynamic_cli::plugin::SystemPlugin;
343    ///
344    /// let builder = CliBuilder::new()
345    ///     .register_plugin(Box::new(SystemPlugin::new()));
346    /// ```
347    pub fn register_plugin(mut self, plugin: Box<dyn Plugin>) -> Self {
348        self.plugins.push(plugin);
349        self
350    }
351
352    /// Load a WASM plugin from disk, map its business functions, and
353    /// register it
354    ///
355    /// Convenience wrapper around [`WasmPlugin::load`][crate::plugin::wasm::WasmPlugin::load],
356    /// [`WasmPlugin::with_function_map`][crate::plugin::wasm::WasmPlugin::with_function_map],
357    /// and [`register_plugin`][Self::register_plugin]. Only available with
358    /// the `wasm-plugins` feature.
359    ///
360    /// `function_map` is mandatory here, unlike the other `WasmPlugin`
361    /// builder methods (`with_format`, `with_metadata`), which have
362    /// reasonable defaults (YAML, file-name-derived metadata). A
363    /// `WasmPlugin` with an empty function map registers zero handlers —
364    /// silently inert — so this wrapper does not offer a path that skips
365    /// mapping. Applications that also need a non-default format or
366    /// explicit metadata should build the `WasmPlugin` directly and pass it
367    /// to [`register_plugin`][Self::register_plugin] instead.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the module cannot be loaded or fails mandatory
372    /// export validation. See `WASM_PLUGIN_INTERFACE.md` for the ABI
373    /// contract.
374    ///
375    /// # Example
376    ///
377    /// ```no_run
378    /// use dynamic_cli::CliBuilder;
379    /// use std::path::Path;
380    ///
381    /// # fn main() -> dynamic_cli::Result<()> {
382    /// let builder = CliBuilder::new()
383    ///     .register_wasm_plugin(
384    ///         Path::new("plugins/greet.wasm"),
385    ///         &[("greet_hello", "say_hello")],
386    ///     )?;
387    /// # Ok(())
388    /// # }
389    /// ```
390    #[cfg(feature = "wasm-plugins")]
391    pub fn register_wasm_plugin(
392        self,
393        path: &std::path::Path,
394        function_map: &[(&str, &str)],
395    ) -> Result<Self> {
396        let mut plugin = crate::plugin::wasm::WasmPlugin::load(path)?;
397        for &(impl_name, wasm_fn_name) in function_map {
398            plugin = plugin.with_function_map(impl_name, wasm_fn_name);
399        }
400        Ok(self.register_plugin(Box::new(plugin)))
401    }
402
403    /// Set the REPL prompt
404    ///
405    /// Only used in REPL mode. If not specified, uses the prompt from
406    /// the configuration or defaults to "cli".
407    ///
408    /// # Arguments
409    ///
410    /// * `prompt` - Prompt prefix (e.g., "myapp" displays as "myapp > ")
411    ///
412    /// # Example
413    ///
414    /// ```
415    /// use dynamic_cli::CliBuilder;
416    ///
417    /// let builder = CliBuilder::new()
418    ///     .prompt("myapp");
419    /// ```
420    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
421        self.prompt = Some(prompt.into());
422        self
423    }
424
425    /// Override the base segment of the REPL's `\`-continuation prompt
426    /// (#68).
427    ///
428    /// Only used in REPL mode — no effect on `CliInterface::run()` (CLI
429    /// one-shot) or `run_script()`/`:load`, which never accumulate
430    /// `\`-continued input: without a live operator, nothing can answer
431    /// a continuation prompt. Mirrors
432    /// [`ReplInterface::with_prompt_multiline`], to which the value is
433    /// forwarded verbatim at `build()`/`run()` time — see that method for
434    /// exactly how the suffix is appended. If not called, the base defaults
435    /// to `"..."`.
436    ///
437    /// # Arguments
438    ///
439    /// * `prompt` - Base segment (e.g., `"_ _ _"`), not the full prompt —
440    ///   the configured `prompt_suffix` is always appended after it.
441    ///
442    /// # Example
443    ///
444    /// ```
445    /// use dynamic_cli::CliBuilder;
446    ///
447    /// let builder = CliBuilder::new()
448    ///     .prompt("rpn")
449    ///     .prompt_multiline("_ _ _");
450    /// ```
451    pub fn prompt_multiline(mut self, prompt: impl Into<String>) -> Self {
452        self.prompt_multiline = Some(prompt.into());
453        self
454    }
455
456    /// Set a custom help formatter.
457    ///
458    /// By default, [`DefaultHelpFormatter`] is used lazily when `--help` is
459    /// detected. Call this method to supply your own implementation.
460    ///
461    /// The formatter is stored and transferred to [`CliApp`] during `build()`.
462    /// It is instantiated **only** when `--help` is detected in `run_cli()`.
463    ///
464    /// # Arguments
465    ///
466    /// * `formatter` - Boxed implementation of [`HelpFormatter`]
467    ///
468    /// # Example
469    ///
470    /// ```
471    /// use dynamic_cli::CliBuilder;
472    /// use dynamic_cli::help::{HelpFormatter, DefaultHelpFormatter};
473    /// use dynamic_cli::config::schema::CommandsConfig;
474    ///
475    /// struct MyFormatter;
476    ///
477    /// impl HelpFormatter for MyFormatter {
478    ///     fn format_app(&self, config: &CommandsConfig) -> String {
479    ///         format!("Help for {}", config.metadata.prompt)
480    ///     }
481    ///     fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
482    ///         format!("Help for command '{command}'")
483    ///     }
484    /// }
485    ///
486    /// let builder = CliBuilder::new()
487    ///     .help_formatter(Box::new(MyFormatter));
488    /// ```
489    pub fn help_formatter(mut self, formatter: Box<dyn HelpFormatter>) -> Self {
490        self.help_formatter = Some(formatter);
491        self
492    }
493
494    /// Build the application
495    ///
496    /// Performs the following steps:
497    /// 1. Load configuration (if `config_file()` was used)
498    /// 2. Validate the configuration with
499    ///    [`validate_config`], whether it
500    ///    came from `config_file()` or `config()`
501    /// 3. Validate that a context was provided
502    /// 4. Create the command registry
503    /// 5. Register all command handlers
504    /// 6. Verify that all required commands have handlers
505    /// 7. Create the `CliApp`
506    ///
507    /// # Returns
508    ///
509    /// A configured `CliApp` ready to run
510    ///
511    /// # Errors
512    ///
513    /// - Configuration errors (file not found, invalid format, configuration
514    ///   rejected by the validator, etc.)
515    /// - Missing context
516    /// - Missing required handlers
517    /// - Registry errors
518    ///
519    /// # Example
520    ///
521    /// ```no_run
522    /// use dynamic_cli::prelude::*;
523    ///
524    /// # #[derive(Default)]
525    /// # struct MyContext;
526    /// # impl ExecutionContext for MyContext {
527    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
528    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
529    /// # }
530    /// # struct MyHandler;
531    /// # impl CommandHandler for MyHandler {
532    /// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
533    /// # }
534    /// # fn main() -> dynamic_cli::Result<()> {
535    /// let app = CliBuilder::new()
536    ///     .config_file("commands.yaml")
537    ///     .context(Box::new(MyContext::default()))
538    ///     .register_sync_handler("handler", Box::new(MyHandler))
539    ///     .build()?;
540    ///
541    /// // Now app is ready to run
542    /// # Ok(())
543    /// # }
544    /// ```
545    pub fn build(mut self) -> Result<CliApp> {
546        // Load configuration if path was specified
547        let config = if let Some(config) = self.config.take() {
548            config
549        } else if let Some(path) = self.config_path.take() {
550            load_config(path)?
551        } else {
552            return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
553                reason: "No configuration provided. Use config_file() or config()".to_string(),
554                path: None,
555                suggestion: None,
556            }));
557        };
558
559        // DA-002 (#2): the configuration is validated at startup, whatever
560        // its source (#96)
561        validate_config(&config)?;
562
563        // Validate context was provided
564        let context = self.context.take().ok_or_else(|| {
565            DynamicCliError::Config(ConfigError::InvalidSchema {
566                reason: "No execution context provided. Use context()".to_string(),
567                path: None,
568                suggestion: None,
569            })
570        })?;
571
572        // Create registry and register commands
573        let mut registry = CommandRegistry::new();
574
575        // Expand plugins into the handler map before processing commands.
576        // Conflicts between plugins (or between a plugin and a directly
577        // registered handler) are detected here and produce a clear error,
578        // before any command resolution begins.
579        for plugin in self.plugins.drain(..) {
580            let plugin_name = plugin.name().to_string();
581            for (impl_name, handler) in plugin.handlers() {
582                if self.handlers.contains_key(&impl_name) {
583                    return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
584                        reason: format!(
585                            "Plugin '{}' tried to register handler '{}' \
586                             which is already registered.",
587                            plugin_name, impl_name
588                        ),
589                        path: None,
590                        suggestion: Some(format!(
591                            "Remove the duplicate call to register_sync_handler(\"{}\") \
592                             or rename the implementation in your YAML config.",
593                            impl_name
594                        )),
595                    }));
596                }
597                self.handlers.insert(impl_name, handler);
598            }
599        }
600
601        for command_def in &config.commands {
602            // Find handlers for this command — sync and async are looked up
603            // independently; at most one should be present (see conflict
604            // check below).
605            let sync_handler = self.handlers.remove(&command_def.implementation);
606            let async_handler = self.async_handlers.remove(&command_def.implementation);
607
608            // A command's implementation name must resolve to exactly one
609            // handler kind. Registering both is a configuration mistake,
610            // not something to resolve silently (e.g. "sync wins") — that
611            // would hide a bug where the same name was registered twice
612            // with different handler types.
613            if sync_handler.is_some() && async_handler.is_some() {
614                return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
615                    reason: format!(
616                        "Command '{}' has both a sync and an async handler \
617                         registered for implementation '{}'.",
618                        command_def.name, command_def.implementation
619                    ),
620                    path: None,
621                    suggestion: Some(
622                        "Use either register_sync_handler() or \
623                         register_async_handler() for this implementation, not both."
624                            .to_string(),
625                    ),
626                }));
627            }
628
629            // Check if handler is required
630            if command_def.required && sync_handler.is_none() && async_handler.is_none() {
631                return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
632                    reason: format!(
633                        "Required command '{}' has no registered handler (implementation: '{}'). \
634                        Use register_sync_handler() or register_async_handler() to register it.",
635                        command_def.name, command_def.implementation
636                    ),
637                    path: None,
638                    suggestion: None,
639                }));
640            }
641
642            // Register command with whichever handler kind was found.
643            if let Some(handler) = sync_handler {
644                registry.register_sync(command_def.clone(), handler)?;
645            } else if let Some(handler) = async_handler {
646                registry.register_async(command_def.clone(), handler)?;
647            }
648        }
649
650        // Determine prompt
651        let prompt = self
652            .prompt
653            .or_else(|| Some(config.metadata.prompt.clone()))
654            .unwrap_or_else(|| "cli".to_string());
655
656        Ok(CliApp {
657            registry,
658            context,
659            prompt,
660            prompt_multiline: self.prompt_multiline,
661            config,
662            help_formatter: self.help_formatter,
663        })
664    }
665}
666
667impl Default for CliBuilder {
668    fn default() -> Self {
669        Self::new()
670    }
671}
672
673/// Built CLI/REPL application
674///
675/// Created by `CliBuilder::build()`. Provides methods to run the application
676/// in different modes:
677/// - `run()` - Auto-detect CLI vs REPL based on arguments
678/// - `run_cli()` - Force CLI mode with specific arguments
679/// - `run_repl()` - Force REPL mode
680///
681/// # Example
682///
683/// ```no_run
684/// use dynamic_cli::prelude::*;
685///
686/// # #[derive(Default)]
687/// # struct MyContext;
688/// # impl ExecutionContext for MyContext {
689/// #     fn as_any(&self) -> &dyn std::any::Any { self }
690/// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
691/// # }
692/// # struct MyHandler;
693/// # impl CommandHandler for MyHandler {
694/// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
695/// # }
696/// # fn main() -> dynamic_cli::Result<()> {
697/// let app = CliBuilder::new()
698///     .config_file("commands.yaml")
699///     .context(Box::new(MyContext::default()))
700///     .register_sync_handler("handler", Box::new(MyHandler))
701///     .build()?;
702///
703/// // Auto-detect mode (CLI if args provided, REPL otherwise)
704/// app.run()
705/// # }
706/// ```
707pub struct CliApp {
708    /// Command registry
709    registry: CommandRegistry,
710
711    /// Execution context
712    context: Box<dyn ExecutionContext>,
713
714    /// REPL prompt
715    prompt: String,
716
717    /// Base override for the REPL's `\`-continuation prompt (#68).
718    /// Forwarded to `ReplInterface::with_prompt_multiline` in `run_repl()`
719    /// when set. Unused outside REPL mode.
720    prompt_multiline: Option<String>,
721
722    /// Full configuration - needed by the help formatter
723    config: CommandsConfig,
724
725    /// Custom help formatter, or None to use DefaultHelpFormatter
726    help_formatter: Option<Box<dyn HelpFormatter>>,
727}
728
729impl std::fmt::Debug for CliApp {
730    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
731        f.debug_struct("CliApp")
732            .field("prompt", &self.prompt)
733            .field("prompt_multiline", &self.prompt_multiline)
734            .field("registry", &"<CommandRegistry>")
735            .field("context", &"<ExecutionContext>")
736            .field("help_formatter", &"<Option<Box<dyn HelpFormatter>>>")
737            .finish()
738    }
739}
740
741impl CliApp {
742    /// Run in CLI mode with provided arguments
743    ///
744    /// Executes a single command and exits.
745    ///
746    /// # Arguments
747    ///
748    /// * `args` - Command-line arguments (typically from `env::args().skip(1)`)
749    ///
750    /// # Returns
751    ///
752    /// - `Ok(())` on successful execution
753    /// - `Err(...)` on parse, validation, or execution errors
754    ///
755    /// # Example
756    ///
757    /// ```no_run
758    /// # use dynamic_cli::prelude::*;
759    /// # #[derive(Default)]
760    /// # struct MyContext;
761    /// # impl ExecutionContext for MyContext {
762    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
763    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
764    /// # }
765    /// # struct MyHandler;
766    /// # impl CommandHandler for MyHandler {
767    /// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
768    /// # }
769    /// # fn main() -> dynamic_cli::Result<()> {
770    /// # let app = CliBuilder::new()
771    /// #     .config_file("commands.yaml")
772    /// #     .context(Box::new(MyContext::default()))
773    /// #     .register_sync_handler("handler", Box::new(MyHandler))
774    /// #     .build()?;
775    /// // Run with specific arguments
776    /// app.run_cli(vec!["command".to_string(), "arg1".to_string()])
777    /// # }
778    /// ```
779    pub fn run_cli(self, args: Vec<String>) -> Result<()> {
780        // Intercept --help before command dispatch.
781        match args.as_slice() {
782            [flag] if flag == "--help" => {
783                print!("{}", self.render_cli_help(None));
784                return Ok(());
785            }
786            [flag, command] if flag == "--help" => {
787                print!("{}", self.render_cli_help(Some(command)));
788                return Ok(());
789            }
790            _ => {}
791        }
792
793        let cli = CliInterface::new(self.registry, self.context);
794        cli.run(args)
795    }
796
797    /// Render the CLI help: the application help followed by the line on
798    /// `--help <command>`, or the help of `command`.
799    ///
800    /// Uses the supplied formatter, or [`DefaultHelpFormatter`] when none
801    /// was supplied. `DefaultHelpFormatter` is a unit struct: the fallback
802    /// costs no allocation and is only built when help is requested.
803    fn render_cli_help(&self, command: Option<&str>) -> String {
804        let default_formatter = DefaultHelpFormatter::new();
805        let formatter: &dyn HelpFormatter =
806            self.help_formatter.as_deref().unwrap_or(&default_formatter);
807
808        match command {
809            Some(cmd) => formatter.format_command(&self.config, cmd),
810            None => {
811                let mut out = formatter.format_app(&self.config);
812                out.push_str(&command_help_footer("Run with", "--help <command>"));
813                out
814            }
815        }
816    }
817
818    /// Run a batch of command lines read from a file (#41).
819    ///
820    /// Convenience wrapper around
821    /// [`CliInterface::run_script`][crate::interface::CliInterface::run_script]
822    /// — see there for the line format, tokenization, and error-policy
823    /// details.
824    ///
825    /// # Example
826    ///
827    /// ```no_run
828    /// # use dynamic_cli::prelude::*;
829    /// # #[derive(Default)]
830    /// # struct MyContext;
831    /// # impl ExecutionContext for MyContext {
832    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
833    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
834    /// # }
835    /// # struct MyHandler;
836    /// # impl CommandHandler for MyHandler {
837    /// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
838    /// # }
839    /// # fn main() -> dynamic_cli::Result<()> {
840    /// # let app = CliBuilder::new()
841    /// #     .config_file("commands.yaml")
842    /// #     .context(Box::new(MyContext::default()))
843    /// #     .register_sync_handler("handler", Box::new(MyHandler))
844    /// #     .build()?;
845    /// let outcome = app.run_script("commands.txt", ScriptErrorPolicy::Continue)?;
846    /// println!("{}/{} lines succeeded", outcome.lines_succeeded, outcome.lines_executed);
847    /// # Ok(())
848    /// # }
849    /// ```
850    pub fn run_script(
851        self,
852        path: impl AsRef<std::path::Path>,
853        policy: crate::interface::ScriptErrorPolicy,
854    ) -> Result<crate::interface::ScriptOutcome> {
855        let cli = CliInterface::new(self.registry, self.context);
856        cli.run_script(path, policy)
857    }
858
859    /// Run in REPL mode
860    ///
861    /// Enters an interactive loop that continues until the user exits.
862    ///
863    /// # Returns
864    ///
865    /// - `Ok(())` when user exits normally
866    /// - `Err(...)` on critical errors (e.g., rustyline initialization failure)
867    ///
868    /// # Example
869    ///
870    /// ```no_run
871    /// # use dynamic_cli::prelude::*;
872    /// # #[derive(Default)]
873    /// # struct MyContext;
874    /// # impl ExecutionContext for MyContext {
875    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
876    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
877    /// # }
878    /// # struct MyHandler;
879    /// # impl CommandHandler for MyHandler {
880    /// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
881    /// # }
882    /// # fn main() -> dynamic_cli::Result<()> {
883    /// # let app = CliBuilder::new()
884    /// #     .config_file("commands.yaml")
885    /// #     .context(Box::new(MyContext::default()))
886    /// #     .register_sync_handler("handler", Box::new(MyHandler))
887    /// #     .build()?;
888    /// // Start interactive REPL
889    /// app.run_repl()
890    /// # }
891    /// ```
892    pub fn run_repl(self) -> Result<()> {
893        let mut repl = ReplInterface::new(
894            self.registry,
895            self.context,
896            self.prompt,
897            Some(self.config),
898            self.help_formatter,
899        )?;
900
901        if let Some(prompt_multiline) = self.prompt_multiline {
902            repl = repl.with_prompt_multiline(prompt_multiline);
903        }
904
905        repl.run()
906    }
907
908    /// Run with automatic mode detection
909    ///
910    /// Decides between CLI and REPL based on command-line arguments:
911    /// - If arguments provided → CLI mode
912    /// - If no arguments → REPL mode
913    ///
914    /// This is the recommended method for most applications.
915    ///
916    /// # Returns
917    ///
918    /// - `Ok(())` on successful execution
919    /// - `Err(...)` on errors
920    ///
921    /// # Example
922    ///
923    /// ```no_run
924    /// # use dynamic_cli::prelude::*;
925    /// # #[derive(Default)]
926    /// # struct MyContext;
927    /// # impl ExecutionContext for MyContext {
928    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
929    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
930    /// # }
931    /// # struct MyHandler;
932    /// # impl CommandHandler for MyHandler {
933    /// #     fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
934    /// # }
935    /// # fn main() -> dynamic_cli::Result<()> {
936    /// # let app = CliBuilder::new()
937    /// #     .config_file("commands.yaml")
938    /// #     .context(Box::new(MyContext::default()))
939    /// #     .register_sync_handler("handler", Box::new(MyHandler))
940    /// #     .build()?;
941    /// // Auto-detect: CLI if args, REPL if no args
942    /// app.run()
943    /// # }
944    /// ```
945    pub fn run(self) -> Result<()> {
946        let args: Vec<String> = std::env::args().skip(1).collect();
947
948        if args.is_empty() {
949            // No arguments → REPL mode
950            self.run_repl()
951        } else {
952            // Arguments provided → CLI mode
953            self.run_cli(args)
954        }
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use crate::config::schema::{CommandDefinition, Metadata};
962    use crate::parser::ParsedArgs;
963
964    // Test context
965    #[derive(Default)]
966    struct TestContext {
967        executed: Vec<String>,
968    }
969
970    impl ExecutionContext for TestContext {
971        fn as_any(&self) -> &dyn std::any::Any {
972            self
973        }
974
975        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
976            self
977        }
978    }
979
980    // Test handler
981    struct TestHandler {
982        name: String,
983    }
984
985    impl CommandHandler for TestHandler {
986        fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
987            let ctx =
988                crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
989            ctx.executed.push(self.name.clone());
990            Ok(())
991        }
992    }
993
994    struct TestAsyncHandler {
995        name: String,
996    }
997
998    #[async_trait::async_trait]
999    impl AsyncCommandHandler for TestAsyncHandler {
1000        async fn execute(
1001            &self,
1002            context: &mut dyn ExecutionContext,
1003            _args: &ParsedArgs,
1004        ) -> Result<()> {
1005            let ctx =
1006                crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
1007            ctx.executed.push(self.name.clone());
1008            Ok(())
1009        }
1010    }
1011
1012    fn create_test_config() -> CommandsConfig {
1013        CommandsConfig {
1014            metadata: Metadata {
1015                version: "1.0.0".to_string(),
1016                prompt: "test".to_string(),
1017                prompt_suffix: " > ".to_string(),
1018            },
1019            commands: vec![CommandDefinition {
1020                name: "test".to_string(),
1021                aliases: vec![],
1022                description: "Test command".to_string(),
1023                required: true,
1024                arguments: vec![],
1025                options: vec![],
1026                implementation: "test_handler".to_string(),
1027                continue_on_failure: false,
1028                requires_success: false,
1029            }],
1030            global_options: vec![],
1031            directives: vec![],
1032        }
1033    }
1034
1035    #[test]
1036    fn test_builder_creation() {
1037        let builder = CliBuilder::new();
1038        assert!(builder.config.is_none());
1039        assert!(builder.context.is_none());
1040    }
1041
1042    #[test]
1043    fn test_builder_with_config() {
1044        let config = create_test_config();
1045        let builder = CliBuilder::new().config(config.clone());
1046
1047        assert!(builder.config.is_some());
1048    }
1049
1050    #[test]
1051    fn test_builder_with_context() {
1052        let context = Box::new(TestContext::default());
1053        let builder = CliBuilder::new().context(context);
1054
1055        assert!(builder.context.is_some());
1056    }
1057
1058    #[test]
1059    fn test_builder_with_handler() {
1060        let handler = Box::new(TestHandler {
1061            name: "test".to_string(),
1062        });
1063
1064        let builder = CliBuilder::new().register_sync_handler("test_handler", handler);
1065
1066        assert_eq!(builder.handlers.len(), 1);
1067    }
1068
1069    #[test]
1070    fn test_builder_with_prompt() {
1071        let builder = CliBuilder::new().prompt("myapp");
1072
1073        assert_eq!(builder.prompt, Some("myapp".to_string()));
1074    }
1075
1076    #[test]
1077    fn test_builder_with_prompt_multiline() {
1078        let builder = CliBuilder::new().prompt("rpn").prompt_multiline("_ _ _");
1079
1080        assert_eq!(builder.prompt_multiline, Some("_ _ _".to_string()));
1081    }
1082
1083    #[test]
1084    fn test_builder_without_prompt_multiline_defaults_to_none() {
1085        // No-op when never called — CliApp/ReplInterface fall back to the
1086        // "..." default — DD-027 (#48), #67 — no breaking change for existing
1087        // callers of CliBuilder that never set this.
1088        let builder = CliBuilder::new().prompt("myapp");
1089
1090        assert_eq!(builder.prompt_multiline, None);
1091    }
1092
1093    #[test]
1094    fn test_builder_build_success() {
1095        let config = create_test_config();
1096        let context = Box::new(TestContext::default());
1097        let handler = Box::new(TestHandler {
1098            name: "test".to_string(),
1099        });
1100
1101        let app = CliBuilder::new()
1102            .config(config)
1103            .context(context)
1104            .register_sync_handler("test_handler", handler)
1105            .build();
1106
1107        assert!(app.is_ok());
1108    }
1109
1110    #[test]
1111    fn test_builder_build_missing_config() {
1112        let context = Box::new(TestContext::default());
1113
1114        let result = CliBuilder::new().context(context).build();
1115
1116        assert!(result.is_err());
1117        match result.unwrap_err() {
1118            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1119                assert!(reason.contains("No configuration provided"));
1120            }
1121            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1122        }
1123    }
1124
1125    // ------------------------------------------------------------------
1126    // Configuration validation at build time (#96)
1127    // ------------------------------------------------------------------
1128
1129    /// Configuration rejected by `validate_config`: a required argument
1130    /// follows an optional one
1131    fn create_invalid_config() -> CommandsConfig {
1132        use crate::config::schema::{ArgumentDefinition, ArgumentType};
1133
1134        let argument = |name: &str, required: bool| ArgumentDefinition {
1135            name: name.to_string(),
1136            arg_type: ArgumentType::String,
1137            required,
1138            description: "Test argument".to_string(),
1139            validation: vec![],
1140            secure: false,
1141        };
1142        let mut config = create_test_config();
1143        config.commands[0].arguments = vec![argument("first", false), argument("second", true)];
1144        config
1145    }
1146
1147    fn build_with(config: CommandsConfig) -> Result<CliApp> {
1148        CliBuilder::new()
1149            .config(config)
1150            .context(Box::new(TestContext::default()))
1151            .register_sync_handler(
1152                "test_handler",
1153                Box::new(TestHandler {
1154                    name: "test".to_string(),
1155                }),
1156            )
1157            .build()
1158    }
1159
1160    #[test]
1161    fn test_builder_build_rejects_invalid_config() {
1162        // Without validation in `build()`, this configuration was accepted.
1163        let config = create_invalid_config();
1164        assert!(validate_config(&config).is_err());
1165
1166        match build_with(config) {
1167            Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, path, .. })) => {
1168                assert!(reason.contains("'second'"), "{}", reason);
1169                assert_eq!(path.as_deref(), Some("test.arguments[1]"));
1170            }
1171            Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
1172            Ok(_) => panic!("Expected build() to reject the configuration"),
1173        }
1174    }
1175
1176    #[test]
1177    fn test_builder_build_rejects_invalid_config_file() {
1178        use std::io::Write;
1179
1180        let mut file = tempfile::Builder::new().suffix(".yaml").tempfile().unwrap();
1181        let yaml = serde_yaml::to_string(&create_invalid_config()).unwrap();
1182        file.write_all(yaml.as_bytes()).unwrap();
1183
1184        let result = CliBuilder::new()
1185            .config_file(file.path())
1186            .context(Box::new(TestContext::default()))
1187            .register_sync_handler(
1188                "test_handler",
1189                Box::new(TestHandler {
1190                    name: "test".to_string(),
1191                }),
1192            )
1193            .build();
1194
1195        assert!(matches!(
1196            result,
1197            Err(DynamicCliError::Config(ConfigError::InvalidSchema { .. }))
1198        ));
1199    }
1200
1201    #[test]
1202    fn test_builder_build_rejects_directive_collision() {
1203        use crate::config::{DirectiveDefinition, ReplDirective};
1204
1205        // `q` stays the default alias of `repl_quit`.
1206        let mut config = create_test_config();
1207        config.directives = vec![DirectiveDefinition {
1208            implementation: ReplDirective::Exit,
1209            name: "q".to_string(),
1210            aliases: vec![],
1211            description: "Leave".to_string(),
1212        }];
1213
1214        match build_with(config) {
1215            Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, path, .. })) => {
1216                assert!(reason.contains("'q'"), "{}", reason);
1217                assert_eq!(path.as_deref(), Some("directives[0]"));
1218            }
1219            Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
1220            Ok(_) => panic!("Expected build() to reject the configuration"),
1221        }
1222    }
1223
1224    #[test]
1225    fn test_builder_build_validates_before_context_check() {
1226        // The configuration error is reported even when the context is
1227        // missing too: validation is the first step after loading.
1228        let result = CliBuilder::new().config(create_invalid_config()).build();
1229
1230        match result {
1231            Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. })) => {
1232                assert!(!reason.contains("No execution context"), "{}", reason);
1233            }
1234            Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
1235            Ok(_) => panic!("Expected build() to reject the configuration"),
1236        }
1237    }
1238
1239    #[test]
1240    fn test_builder_build_missing_context() {
1241        let config = create_test_config();
1242
1243        let result = CliBuilder::new().config(config).build();
1244
1245        assert!(result.is_err());
1246        match result.unwrap_err() {
1247            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1248                assert!(reason.contains("No execution context provided"));
1249            }
1250            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1251        }
1252    }
1253
1254    #[test]
1255    fn test_builder_build_missing_required_handler() {
1256        let config = create_test_config();
1257        let context = Box::new(TestContext::default());
1258
1259        let result = CliBuilder::new().config(config).context(context).build();
1260
1261        assert!(result.is_err());
1262        match result.unwrap_err() {
1263            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1264                assert!(reason.contains("Required command"));
1265                assert!(reason.contains("no registered handler"));
1266            }
1267            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1268        }
1269    }
1270
1271    #[test]
1272    fn test_builder_chaining() {
1273        let config = create_test_config();
1274        let context = Box::new(TestContext::default());
1275        let handler = Box::new(TestHandler {
1276            name: "test".to_string(),
1277        });
1278
1279        // Test that all methods chain correctly
1280        let app = CliBuilder::new()
1281            .config(config)
1282            .context(context)
1283            .register_sync_handler("test_handler", handler)
1284            .prompt("test")
1285            .build();
1286
1287        assert!(app.is_ok());
1288    }
1289
1290    #[test]
1291    fn test_cli_app_run_cli() {
1292        let config = create_test_config();
1293        let context = Box::new(TestContext::default());
1294        let handler = Box::new(TestHandler {
1295            name: "test".to_string(),
1296        });
1297
1298        let app = CliBuilder::new()
1299            .config(config)
1300            .context(context)
1301            .register_sync_handler("test_handler", handler)
1302            .build()
1303            .unwrap();
1304
1305        // Run with test command
1306        let result = app.run_cli(vec!["test".to_string()]);
1307        assert!(result.is_ok());
1308    }
1309
1310    #[test]
1311    fn test_default_prompt_from_config() {
1312        let config = create_test_config();
1313        let context = Box::new(TestContext::default());
1314        let handler = Box::new(TestHandler {
1315            name: "test".to_string(),
1316        });
1317
1318        let app = CliBuilder::new()
1319            .config(config)
1320            .context(context)
1321            .register_sync_handler("test_handler", handler)
1322            .build()
1323            .unwrap();
1324
1325        // Prompt should be taken from config
1326        assert_eq!(app.prompt, "test");
1327    }
1328
1329    #[test]
1330    fn test_override_prompt() {
1331        let config = create_test_config();
1332        let context = Box::new(TestContext::default());
1333        let handler = Box::new(TestHandler {
1334            name: "test".to_string(),
1335        });
1336
1337        let app = CliBuilder::new()
1338            .config(config)
1339            .context(context)
1340            .register_sync_handler("test_handler", handler)
1341            .prompt("custom")
1342            .build()
1343            .unwrap();
1344
1345        // Prompt should be overridden
1346        assert_eq!(app.prompt, "custom");
1347    }
1348
1349    #[test]
1350    fn test_builder_build_forwards_prompt_multiline_to_app() {
1351        let config = create_test_config();
1352        let context = Box::new(TestContext::default());
1353        let handler = Box::new(TestHandler {
1354            name: "test".to_string(),
1355        });
1356
1357        let app = CliBuilder::new()
1358            .config(config)
1359            .context(context)
1360            .register_sync_handler("test_handler", handler)
1361            .prompt("custom")
1362            .prompt_multiline("_ _ _")
1363            .build()
1364            .unwrap();
1365
1366        assert_eq!(app.prompt_multiline, Some("_ _ _".to_string()));
1367    }
1368
1369    #[test]
1370    fn test_builder_build_without_prompt_multiline_leaves_app_none() {
1371        let config = create_test_config();
1372        let context = Box::new(TestContext::default());
1373        let handler = Box::new(TestHandler {
1374            name: "test".to_string(),
1375        });
1376
1377        let app = CliBuilder::new()
1378            .config(config)
1379            .context(context)
1380            .register_sync_handler("test_handler", handler)
1381            .prompt("custom")
1382            .build()
1383            .unwrap();
1384
1385        assert_eq!(app.prompt_multiline, None);
1386    }
1387
1388    // ============================================================================
1389    // async_handlers / register_async_handler / build() TESTS — DA-022 (#8)
1390    // ============================================================================
1391
1392    #[test]
1393    fn test_builder_with_async_handler() {
1394        let handler = Box::new(TestAsyncHandler {
1395            name: "test".to_string(),
1396        });
1397
1398        let builder = CliBuilder::new().register_async_handler("test_handler", handler);
1399
1400        assert_eq!(builder.async_handlers.len(), 1);
1401        assert!(builder.handlers.is_empty());
1402    }
1403
1404    /// The core gap this session closes: an async handler registered via
1405    /// `register_async_handler()` must actually reach the built registry —
1406    /// previously `build()` only ever drained `self.handlers`.
1407    #[test]
1408    fn test_builder_build_with_async_handler_only() {
1409        let config = create_test_config();
1410        let context = Box::new(TestContext::default());
1411        let handler = Box::new(TestAsyncHandler {
1412            name: "test".to_string(),
1413        });
1414
1415        let app = CliBuilder::new()
1416            .config(config)
1417            .context(context)
1418            .register_async_handler("test_handler", handler)
1419            .build();
1420
1421        assert!(app.is_ok());
1422    }
1423
1424    /// Registering both a sync and an async handler for the same
1425    /// implementation name must fail at build() time — this is the
1426    /// conflict check `build()` didn't have before this session.
1427    #[test]
1428    fn test_builder_build_sync_and_async_conflict() {
1429        let config = create_test_config();
1430        let context = Box::new(TestContext::default());
1431        let sync_handler = Box::new(TestHandler {
1432            name: "sync".to_string(),
1433        });
1434        let async_handler = Box::new(TestAsyncHandler {
1435            name: "async".to_string(),
1436        });
1437
1438        let result = CliBuilder::new()
1439            .config(config)
1440            .context(context)
1441            .register_sync_handler("test_handler", sync_handler)
1442            .register_async_handler("test_handler", async_handler)
1443            .build();
1444
1445        assert!(result.is_err());
1446        match result.unwrap_err() {
1447            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1448                assert!(reason.contains("both a sync and an async handler"));
1449            }
1450            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1451        }
1452    }
1453
1454    /// A required command satisfied only by an async handler must not be
1455    /// reported as "missing" — the required-handler check must look at
1456    /// both maps, not just `self.handlers`.
1457    #[test]
1458    fn test_builder_build_required_command_satisfied_by_async_handler() {
1459        let config = create_test_config(); // "test" command is `required: true`
1460        let context = Box::new(TestContext::default());
1461        let handler = Box::new(TestAsyncHandler {
1462            name: "test".to_string(),
1463        });
1464
1465        let result = CliBuilder::new()
1466            .config(config)
1467            .context(context)
1468            .register_async_handler("test_handler", handler)
1469            .build();
1470
1471        assert!(result.is_ok());
1472    }
1473
1474    #[test]
1475    fn test_cli_app_run_cli_with_async_handler() {
1476        let config = create_test_config();
1477        let context = Box::new(TestContext::default());
1478        let handler = Box::new(TestAsyncHandler {
1479            name: "test".to_string(),
1480        });
1481
1482        let app = CliBuilder::new()
1483            .config(config)
1484            .context(context)
1485            .register_async_handler("test_handler", handler)
1486            .build()
1487            .unwrap();
1488
1489        let result = app.run_cli(vec!["test".to_string()]);
1490        assert!(result.is_ok());
1491    }
1492
1493    #[test]
1494    fn test_builder_with_help_formatter() {
1495        use crate::help::DefaultHelpFormatter;
1496
1497        let formatter = Box::new(DefaultHelpFormatter::new());
1498        let builder = CliBuilder::new().help_formatter(formatter);
1499
1500        assert!(builder.help_formatter.is_some());
1501    }
1502
1503    #[test]
1504    fn test_run_cli_help_global() {
1505        let config = create_test_config();
1506        let context = Box::new(TestContext::default());
1507        let handler = Box::new(TestHandler {
1508            name: "test".to_string(),
1509        });
1510
1511        let app = CliBuilder::new()
1512            .config(config)
1513            .context(context)
1514            .register_sync_handler("test_handler", handler)
1515            .build()
1516            .unwrap();
1517
1518        // --help should return Ok(()) without dispatching to any handler.
1519        let result = app.run_cli(vec!["--help".to_string()]);
1520        assert!(result.is_ok());
1521    }
1522
1523    #[test]
1524    fn test_run_cli_help_command() {
1525        let config = create_test_config();
1526        let context = Box::new(TestContext::default());
1527        let handler = Box::new(TestHandler {
1528            name: "test".to_string(),
1529        });
1530
1531        let app = CliBuilder::new()
1532            .config(config)
1533            .context(context)
1534            .register_sync_handler("test_handler", handler)
1535            .build()
1536            .unwrap();
1537
1538        // --help <command> should return Ok(()) without dispatching.
1539        let result = app.run_cli(vec!["--help".to_string(), "test".to_string()]);
1540        assert!(result.is_ok());
1541    }
1542
1543    #[test]
1544    fn test_run_cli_help_unknown_command_still_ok() {
1545        let config = create_test_config();
1546        let context = Box::new(TestContext::default());
1547        let handler = Box::new(TestHandler {
1548            name: "test".to_string(),
1549        });
1550
1551        let app = CliBuilder::new()
1552            .config(config)
1553            .context(context)
1554            .register_sync_handler("test_handler", handler)
1555            .build()
1556            .unwrap();
1557
1558        // --help with an unknown command name: formatter handles it gracefully.
1559        let result = app.run_cli(vec!["--help".to_string(), "ghost".to_string()]);
1560        assert!(result.is_ok());
1561    }
1562
1563    fn build_help_app() -> CliApp {
1564        CliBuilder::new()
1565            .config(create_test_config())
1566            .context(Box::new(TestContext::default()))
1567            .register_sync_handler(
1568                "test_handler",
1569                Box::new(TestHandler {
1570                    name: "test".to_string(),
1571                }),
1572            )
1573            .build()
1574            .unwrap()
1575    }
1576
1577    #[test]
1578    fn test_render_cli_help_app_ends_with_cli_footer() {
1579        colored::control::set_override(false);
1580        let app = build_help_app();
1581        let out = app.render_cli_help(None);
1582
1583        assert_eq!(
1584            out,
1585            format!(
1586                "{}\nRun with '--help <command>' for more information on a command.\n",
1587                DefaultHelpFormatter::new().format_app(&app.config)
1588            )
1589        );
1590    }
1591
1592    #[test]
1593    fn test_render_cli_help_command_has_no_footer() {
1594        colored::control::set_override(false);
1595        let app = build_help_app();
1596        let out = app.render_cli_help(Some("test"));
1597
1598        assert_eq!(
1599            out,
1600            DefaultHelpFormatter::new().format_command(&app.config, "test")
1601        );
1602    }
1603
1604    #[test]
1605    fn test_render_cli_help_footer_follows_custom_formatter() {
1606        struct Plain;
1607        impl HelpFormatter for Plain {
1608            fn format_app(&self, _: &CommandsConfig) -> String {
1609                "APP\n".to_string()
1610            }
1611            fn format_command(&self, _: &CommandsConfig, command: &str) -> String {
1612                format!("CMD {command}\n")
1613            }
1614        }
1615
1616        colored::control::set_override(false);
1617        let app = CliBuilder::new()
1618            .config(create_test_config())
1619            .context(Box::new(TestContext::default()))
1620            .help_formatter(Box::new(Plain))
1621            .register_sync_handler(
1622                "test_handler",
1623                Box::new(TestHandler {
1624                    name: "test".to_string(),
1625                }),
1626            )
1627            .build()
1628            .unwrap();
1629
1630        assert_eq!(
1631            app.render_cli_help(None),
1632            "APP\n\nRun with '--help <command>' for more information on a command.\n"
1633        );
1634        assert_eq!(app.render_cli_help(Some("x")), "CMD x\n");
1635    }
1636}