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 in REPL mode
767 ///
768 /// Enters an interactive loop that continues until the user exits.
769 ///
770 /// # Returns
771 ///
772 /// - `Ok(())` when user exits normally
773 /// - `Err(...)` on critical errors (e.g., rustyline initialization failure)
774 ///
775 /// # Example
776 ///
777 /// ```no_run
778 /// # use dynamic_cli::prelude::*;
779 /// # #[derive(Default)]
780 /// # struct MyContext;
781 /// # impl ExecutionContext for MyContext {
782 /// # fn as_any(&self) -> &dyn std::any::Any { self }
783 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
784 /// # }
785 /// # struct MyHandler;
786 /// # impl CommandHandler for MyHandler {
787 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
788 /// # }
789 /// # fn main() -> dynamic_cli::Result<()> {
790 /// # let app = CliBuilder::new()
791 /// # .config_file("commands.yaml")
792 /// # .context(Box::new(MyContext::default()))
793 /// # .register_sync_handler("handler", Box::new(MyHandler))
794 /// # .build()?;
795 /// // Start interactive REPL
796 /// app.run_repl()
797 /// # }
798 /// ```
799 pub fn run_repl(self) -> Result<()> {
800 ReplInterface::new(
801 self.registry,
802 self.context,
803 self.prompt,
804 Some(self.config),
805 self.help_formatter,
806 )?
807 .run()
808 }
809
810 /// Run with automatic mode detection
811 ///
812 /// Decides between CLI and REPL based on command-line arguments:
813 /// - If arguments provided → CLI mode
814 /// - If no arguments → REPL mode
815 ///
816 /// This is the recommended method for most applications.
817 ///
818 /// # Returns
819 ///
820 /// - `Ok(())` on successful execution
821 /// - `Err(...)` on errors
822 ///
823 /// # Example
824 ///
825 /// ```no_run
826 /// # use dynamic_cli::prelude::*;
827 /// # #[derive(Default)]
828 /// # struct MyContext;
829 /// # impl ExecutionContext for MyContext {
830 /// # fn as_any(&self) -> &dyn std::any::Any { self }
831 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
832 /// # }
833 /// # struct MyHandler;
834 /// # impl CommandHandler for MyHandler {
835 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
836 /// # }
837 /// # fn main() -> dynamic_cli::Result<()> {
838 /// # let app = CliBuilder::new()
839 /// # .config_file("commands.yaml")
840 /// # .context(Box::new(MyContext::default()))
841 /// # .register_sync_handler("handler", Box::new(MyHandler))
842 /// # .build()?;
843 /// // Auto-detect: CLI if args, REPL if no args
844 /// app.run()
845 /// # }
846 /// ```
847 pub fn run(self) -> Result<()> {
848 let args: Vec<String> = std::env::args().skip(1).collect();
849
850 if args.is_empty() {
851 // No arguments → REPL mode
852 self.run_repl()
853 } else {
854 // Arguments provided → CLI mode
855 self.run_cli(args)
856 }
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::config::schema::{CommandDefinition, Metadata};
864 use crate::parser::ParsedArgs;
865
866 // Test context
867 #[derive(Default)]
868 struct TestContext {
869 executed: Vec<String>,
870 }
871
872 impl ExecutionContext for TestContext {
873 fn as_any(&self) -> &dyn std::any::Any {
874 self
875 }
876
877 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
878 self
879 }
880 }
881
882 // Test handler
883 struct TestHandler {
884 name: String,
885 }
886
887 impl CommandHandler for TestHandler {
888 fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
889 let ctx =
890 crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
891 ctx.executed.push(self.name.clone());
892 Ok(())
893 }
894 }
895
896 struct TestAsyncHandler {
897 name: String,
898 }
899
900 #[async_trait::async_trait]
901 impl AsyncCommandHandler for TestAsyncHandler {
902 async fn execute(
903 &self,
904 context: &mut dyn ExecutionContext,
905 _args: &ParsedArgs,
906 ) -> Result<()> {
907 let ctx =
908 crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
909 ctx.executed.push(self.name.clone());
910 Ok(())
911 }
912 }
913
914 fn create_test_config() -> CommandsConfig {
915 CommandsConfig {
916 metadata: Metadata {
917 version: "1.0.0".to_string(),
918 prompt: "test".to_string(),
919 prompt_suffix: " > ".to_string(),
920 },
921 commands: vec![CommandDefinition {
922 name: "test".to_string(),
923 aliases: vec![],
924 description: "Test command".to_string(),
925 required: true,
926 arguments: vec![],
927 options: vec![],
928 implementation: "test_handler".to_string(),
929 }],
930 global_options: vec![],
931 }
932 }
933
934 #[test]
935 fn test_builder_creation() {
936 let builder = CliBuilder::new();
937 assert!(builder.config.is_none());
938 assert!(builder.context.is_none());
939 }
940
941 #[test]
942 fn test_builder_with_config() {
943 let config = create_test_config();
944 let builder = CliBuilder::new().config(config.clone());
945
946 assert!(builder.config.is_some());
947 }
948
949 #[test]
950 fn test_builder_with_context() {
951 let context = Box::new(TestContext::default());
952 let builder = CliBuilder::new().context(context);
953
954 assert!(builder.context.is_some());
955 }
956
957 #[test]
958 fn test_builder_with_handler() {
959 let handler = Box::new(TestHandler {
960 name: "test".to_string(),
961 });
962
963 let builder = CliBuilder::new().register_sync_handler("test_handler", handler);
964
965 assert_eq!(builder.handlers.len(), 1);
966 }
967
968 /// Deprecated-alias coverage (DD-022 companion issue): `register_handler()`
969 /// must keep behaving exactly like `register_sync_handler()` until it's
970 /// removed in v1.0.0. This is the only place in the crate allowed to
971 /// call it directly.
972 #[test]
973 #[allow(deprecated)]
974 fn test_deprecated_register_handler_alias_still_works() {
975 let handler = Box::new(TestHandler {
976 name: "test".to_string(),
977 });
978
979 let builder = CliBuilder::new().register_handler("test_handler", handler);
980
981 assert_eq!(builder.handlers.len(), 1);
982 assert!(builder.async_handlers.is_empty());
983 }
984
985 #[test]
986 fn test_builder_with_prompt() {
987 let builder = CliBuilder::new().prompt("myapp");
988
989 assert_eq!(builder.prompt, Some("myapp".to_string()));
990 }
991
992 #[test]
993 fn test_builder_build_success() {
994 let config = create_test_config();
995 let context = Box::new(TestContext::default());
996 let handler = Box::new(TestHandler {
997 name: "test".to_string(),
998 });
999
1000 let app = CliBuilder::new()
1001 .config(config)
1002 .context(context)
1003 .register_sync_handler("test_handler", handler)
1004 .build();
1005
1006 assert!(app.is_ok());
1007 }
1008
1009 #[test]
1010 fn test_builder_build_missing_config() {
1011 let context = Box::new(TestContext::default());
1012
1013 let result = CliBuilder::new().context(context).build();
1014
1015 assert!(result.is_err());
1016 match result.unwrap_err() {
1017 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1018 assert!(reason.contains("No configuration provided"));
1019 }
1020 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1021 }
1022 }
1023
1024 #[test]
1025 fn test_builder_build_missing_context() {
1026 let config = create_test_config();
1027
1028 let result = CliBuilder::new().config(config).build();
1029
1030 assert!(result.is_err());
1031 match result.unwrap_err() {
1032 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1033 assert!(reason.contains("No execution context provided"));
1034 }
1035 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1036 }
1037 }
1038
1039 #[test]
1040 fn test_builder_build_missing_required_handler() {
1041 let config = create_test_config();
1042 let context = Box::new(TestContext::default());
1043
1044 let result = CliBuilder::new().config(config).context(context).build();
1045
1046 assert!(result.is_err());
1047 match result.unwrap_err() {
1048 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1049 assert!(reason.contains("Required command"));
1050 assert!(reason.contains("no registered handler"));
1051 }
1052 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1053 }
1054 }
1055
1056 #[test]
1057 fn test_builder_chaining() {
1058 let config = create_test_config();
1059 let context = Box::new(TestContext::default());
1060 let handler = Box::new(TestHandler {
1061 name: "test".to_string(),
1062 });
1063
1064 // Test that all methods chain correctly
1065 let app = CliBuilder::new()
1066 .config(config)
1067 .context(context)
1068 .register_sync_handler("test_handler", handler)
1069 .prompt("test")
1070 .build();
1071
1072 assert!(app.is_ok());
1073 }
1074
1075 #[test]
1076 fn test_cli_app_run_cli() {
1077 let config = create_test_config();
1078 let context = Box::new(TestContext::default());
1079 let handler = Box::new(TestHandler {
1080 name: "test".to_string(),
1081 });
1082
1083 let app = CliBuilder::new()
1084 .config(config)
1085 .context(context)
1086 .register_sync_handler("test_handler", handler)
1087 .build()
1088 .unwrap();
1089
1090 // Run with test command
1091 let result = app.run_cli(vec!["test".to_string()]);
1092 assert!(result.is_ok());
1093 }
1094
1095 #[test]
1096 fn test_default_prompt_from_config() {
1097 let config = create_test_config();
1098 let context = Box::new(TestContext::default());
1099 let handler = Box::new(TestHandler {
1100 name: "test".to_string(),
1101 });
1102
1103 let app = CliBuilder::new()
1104 .config(config)
1105 .context(context)
1106 .register_sync_handler("test_handler", handler)
1107 .build()
1108 .unwrap();
1109
1110 // Prompt should be taken from config
1111 assert_eq!(app.prompt, "test");
1112 }
1113
1114 #[test]
1115 fn test_override_prompt() {
1116 let config = create_test_config();
1117 let context = Box::new(TestContext::default());
1118 let handler = Box::new(TestHandler {
1119 name: "test".to_string(),
1120 });
1121
1122 let app = CliBuilder::new()
1123 .config(config)
1124 .context(context)
1125 .register_sync_handler("test_handler", handler)
1126 .prompt("custom")
1127 .build()
1128 .unwrap();
1129
1130 // Prompt should be overridden
1131 assert_eq!(app.prompt, "custom");
1132 }
1133
1134 // ============================================================================
1135 // async_handlers / register_async_handler / build() TESTS (DD-022)
1136 // ============================================================================
1137
1138 #[test]
1139 fn test_builder_with_async_handler() {
1140 let handler = Box::new(TestAsyncHandler {
1141 name: "test".to_string(),
1142 });
1143
1144 let builder = CliBuilder::new().register_async_handler("test_handler", handler);
1145
1146 assert_eq!(builder.async_handlers.len(), 1);
1147 assert!(builder.handlers.is_empty());
1148 }
1149
1150 /// The core gap this session closes: an async handler registered via
1151 /// `register_async_handler()` must actually reach the built registry —
1152 /// previously `build()` only ever drained `self.handlers`.
1153 #[test]
1154 fn test_builder_build_with_async_handler_only() {
1155 let config = create_test_config();
1156 let context = Box::new(TestContext::default());
1157 let handler = Box::new(TestAsyncHandler {
1158 name: "test".to_string(),
1159 });
1160
1161 let app = CliBuilder::new()
1162 .config(config)
1163 .context(context)
1164 .register_async_handler("test_handler", handler)
1165 .build();
1166
1167 assert!(app.is_ok());
1168 }
1169
1170 /// Registering both a sync and an async handler for the same
1171 /// implementation name must fail at build() time — this is the
1172 /// conflict check `build()` didn't have before this session.
1173 #[test]
1174 fn test_builder_build_sync_and_async_conflict() {
1175 let config = create_test_config();
1176 let context = Box::new(TestContext::default());
1177 let sync_handler = Box::new(TestHandler {
1178 name: "sync".to_string(),
1179 });
1180 let async_handler = Box::new(TestAsyncHandler {
1181 name: "async".to_string(),
1182 });
1183
1184 let result = CliBuilder::new()
1185 .config(config)
1186 .context(context)
1187 .register_sync_handler("test_handler", sync_handler)
1188 .register_async_handler("test_handler", async_handler)
1189 .build();
1190
1191 assert!(result.is_err());
1192 match result.unwrap_err() {
1193 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1194 assert!(reason.contains("both a sync and an async handler"));
1195 }
1196 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1197 }
1198 }
1199
1200 /// A required command satisfied only by an async handler must not be
1201 /// reported as "missing" — the required-handler check must look at
1202 /// both maps, not just `self.handlers`.
1203 #[test]
1204 fn test_builder_build_required_command_satisfied_by_async_handler() {
1205 let config = create_test_config(); // "test" command is `required: true`
1206 let context = Box::new(TestContext::default());
1207 let handler = Box::new(TestAsyncHandler {
1208 name: "test".to_string(),
1209 });
1210
1211 let result = CliBuilder::new()
1212 .config(config)
1213 .context(context)
1214 .register_async_handler("test_handler", handler)
1215 .build();
1216
1217 assert!(result.is_ok());
1218 }
1219
1220 #[test]
1221 fn test_cli_app_run_cli_with_async_handler() {
1222 let config = create_test_config();
1223 let context = Box::new(TestContext::default());
1224 let handler = Box::new(TestAsyncHandler {
1225 name: "test".to_string(),
1226 });
1227
1228 let app = CliBuilder::new()
1229 .config(config)
1230 .context(context)
1231 .register_async_handler("test_handler", handler)
1232 .build()
1233 .unwrap();
1234
1235 let result = app.run_cli(vec!["test".to_string()]);
1236 assert!(result.is_ok());
1237 }
1238
1239 #[test]
1240 fn test_builder_with_help_formatter() {
1241 use crate::help::DefaultHelpFormatter;
1242
1243 let formatter = Box::new(DefaultHelpFormatter::new());
1244 let builder = CliBuilder::new().help_formatter(formatter);
1245
1246 assert!(builder.help_formatter.is_some());
1247 }
1248
1249 #[test]
1250 fn test_run_cli_help_global() {
1251 let config = create_test_config();
1252 let context = Box::new(TestContext::default());
1253 let handler = Box::new(TestHandler {
1254 name: "test".to_string(),
1255 });
1256
1257 let app = CliBuilder::new()
1258 .config(config)
1259 .context(context)
1260 .register_sync_handler("test_handler", handler)
1261 .build()
1262 .unwrap();
1263
1264 // --help should return Ok(()) without dispatching to any handler.
1265 let result = app.run_cli(vec!["--help".to_string()]);
1266 assert!(result.is_ok());
1267 }
1268
1269 #[test]
1270 fn test_run_cli_help_command() {
1271 let config = create_test_config();
1272 let context = Box::new(TestContext::default());
1273 let handler = Box::new(TestHandler {
1274 name: "test".to_string(),
1275 });
1276
1277 let app = CliBuilder::new()
1278 .config(config)
1279 .context(context)
1280 .register_sync_handler("test_handler", handler)
1281 .build()
1282 .unwrap();
1283
1284 // --help <command> should return Ok(()) without dispatching.
1285 let result = app.run_cli(vec!["--help".to_string(), "test".to_string()]);
1286 assert!(result.is_ok());
1287 }
1288
1289 #[test]
1290 fn test_run_cli_help_unknown_command_still_ok() {
1291 let config = create_test_config();
1292 let context = Box::new(TestContext::default());
1293 let handler = Box::new(TestHandler {
1294 name: "test".to_string(),
1295 });
1296
1297 let app = CliBuilder::new()
1298 .config(config)
1299 .context(context)
1300 .register_sync_handler("test_handler", handler)
1301 .build()
1302 .unwrap();
1303
1304 // --help with an unknown command name: formatter handles it gracefully.
1305 let result = app.run_cli(vec!["--help".to_string(), "ghost".to_string()]);
1306 assert!(result.is_ok());
1307 }
1308}