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