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