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                continue_on_failure: false,
971                requires_success: false,
972            }],
973            global_options: vec![],
974        }
975    }
976
977    #[test]
978    fn test_builder_creation() {
979        let builder = CliBuilder::new();
980        assert!(builder.config.is_none());
981        assert!(builder.context.is_none());
982    }
983
984    #[test]
985    fn test_builder_with_config() {
986        let config = create_test_config();
987        let builder = CliBuilder::new().config(config.clone());
988
989        assert!(builder.config.is_some());
990    }
991
992    #[test]
993    fn test_builder_with_context() {
994        let context = Box::new(TestContext::default());
995        let builder = CliBuilder::new().context(context);
996
997        assert!(builder.context.is_some());
998    }
999
1000    #[test]
1001    fn test_builder_with_handler() {
1002        let handler = Box::new(TestHandler {
1003            name: "test".to_string(),
1004        });
1005
1006        let builder = CliBuilder::new().register_sync_handler("test_handler", handler);
1007
1008        assert_eq!(builder.handlers.len(), 1);
1009    }
1010
1011    /// Deprecated-alias coverage (DD-022 companion issue): `register_handler()`
1012    /// must keep behaving exactly like `register_sync_handler()` until it's
1013    /// removed in v1.0.0. This is the only place in the crate allowed to
1014    /// call it directly.
1015    #[test]
1016    #[allow(deprecated)]
1017    fn test_deprecated_register_handler_alias_still_works() {
1018        let handler = Box::new(TestHandler {
1019            name: "test".to_string(),
1020        });
1021
1022        let builder = CliBuilder::new().register_handler("test_handler", handler);
1023
1024        assert_eq!(builder.handlers.len(), 1);
1025        assert!(builder.async_handlers.is_empty());
1026    }
1027
1028    #[test]
1029    fn test_builder_with_prompt() {
1030        let builder = CliBuilder::new().prompt("myapp");
1031
1032        assert_eq!(builder.prompt, Some("myapp".to_string()));
1033    }
1034
1035    #[test]
1036    fn test_builder_build_success() {
1037        let config = create_test_config();
1038        let context = Box::new(TestContext::default());
1039        let handler = Box::new(TestHandler {
1040            name: "test".to_string(),
1041        });
1042
1043        let app = CliBuilder::new()
1044            .config(config)
1045            .context(context)
1046            .register_sync_handler("test_handler", handler)
1047            .build();
1048
1049        assert!(app.is_ok());
1050    }
1051
1052    #[test]
1053    fn test_builder_build_missing_config() {
1054        let context = Box::new(TestContext::default());
1055
1056        let result = CliBuilder::new().context(context).build();
1057
1058        assert!(result.is_err());
1059        match result.unwrap_err() {
1060            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1061                assert!(reason.contains("No configuration provided"));
1062            }
1063            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1064        }
1065    }
1066
1067    #[test]
1068    fn test_builder_build_missing_context() {
1069        let config = create_test_config();
1070
1071        let result = CliBuilder::new().config(config).build();
1072
1073        assert!(result.is_err());
1074        match result.unwrap_err() {
1075            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1076                assert!(reason.contains("No execution context provided"));
1077            }
1078            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1079        }
1080    }
1081
1082    #[test]
1083    fn test_builder_build_missing_required_handler() {
1084        let config = create_test_config();
1085        let context = Box::new(TestContext::default());
1086
1087        let result = CliBuilder::new().config(config).context(context).build();
1088
1089        assert!(result.is_err());
1090        match result.unwrap_err() {
1091            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1092                assert!(reason.contains("Required command"));
1093                assert!(reason.contains("no registered handler"));
1094            }
1095            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1096        }
1097    }
1098
1099    #[test]
1100    fn test_builder_chaining() {
1101        let config = create_test_config();
1102        let context = Box::new(TestContext::default());
1103        let handler = Box::new(TestHandler {
1104            name: "test".to_string(),
1105        });
1106
1107        // Test that all methods chain correctly
1108        let app = CliBuilder::new()
1109            .config(config)
1110            .context(context)
1111            .register_sync_handler("test_handler", handler)
1112            .prompt("test")
1113            .build();
1114
1115        assert!(app.is_ok());
1116    }
1117
1118    #[test]
1119    fn test_cli_app_run_cli() {
1120        let config = create_test_config();
1121        let context = Box::new(TestContext::default());
1122        let handler = Box::new(TestHandler {
1123            name: "test".to_string(),
1124        });
1125
1126        let app = CliBuilder::new()
1127            .config(config)
1128            .context(context)
1129            .register_sync_handler("test_handler", handler)
1130            .build()
1131            .unwrap();
1132
1133        // Run with test command
1134        let result = app.run_cli(vec!["test".to_string()]);
1135        assert!(result.is_ok());
1136    }
1137
1138    #[test]
1139    fn test_default_prompt_from_config() {
1140        let config = create_test_config();
1141        let context = Box::new(TestContext::default());
1142        let handler = Box::new(TestHandler {
1143            name: "test".to_string(),
1144        });
1145
1146        let app = CliBuilder::new()
1147            .config(config)
1148            .context(context)
1149            .register_sync_handler("test_handler", handler)
1150            .build()
1151            .unwrap();
1152
1153        // Prompt should be taken from config
1154        assert_eq!(app.prompt, "test");
1155    }
1156
1157    #[test]
1158    fn test_override_prompt() {
1159        let config = create_test_config();
1160        let context = Box::new(TestContext::default());
1161        let handler = Box::new(TestHandler {
1162            name: "test".to_string(),
1163        });
1164
1165        let app = CliBuilder::new()
1166            .config(config)
1167            .context(context)
1168            .register_sync_handler("test_handler", handler)
1169            .prompt("custom")
1170            .build()
1171            .unwrap();
1172
1173        // Prompt should be overridden
1174        assert_eq!(app.prompt, "custom");
1175    }
1176
1177    // ============================================================================
1178    // async_handlers / register_async_handler / build() TESTS (DD-022)
1179    // ============================================================================
1180
1181    #[test]
1182    fn test_builder_with_async_handler() {
1183        let handler = Box::new(TestAsyncHandler {
1184            name: "test".to_string(),
1185        });
1186
1187        let builder = CliBuilder::new().register_async_handler("test_handler", handler);
1188
1189        assert_eq!(builder.async_handlers.len(), 1);
1190        assert!(builder.handlers.is_empty());
1191    }
1192
1193    /// The core gap this session closes: an async handler registered via
1194    /// `register_async_handler()` must actually reach the built registry —
1195    /// previously `build()` only ever drained `self.handlers`.
1196    #[test]
1197    fn test_builder_build_with_async_handler_only() {
1198        let config = create_test_config();
1199        let context = Box::new(TestContext::default());
1200        let handler = Box::new(TestAsyncHandler {
1201            name: "test".to_string(),
1202        });
1203
1204        let app = CliBuilder::new()
1205            .config(config)
1206            .context(context)
1207            .register_async_handler("test_handler", handler)
1208            .build();
1209
1210        assert!(app.is_ok());
1211    }
1212
1213    /// Registering both a sync and an async handler for the same
1214    /// implementation name must fail at build() time — this is the
1215    /// conflict check `build()` didn't have before this session.
1216    #[test]
1217    fn test_builder_build_sync_and_async_conflict() {
1218        let config = create_test_config();
1219        let context = Box::new(TestContext::default());
1220        let sync_handler = Box::new(TestHandler {
1221            name: "sync".to_string(),
1222        });
1223        let async_handler = Box::new(TestAsyncHandler {
1224            name: "async".to_string(),
1225        });
1226
1227        let result = CliBuilder::new()
1228            .config(config)
1229            .context(context)
1230            .register_sync_handler("test_handler", sync_handler)
1231            .register_async_handler("test_handler", async_handler)
1232            .build();
1233
1234        assert!(result.is_err());
1235        match result.unwrap_err() {
1236            DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1237                assert!(reason.contains("both a sync and an async handler"));
1238            }
1239            other => panic!("Expected InvalidSchema error, got: {:?}", other),
1240        }
1241    }
1242
1243    /// A required command satisfied only by an async handler must not be
1244    /// reported as "missing" — the required-handler check must look at
1245    /// both maps, not just `self.handlers`.
1246    #[test]
1247    fn test_builder_build_required_command_satisfied_by_async_handler() {
1248        let config = create_test_config(); // "test" command is `required: true`
1249        let context = Box::new(TestContext::default());
1250        let handler = Box::new(TestAsyncHandler {
1251            name: "test".to_string(),
1252        });
1253
1254        let result = CliBuilder::new()
1255            .config(config)
1256            .context(context)
1257            .register_async_handler("test_handler", handler)
1258            .build();
1259
1260        assert!(result.is_ok());
1261    }
1262
1263    #[test]
1264    fn test_cli_app_run_cli_with_async_handler() {
1265        let config = create_test_config();
1266        let context = Box::new(TestContext::default());
1267        let handler = Box::new(TestAsyncHandler {
1268            name: "test".to_string(),
1269        });
1270
1271        let app = CliBuilder::new()
1272            .config(config)
1273            .context(context)
1274            .register_async_handler("test_handler", handler)
1275            .build()
1276            .unwrap();
1277
1278        let result = app.run_cli(vec!["test".to_string()]);
1279        assert!(result.is_ok());
1280    }
1281
1282    #[test]
1283    fn test_builder_with_help_formatter() {
1284        use crate::help::DefaultHelpFormatter;
1285
1286        let formatter = Box::new(DefaultHelpFormatter::new());
1287        let builder = CliBuilder::new().help_formatter(formatter);
1288
1289        assert!(builder.help_formatter.is_some());
1290    }
1291
1292    #[test]
1293    fn test_run_cli_help_global() {
1294        let config = create_test_config();
1295        let context = Box::new(TestContext::default());
1296        let handler = Box::new(TestHandler {
1297            name: "test".to_string(),
1298        });
1299
1300        let app = CliBuilder::new()
1301            .config(config)
1302            .context(context)
1303            .register_sync_handler("test_handler", handler)
1304            .build()
1305            .unwrap();
1306
1307        // --help should return Ok(()) without dispatching to any handler.
1308        let result = app.run_cli(vec!["--help".to_string()]);
1309        assert!(result.is_ok());
1310    }
1311
1312    #[test]
1313    fn test_run_cli_help_command() {
1314        let config = create_test_config();
1315        let context = Box::new(TestContext::default());
1316        let handler = Box::new(TestHandler {
1317            name: "test".to_string(),
1318        });
1319
1320        let app = CliBuilder::new()
1321            .config(config)
1322            .context(context)
1323            .register_sync_handler("test_handler", handler)
1324            .build()
1325            .unwrap();
1326
1327        // --help <command> should return Ok(()) without dispatching.
1328        let result = app.run_cli(vec!["--help".to_string(), "test".to_string()]);
1329        assert!(result.is_ok());
1330    }
1331
1332    #[test]
1333    fn test_run_cli_help_unknown_command_still_ok() {
1334        let config = create_test_config();
1335        let context = Box::new(TestContext::default());
1336        let handler = Box::new(TestHandler {
1337            name: "test".to_string(),
1338        });
1339
1340        let app = CliBuilder::new()
1341            .config(config)
1342            .context(context)
1343            .register_sync_handler("test_handler", handler)
1344            .build()
1345            .unwrap();
1346
1347        // --help with an unknown command name: formatter handles it gracefully.
1348        let result = app.run_cli(vec!["--help".to_string(), "ghost".to_string()]);
1349        assert!(result.is_ok());
1350    }
1351}