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