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