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