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::config::validator::validate_config;
48use crate::context::ExecutionContext;
49use crate::error::{ConfigError, DynamicCliError, Result};
50use crate::executor::{AsyncCommandHandler, CommandHandler};
51use crate::help::{command_help_footer, 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, _: &dynamic_cli::parser::ParsedArgs) -> 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 /// Base override for the REPL's `\`-continuation prompt (#68).
118 /// Forwarded verbatim to [`ReplInterface::with_prompt_multiline`] when
119 /// set; `None` keeps the "..." default. No effect in CLI one-shot mode.
120 prompt_multiline: Option<String>,
121
122 /// Custom help formatter. None = DefaultHelpFormatter used lazily.
123 help_formatter: Option<Box<dyn HelpFormatter>>,
124}
125
126impl CliBuilder {
127 /// Create a new builder
128 ///
129 /// # Example
130 ///
131 /// ```
132 /// use dynamic_cli::CliBuilder;
133 ///
134 /// let builder = CliBuilder::new();
135 /// ```
136 pub fn new() -> Self {
137 Self {
138 config_path: None,
139 config: None,
140 context: None,
141 handlers: HashMap::new(),
142 async_handlers: HashMap::new(),
143 plugins: Vec::new(),
144 prompt: None,
145 prompt_multiline: None,
146 help_formatter: None,
147 }
148 }
149
150 /// Specify the configuration file
151 ///
152 /// The file will be loaded and validated during `build()`. Supports YAML
153 /// and JSON formats.
154 ///
155 /// # Arguments
156 ///
157 /// * `path` - Path to the configuration file (`.yaml`, `.yml`, or `.json`)
158 ///
159 /// # Example
160 ///
161 /// ```
162 /// use dynamic_cli::CliBuilder;
163 ///
164 /// let builder = CliBuilder::new()
165 /// .config_file("commands.yaml");
166 /// ```
167 pub fn config_file<P: Into<PathBuf>>(mut self, path: P) -> Self {
168 self.config_path = Some(path.into());
169 self
170 }
171
172 /// Provide a pre-loaded configuration
173 ///
174 /// Use this instead of `config_file()` if you want to load and potentially
175 /// modify the configuration before building.
176 ///
177 /// The configuration does not need to be validated beforehand: `build()`
178 /// runs [`validate_config`] on it.
179 ///
180 /// # Arguments
181 ///
182 /// * `config` - Loaded configuration
183 ///
184 /// # Example
185 ///
186 /// ```no_run
187 /// use dynamic_cli::{CliBuilder, config::loader::load_config};
188 ///
189 /// # fn main() -> dynamic_cli::Result<()> {
190 /// let mut config = load_config("commands.yaml")?;
191 /// // Modify config if needed...
192 ///
193 /// let builder = CliBuilder::new()
194 /// .config(config);
195 /// # Ok(())
196 /// # }
197 /// ```
198 pub fn config(mut self, config: CommandsConfig) -> Self {
199 self.config = Some(config);
200 self
201 }
202
203 /// Set the execution context
204 ///
205 /// The context will be passed to all command handlers and can store
206 /// application state.
207 ///
208 /// # Arguments
209 ///
210 /// * `context` - Boxed execution context implementing `ExecutionContext`
211 ///
212 /// # Example
213 ///
214 /// ```
215 /// use dynamic_cli::prelude::*;
216 ///
217 /// #[derive(Default)]
218 /// struct MyContext {
219 /// count: u32,
220 /// }
221 ///
222 /// impl ExecutionContext for MyContext {
223 /// fn as_any(&self) -> &dyn std::any::Any { self }
224 /// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
225 /// }
226 ///
227 /// let builder = CliBuilder::new()
228 /// .context(Box::new(MyContext::default()));
229 /// ```
230 pub fn context(mut self, context: Box<dyn ExecutionContext>) -> Self {
231 self.context = Some(context);
232 self
233 }
234
235 /// Register a (sync) command handler
236 ///
237 /// Associates a handler with the command's implementation name from the config.
238 /// The name must match the `implementation` field in the command definition.
239 ///
240 /// # Arguments
241 ///
242 /// * `name` - Implementation name from the configuration
243 /// * `handler` - Boxed command handler implementing `CommandHandler`
244 ///
245 /// # Example
246 ///
247 /// ```
248 /// use dynamic_cli::prelude::*;
249 ///
250 /// struct MyCommand;
251 ///
252 /// impl CommandHandler for MyCommand {
253 /// fn execute(
254 /// &self,
255 /// _ctx: &mut dyn ExecutionContext,
256 /// _args: &ParsedArgs,
257 /// ) -> dynamic_cli::Result<()> {
258 /// println!("Executed!");
259 /// Ok(())
260 /// }
261 /// }
262 ///
263 /// let builder = CliBuilder::new()
264 /// .register_sync_handler("my_command", Box::new(MyCommand));
265 /// ```
266 pub fn register_sync_handler(
267 mut self,
268 name: impl Into<String>,
269 handler: Box<dyn CommandHandler>,
270 ) -> Self {
271 self.handlers.insert(name.into(), handler);
272 self
273 }
274
275 /// Register an async command handler
276 ///
277 /// Additive counterpart of [`register_sync_handler`][Self::register_sync_handler].
278 /// Associates an async handler with the command's implementation name
279 /// from the config, exactly like its sync counterpart. Registering both
280 /// a sync and an async handler for the *same* implementation name is
281 /// detected as an error at [`build()`][Self::build] time, not here —
282 /// this method, like `register_sync_handler`, always succeeds (it just
283 /// stores the handler for `build()` to consume).
284 ///
285 /// # Arguments
286 ///
287 /// * `name` - Implementation name from the configuration
288 /// * `handler` - Boxed async command handler implementing `AsyncCommandHandler`
289 ///
290 /// # Example
291 ///
292 /// ```
293 /// use dynamic_cli::prelude::*;
294 /// use dynamic_cli::executor::AsyncCommandHandler;
295 /// use async_trait::async_trait;
296 ///
297 /// struct FetchCommand;
298 ///
299 /// #[async_trait]
300 /// impl AsyncCommandHandler for FetchCommand {
301 /// async fn execute(
302 /// &self,
303 /// _ctx: &mut dyn ExecutionContext,
304 /// _args: &ParsedArgs,
305 /// ) -> dynamic_cli::Result<()> {
306 /// println!("Fetched!");
307 /// Ok(())
308 /// }
309 /// }
310 ///
311 /// let builder = CliBuilder::new()
312 /// .register_async_handler("fetch_command", Box::new(FetchCommand));
313 /// ```
314 pub fn register_async_handler(
315 mut self,
316 name: impl Into<String>,
317 handler: Box<dyn AsyncCommandHandler>,
318 ) -> Self {
319 self.async_handlers.insert(name.into(), handler);
320 self
321 }
322
323 /// Register a plugin
324 ///
325 /// A plugin groups related handlers under a single unit. During
326 /// [`build()`][Self::build], each handler declared by the plugin is
327 /// merged into the handler map. Conflicts with already-registered
328 /// handler names (whether from another plugin or from
329 /// [`register_sync_handler`][Self::register_sync_handler]) produce a
330 /// build-time error.
331 ///
332 /// The YAML config remains the sole source of truth for command
333 /// definitions. Plugin handlers are matched by their `implementation`
334 /// name, exactly as with [`register_sync_handler`][Self::register_sync_handler].
335 /// Plugins remain sync-only for now: `Plugin::handlers()` returns sync
336 /// handlers only.
337 ///
338 /// # Example
339 ///
340 /// ```
341 /// use dynamic_cli::CliBuilder;
342 /// use dynamic_cli::plugin::SystemPlugin;
343 ///
344 /// let builder = CliBuilder::new()
345 /// .register_plugin(Box::new(SystemPlugin::new()));
346 /// ```
347 pub fn register_plugin(mut self, plugin: Box<dyn Plugin>) -> Self {
348 self.plugins.push(plugin);
349 self
350 }
351
352 /// Load a WASM plugin from disk, map its business functions, and
353 /// register it
354 ///
355 /// Convenience wrapper around [`WasmPlugin::load`][crate::plugin::wasm::WasmPlugin::load],
356 /// [`WasmPlugin::with_function_map`][crate::plugin::wasm::WasmPlugin::with_function_map],
357 /// and [`register_plugin`][Self::register_plugin]. Only available with
358 /// the `wasm-plugins` feature.
359 ///
360 /// `function_map` is mandatory here, unlike the other `WasmPlugin`
361 /// builder methods (`with_format`, `with_metadata`), which have
362 /// reasonable defaults (YAML, file-name-derived metadata). A
363 /// `WasmPlugin` with an empty function map registers zero handlers —
364 /// silently inert — so this wrapper does not offer a path that skips
365 /// mapping. Applications that also need a non-default format or
366 /// explicit metadata should build the `WasmPlugin` directly and pass it
367 /// to [`register_plugin`][Self::register_plugin] instead.
368 ///
369 /// # Errors
370 ///
371 /// Returns an error if the module cannot be loaded or fails mandatory
372 /// export validation. See `WASM_PLUGIN_INTERFACE.md` for the ABI
373 /// contract.
374 ///
375 /// # Example
376 ///
377 /// ```no_run
378 /// use dynamic_cli::CliBuilder;
379 /// use std::path::Path;
380 ///
381 /// # fn main() -> dynamic_cli::Result<()> {
382 /// let builder = CliBuilder::new()
383 /// .register_wasm_plugin(
384 /// Path::new("plugins/greet.wasm"),
385 /// &[("greet_hello", "say_hello")],
386 /// )?;
387 /// # Ok(())
388 /// # }
389 /// ```
390 #[cfg(feature = "wasm-plugins")]
391 pub fn register_wasm_plugin(
392 self,
393 path: &std::path::Path,
394 function_map: &[(&str, &str)],
395 ) -> Result<Self> {
396 let mut plugin = crate::plugin::wasm::WasmPlugin::load(path)?;
397 for &(impl_name, wasm_fn_name) in function_map {
398 plugin = plugin.with_function_map(impl_name, wasm_fn_name);
399 }
400 Ok(self.register_plugin(Box::new(plugin)))
401 }
402
403 /// Set the REPL prompt
404 ///
405 /// Only used in REPL mode. If not specified, uses the prompt from
406 /// the configuration or defaults to "cli".
407 ///
408 /// # Arguments
409 ///
410 /// * `prompt` - Prompt prefix (e.g., "myapp" displays as "myapp > ")
411 ///
412 /// # Example
413 ///
414 /// ```
415 /// use dynamic_cli::CliBuilder;
416 ///
417 /// let builder = CliBuilder::new()
418 /// .prompt("myapp");
419 /// ```
420 pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
421 self.prompt = Some(prompt.into());
422 self
423 }
424
425 /// Override the base segment of the REPL's `\`-continuation prompt
426 /// (#68).
427 ///
428 /// Only used in REPL mode — no effect on `CliInterface::run()` (CLI
429 /// one-shot) or `run_script()`/`:load`, which never accumulate
430 /// `\`-continued input: without a live operator, nothing can answer
431 /// a continuation prompt. Mirrors
432 /// [`ReplInterface::with_prompt_multiline`], to which the value is
433 /// forwarded verbatim at `build()`/`run()` time — see that method for
434 /// exactly how the suffix is appended. If not called, the base defaults
435 /// to `"..."`.
436 ///
437 /// # Arguments
438 ///
439 /// * `prompt` - Base segment (e.g., `"_ _ _"`), not the full prompt —
440 /// the configured `prompt_suffix` is always appended after it.
441 ///
442 /// # Example
443 ///
444 /// ```
445 /// use dynamic_cli::CliBuilder;
446 ///
447 /// let builder = CliBuilder::new()
448 /// .prompt("rpn")
449 /// .prompt_multiline("_ _ _");
450 /// ```
451 pub fn prompt_multiline(mut self, prompt: impl Into<String>) -> Self {
452 self.prompt_multiline = Some(prompt.into());
453 self
454 }
455
456 /// Set a custom help formatter.
457 ///
458 /// By default, [`DefaultHelpFormatter`] is used lazily when `--help` is
459 /// detected. Call this method to supply your own implementation.
460 ///
461 /// The formatter is stored and transferred to [`CliApp`] during `build()`.
462 /// It is instantiated **only** when `--help` is detected in `run_cli()`.
463 ///
464 /// # Arguments
465 ///
466 /// * `formatter` - Boxed implementation of [`HelpFormatter`]
467 ///
468 /// # Example
469 ///
470 /// ```
471 /// use dynamic_cli::CliBuilder;
472 /// use dynamic_cli::help::{HelpFormatter, DefaultHelpFormatter};
473 /// use dynamic_cli::config::schema::CommandsConfig;
474 ///
475 /// struct MyFormatter;
476 ///
477 /// impl HelpFormatter for MyFormatter {
478 /// fn format_app(&self, config: &CommandsConfig) -> String {
479 /// format!("Help for {}", config.metadata.prompt)
480 /// }
481 /// fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
482 /// format!("Help for command '{command}'")
483 /// }
484 /// }
485 ///
486 /// let builder = CliBuilder::new()
487 /// .help_formatter(Box::new(MyFormatter));
488 /// ```
489 pub fn help_formatter(mut self, formatter: Box<dyn HelpFormatter>) -> Self {
490 self.help_formatter = Some(formatter);
491 self
492 }
493
494 /// Build the application
495 ///
496 /// Performs the following steps:
497 /// 1. Load configuration (if `config_file()` was used)
498 /// 2. Validate the configuration with
499 /// [`validate_config`], whether it
500 /// came from `config_file()` or `config()`
501 /// 3. Validate that a context was provided
502 /// 4. Create the command registry
503 /// 5. Register all command handlers
504 /// 6. Verify that all required commands have handlers
505 /// 7. Create the `CliApp`
506 ///
507 /// # Returns
508 ///
509 /// A configured `CliApp` ready to run
510 ///
511 /// # Errors
512 ///
513 /// - Configuration errors (file not found, invalid format, configuration
514 /// rejected by the validator, etc.)
515 /// - Missing context
516 /// - Missing required handlers
517 /// - Registry errors
518 ///
519 /// # Example
520 ///
521 /// ```no_run
522 /// use dynamic_cli::prelude::*;
523 ///
524 /// # #[derive(Default)]
525 /// # struct MyContext;
526 /// # impl ExecutionContext for MyContext {
527 /// # fn as_any(&self) -> &dyn std::any::Any { self }
528 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
529 /// # }
530 /// # struct MyHandler;
531 /// # impl CommandHandler for MyHandler {
532 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
533 /// # }
534 /// # fn main() -> dynamic_cli::Result<()> {
535 /// let app = CliBuilder::new()
536 /// .config_file("commands.yaml")
537 /// .context(Box::new(MyContext::default()))
538 /// .register_sync_handler("handler", Box::new(MyHandler))
539 /// .build()?;
540 ///
541 /// // Now app is ready to run
542 /// # Ok(())
543 /// # }
544 /// ```
545 pub fn build(mut self) -> Result<CliApp> {
546 // Load configuration if path was specified
547 let config = if let Some(config) = self.config.take() {
548 config
549 } else if let Some(path) = self.config_path.take() {
550 load_config(path)?
551 } else {
552 return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
553 reason: "No configuration provided. Use config_file() or config()".to_string(),
554 path: None,
555 suggestion: None,
556 }));
557 };
558
559 // DA-002 (#2): the configuration is validated at startup, whatever
560 // its source (#96)
561 validate_config(&config)?;
562
563 // Validate context was provided
564 let context = self.context.take().ok_or_else(|| {
565 DynamicCliError::Config(ConfigError::InvalidSchema {
566 reason: "No execution context provided. Use context()".to_string(),
567 path: None,
568 suggestion: None,
569 })
570 })?;
571
572 // Create registry and register commands
573 let mut registry = CommandRegistry::new();
574
575 // Expand plugins into the handler map before processing commands.
576 // Conflicts between plugins (or between a plugin and a directly
577 // registered handler) are detected here and produce a clear error,
578 // before any command resolution begins.
579 for plugin in self.plugins.drain(..) {
580 let plugin_name = plugin.name().to_string();
581 for (impl_name, handler) in plugin.handlers() {
582 if self.handlers.contains_key(&impl_name) {
583 return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
584 reason: format!(
585 "Plugin '{}' tried to register handler '{}' \
586 which is already registered.",
587 plugin_name, impl_name
588 ),
589 path: None,
590 suggestion: Some(format!(
591 "Remove the duplicate call to register_sync_handler(\"{}\") \
592 or rename the implementation in your YAML config.",
593 impl_name
594 )),
595 }));
596 }
597 self.handlers.insert(impl_name, handler);
598 }
599 }
600
601 for command_def in &config.commands {
602 // Find handlers for this command — sync and async are looked up
603 // independently; at most one should be present (see conflict
604 // check below).
605 let sync_handler = self.handlers.remove(&command_def.implementation);
606 let async_handler = self.async_handlers.remove(&command_def.implementation);
607
608 // A command's implementation name must resolve to exactly one
609 // handler kind. Registering both is a configuration mistake,
610 // not something to resolve silently (e.g. "sync wins") — that
611 // would hide a bug where the same name was registered twice
612 // with different handler types.
613 if sync_handler.is_some() && async_handler.is_some() {
614 return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
615 reason: format!(
616 "Command '{}' has both a sync and an async handler \
617 registered for implementation '{}'.",
618 command_def.name, command_def.implementation
619 ),
620 path: None,
621 suggestion: Some(
622 "Use either register_sync_handler() or \
623 register_async_handler() for this implementation, not both."
624 .to_string(),
625 ),
626 }));
627 }
628
629 // Check if handler is required
630 if command_def.required && sync_handler.is_none() && async_handler.is_none() {
631 return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
632 reason: format!(
633 "Required command '{}' has no registered handler (implementation: '{}'). \
634 Use register_sync_handler() or register_async_handler() to register it.",
635 command_def.name, command_def.implementation
636 ),
637 path: None,
638 suggestion: None,
639 }));
640 }
641
642 // Register command with whichever handler kind was found.
643 if let Some(handler) = sync_handler {
644 registry.register_sync(command_def.clone(), handler)?;
645 } else if let Some(handler) = async_handler {
646 registry.register_async(command_def.clone(), handler)?;
647 }
648 }
649
650 // Determine prompt
651 let prompt = self
652 .prompt
653 .or_else(|| Some(config.metadata.prompt.clone()))
654 .unwrap_or_else(|| "cli".to_string());
655
656 Ok(CliApp {
657 registry,
658 context,
659 prompt,
660 prompt_multiline: self.prompt_multiline,
661 config,
662 help_formatter: self.help_formatter,
663 })
664 }
665}
666
667impl Default for CliBuilder {
668 fn default() -> Self {
669 Self::new()
670 }
671}
672
673/// Built CLI/REPL application
674///
675/// Created by `CliBuilder::build()`. Provides methods to run the application
676/// in different modes:
677/// - `run()` - Auto-detect CLI vs REPL based on arguments
678/// - `run_cli()` - Force CLI mode with specific arguments
679/// - `run_repl()` - Force REPL mode
680///
681/// # Example
682///
683/// ```no_run
684/// use dynamic_cli::prelude::*;
685///
686/// # #[derive(Default)]
687/// # struct MyContext;
688/// # impl ExecutionContext for MyContext {
689/// # fn as_any(&self) -> &dyn std::any::Any { self }
690/// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
691/// # }
692/// # struct MyHandler;
693/// # impl CommandHandler for MyHandler {
694/// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
695/// # }
696/// # fn main() -> dynamic_cli::Result<()> {
697/// let app = CliBuilder::new()
698/// .config_file("commands.yaml")
699/// .context(Box::new(MyContext::default()))
700/// .register_sync_handler("handler", Box::new(MyHandler))
701/// .build()?;
702///
703/// // Auto-detect mode (CLI if args provided, REPL otherwise)
704/// app.run()
705/// # }
706/// ```
707pub struct CliApp {
708 /// Command registry
709 registry: CommandRegistry,
710
711 /// Execution context
712 context: Box<dyn ExecutionContext>,
713
714 /// REPL prompt
715 prompt: String,
716
717 /// Base override for the REPL's `\`-continuation prompt (#68).
718 /// Forwarded to `ReplInterface::with_prompt_multiline` in `run_repl()`
719 /// when set. Unused outside REPL mode.
720 prompt_multiline: Option<String>,
721
722 /// Full configuration - needed by the help formatter
723 config: CommandsConfig,
724
725 /// Custom help formatter, or None to use DefaultHelpFormatter
726 help_formatter: Option<Box<dyn HelpFormatter>>,
727}
728
729impl std::fmt::Debug for CliApp {
730 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
731 f.debug_struct("CliApp")
732 .field("prompt", &self.prompt)
733 .field("prompt_multiline", &self.prompt_multiline)
734 .field("registry", &"<CommandRegistry>")
735 .field("context", &"<ExecutionContext>")
736 .field("help_formatter", &"<Option<Box<dyn HelpFormatter>>>")
737 .finish()
738 }
739}
740
741impl CliApp {
742 /// Run in CLI mode with provided arguments
743 ///
744 /// Executes a single command and exits.
745 ///
746 /// # Arguments
747 ///
748 /// * `args` - Command-line arguments (typically from `env::args().skip(1)`)
749 ///
750 /// # Returns
751 ///
752 /// - `Ok(())` on successful execution
753 /// - `Err(...)` on parse, validation, or execution errors
754 ///
755 /// # Example
756 ///
757 /// ```no_run
758 /// # use dynamic_cli::prelude::*;
759 /// # #[derive(Default)]
760 /// # struct MyContext;
761 /// # impl ExecutionContext for MyContext {
762 /// # fn as_any(&self) -> &dyn std::any::Any { self }
763 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
764 /// # }
765 /// # struct MyHandler;
766 /// # impl CommandHandler for MyHandler {
767 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
768 /// # }
769 /// # fn main() -> dynamic_cli::Result<()> {
770 /// # let app = CliBuilder::new()
771 /// # .config_file("commands.yaml")
772 /// # .context(Box::new(MyContext::default()))
773 /// # .register_sync_handler("handler", Box::new(MyHandler))
774 /// # .build()?;
775 /// // Run with specific arguments
776 /// app.run_cli(vec!["command".to_string(), "arg1".to_string()])
777 /// # }
778 /// ```
779 pub fn run_cli(self, args: Vec<String>) -> Result<()> {
780 // Intercept --help before command dispatch.
781 match args.as_slice() {
782 [flag] if flag == "--help" => {
783 print!("{}", self.render_cli_help(None));
784 return Ok(());
785 }
786 [flag, command] if flag == "--help" => {
787 print!("{}", self.render_cli_help(Some(command)));
788 return Ok(());
789 }
790 _ => {}
791 }
792
793 let cli = CliInterface::new(self.registry, self.context);
794 cli.run(args)
795 }
796
797 /// Render the CLI help: the application help followed by the line on
798 /// `--help <command>`, or the help of `command`.
799 ///
800 /// Uses the supplied formatter, or [`DefaultHelpFormatter`] when none
801 /// was supplied. `DefaultHelpFormatter` is a unit struct: the fallback
802 /// costs no allocation and is only built when help is requested.
803 fn render_cli_help(&self, command: Option<&str>) -> String {
804 let default_formatter = DefaultHelpFormatter::new();
805 let formatter: &dyn HelpFormatter =
806 self.help_formatter.as_deref().unwrap_or(&default_formatter);
807
808 match command {
809 Some(cmd) => formatter.format_command(&self.config, cmd),
810 None => {
811 let mut out = formatter.format_app(&self.config);
812 out.push_str(&command_help_footer("Run with", "--help <command>"));
813 out
814 }
815 }
816 }
817
818 /// Run a batch of command lines read from a file (#41).
819 ///
820 /// Convenience wrapper around
821 /// [`CliInterface::run_script`][crate::interface::CliInterface::run_script]
822 /// — see there for the line format, tokenization, and error-policy
823 /// details.
824 ///
825 /// # Example
826 ///
827 /// ```no_run
828 /// # use dynamic_cli::prelude::*;
829 /// # #[derive(Default)]
830 /// # struct MyContext;
831 /// # impl ExecutionContext for MyContext {
832 /// # fn as_any(&self) -> &dyn std::any::Any { self }
833 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
834 /// # }
835 /// # struct MyHandler;
836 /// # impl CommandHandler for MyHandler {
837 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
838 /// # }
839 /// # fn main() -> dynamic_cli::Result<()> {
840 /// # let app = CliBuilder::new()
841 /// # .config_file("commands.yaml")
842 /// # .context(Box::new(MyContext::default()))
843 /// # .register_sync_handler("handler", Box::new(MyHandler))
844 /// # .build()?;
845 /// let outcome = app.run_script("commands.txt", ScriptErrorPolicy::Continue)?;
846 /// println!("{}/{} lines succeeded", outcome.lines_succeeded, outcome.lines_executed);
847 /// # Ok(())
848 /// # }
849 /// ```
850 pub fn run_script(
851 self,
852 path: impl AsRef<std::path::Path>,
853 policy: crate::interface::ScriptErrorPolicy,
854 ) -> Result<crate::interface::ScriptOutcome> {
855 let cli = CliInterface::new(self.registry, self.context);
856 cli.run_script(path, policy)
857 }
858
859 /// Run in REPL mode
860 ///
861 /// Enters an interactive loop that continues until the user exits.
862 ///
863 /// # Returns
864 ///
865 /// - `Ok(())` when user exits normally
866 /// - `Err(...)` on critical errors (e.g., rustyline initialization failure)
867 ///
868 /// # Example
869 ///
870 /// ```no_run
871 /// # use dynamic_cli::prelude::*;
872 /// # #[derive(Default)]
873 /// # struct MyContext;
874 /// # impl ExecutionContext for MyContext {
875 /// # fn as_any(&self) -> &dyn std::any::Any { self }
876 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
877 /// # }
878 /// # struct MyHandler;
879 /// # impl CommandHandler for MyHandler {
880 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
881 /// # }
882 /// # fn main() -> dynamic_cli::Result<()> {
883 /// # let app = CliBuilder::new()
884 /// # .config_file("commands.yaml")
885 /// # .context(Box::new(MyContext::default()))
886 /// # .register_sync_handler("handler", Box::new(MyHandler))
887 /// # .build()?;
888 /// // Start interactive REPL
889 /// app.run_repl()
890 /// # }
891 /// ```
892 pub fn run_repl(self) -> Result<()> {
893 let mut repl = ReplInterface::new(
894 self.registry,
895 self.context,
896 self.prompt,
897 Some(self.config),
898 self.help_formatter,
899 )?;
900
901 if let Some(prompt_multiline) = self.prompt_multiline {
902 repl = repl.with_prompt_multiline(prompt_multiline);
903 }
904
905 repl.run()
906 }
907
908 /// Run with automatic mode detection
909 ///
910 /// Decides between CLI and REPL based on command-line arguments:
911 /// - If arguments provided → CLI mode
912 /// - If no arguments → REPL mode
913 ///
914 /// This is the recommended method for most applications.
915 ///
916 /// # Returns
917 ///
918 /// - `Ok(())` on successful execution
919 /// - `Err(...)` on errors
920 ///
921 /// # Example
922 ///
923 /// ```no_run
924 /// # use dynamic_cli::prelude::*;
925 /// # #[derive(Default)]
926 /// # struct MyContext;
927 /// # impl ExecutionContext for MyContext {
928 /// # fn as_any(&self) -> &dyn std::any::Any { self }
929 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
930 /// # }
931 /// # struct MyHandler;
932 /// # impl CommandHandler for MyHandler {
933 /// # fn execute(&self, _: &mut dyn ExecutionContext, _: &dynamic_cli::parser::ParsedArgs) -> dynamic_cli::Result<()> { Ok(()) }
934 /// # }
935 /// # fn main() -> dynamic_cli::Result<()> {
936 /// # let app = CliBuilder::new()
937 /// # .config_file("commands.yaml")
938 /// # .context(Box::new(MyContext::default()))
939 /// # .register_sync_handler("handler", Box::new(MyHandler))
940 /// # .build()?;
941 /// // Auto-detect: CLI if args, REPL if no args
942 /// app.run()
943 /// # }
944 /// ```
945 pub fn run(self) -> Result<()> {
946 let args: Vec<String> = std::env::args().skip(1).collect();
947
948 if args.is_empty() {
949 // No arguments → REPL mode
950 self.run_repl()
951 } else {
952 // Arguments provided → CLI mode
953 self.run_cli(args)
954 }
955 }
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961 use crate::config::schema::{CommandDefinition, Metadata};
962 use crate::parser::ParsedArgs;
963
964 // Test context
965 #[derive(Default)]
966 struct TestContext {
967 executed: Vec<String>,
968 }
969
970 impl ExecutionContext for TestContext {
971 fn as_any(&self) -> &dyn std::any::Any {
972 self
973 }
974
975 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
976 self
977 }
978 }
979
980 // Test handler
981 struct TestHandler {
982 name: String,
983 }
984
985 impl CommandHandler for TestHandler {
986 fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
987 let ctx =
988 crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
989 ctx.executed.push(self.name.clone());
990 Ok(())
991 }
992 }
993
994 struct TestAsyncHandler {
995 name: String,
996 }
997
998 #[async_trait::async_trait]
999 impl AsyncCommandHandler for TestAsyncHandler {
1000 async fn execute(
1001 &self,
1002 context: &mut dyn ExecutionContext,
1003 _args: &ParsedArgs,
1004 ) -> Result<()> {
1005 let ctx =
1006 crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
1007 ctx.executed.push(self.name.clone());
1008 Ok(())
1009 }
1010 }
1011
1012 fn create_test_config() -> CommandsConfig {
1013 CommandsConfig {
1014 metadata: Metadata {
1015 version: "1.0.0".to_string(),
1016 prompt: "test".to_string(),
1017 prompt_suffix: " > ".to_string(),
1018 },
1019 commands: vec![CommandDefinition {
1020 name: "test".to_string(),
1021 aliases: vec![],
1022 description: "Test command".to_string(),
1023 required: true,
1024 arguments: vec![],
1025 options: vec![],
1026 implementation: "test_handler".to_string(),
1027 continue_on_failure: false,
1028 requires_success: false,
1029 }],
1030 global_options: vec![],
1031 directives: vec![],
1032 }
1033 }
1034
1035 #[test]
1036 fn test_builder_creation() {
1037 let builder = CliBuilder::new();
1038 assert!(builder.config.is_none());
1039 assert!(builder.context.is_none());
1040 }
1041
1042 #[test]
1043 fn test_builder_with_config() {
1044 let config = create_test_config();
1045 let builder = CliBuilder::new().config(config.clone());
1046
1047 assert!(builder.config.is_some());
1048 }
1049
1050 #[test]
1051 fn test_builder_with_context() {
1052 let context = Box::new(TestContext::default());
1053 let builder = CliBuilder::new().context(context);
1054
1055 assert!(builder.context.is_some());
1056 }
1057
1058 #[test]
1059 fn test_builder_with_handler() {
1060 let handler = Box::new(TestHandler {
1061 name: "test".to_string(),
1062 });
1063
1064 let builder = CliBuilder::new().register_sync_handler("test_handler", handler);
1065
1066 assert_eq!(builder.handlers.len(), 1);
1067 }
1068
1069 #[test]
1070 fn test_builder_with_prompt() {
1071 let builder = CliBuilder::new().prompt("myapp");
1072
1073 assert_eq!(builder.prompt, Some("myapp".to_string()));
1074 }
1075
1076 #[test]
1077 fn test_builder_with_prompt_multiline() {
1078 let builder = CliBuilder::new().prompt("rpn").prompt_multiline("_ _ _");
1079
1080 assert_eq!(builder.prompt_multiline, Some("_ _ _".to_string()));
1081 }
1082
1083 #[test]
1084 fn test_builder_without_prompt_multiline_defaults_to_none() {
1085 // No-op when never called — CliApp/ReplInterface fall back to the
1086 // "..." default — DD-027 (#48), #67 — no breaking change for existing
1087 // callers of CliBuilder that never set this.
1088 let builder = CliBuilder::new().prompt("myapp");
1089
1090 assert_eq!(builder.prompt_multiline, None);
1091 }
1092
1093 #[test]
1094 fn test_builder_build_success() {
1095 let config = create_test_config();
1096 let context = Box::new(TestContext::default());
1097 let handler = Box::new(TestHandler {
1098 name: "test".to_string(),
1099 });
1100
1101 let app = CliBuilder::new()
1102 .config(config)
1103 .context(context)
1104 .register_sync_handler("test_handler", handler)
1105 .build();
1106
1107 assert!(app.is_ok());
1108 }
1109
1110 #[test]
1111 fn test_builder_build_missing_config() {
1112 let context = Box::new(TestContext::default());
1113
1114 let result = CliBuilder::new().context(context).build();
1115
1116 assert!(result.is_err());
1117 match result.unwrap_err() {
1118 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1119 assert!(reason.contains("No configuration provided"));
1120 }
1121 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1122 }
1123 }
1124
1125 // ------------------------------------------------------------------
1126 // Configuration validation at build time (#96)
1127 // ------------------------------------------------------------------
1128
1129 /// Configuration rejected by `validate_config`: a required argument
1130 /// follows an optional one
1131 fn create_invalid_config() -> CommandsConfig {
1132 use crate::config::schema::{ArgumentDefinition, ArgumentType};
1133
1134 let argument = |name: &str, required: bool| ArgumentDefinition {
1135 name: name.to_string(),
1136 arg_type: ArgumentType::String,
1137 required,
1138 description: "Test argument".to_string(),
1139 validation: vec![],
1140 secure: false,
1141 };
1142 let mut config = create_test_config();
1143 config.commands[0].arguments = vec![argument("first", false), argument("second", true)];
1144 config
1145 }
1146
1147 fn build_with(config: CommandsConfig) -> Result<CliApp> {
1148 CliBuilder::new()
1149 .config(config)
1150 .context(Box::new(TestContext::default()))
1151 .register_sync_handler(
1152 "test_handler",
1153 Box::new(TestHandler {
1154 name: "test".to_string(),
1155 }),
1156 )
1157 .build()
1158 }
1159
1160 #[test]
1161 fn test_builder_build_rejects_invalid_config() {
1162 // Without validation in `build()`, this configuration was accepted.
1163 let config = create_invalid_config();
1164 assert!(validate_config(&config).is_err());
1165
1166 match build_with(config) {
1167 Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, path, .. })) => {
1168 assert!(reason.contains("'second'"), "{}", reason);
1169 assert_eq!(path.as_deref(), Some("test.arguments[1]"));
1170 }
1171 Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
1172 Ok(_) => panic!("Expected build() to reject the configuration"),
1173 }
1174 }
1175
1176 #[test]
1177 fn test_builder_build_rejects_invalid_config_file() {
1178 use std::io::Write;
1179
1180 let mut file = tempfile::Builder::new().suffix(".yaml").tempfile().unwrap();
1181 let yaml = serde_yaml::to_string(&create_invalid_config()).unwrap();
1182 file.write_all(yaml.as_bytes()).unwrap();
1183
1184 let result = CliBuilder::new()
1185 .config_file(file.path())
1186 .context(Box::new(TestContext::default()))
1187 .register_sync_handler(
1188 "test_handler",
1189 Box::new(TestHandler {
1190 name: "test".to_string(),
1191 }),
1192 )
1193 .build();
1194
1195 assert!(matches!(
1196 result,
1197 Err(DynamicCliError::Config(ConfigError::InvalidSchema { .. }))
1198 ));
1199 }
1200
1201 #[test]
1202 fn test_builder_build_rejects_directive_collision() {
1203 use crate::config::{DirectiveDefinition, ReplDirective};
1204
1205 // `q` stays the default alias of `repl_quit`.
1206 let mut config = create_test_config();
1207 config.directives = vec![DirectiveDefinition {
1208 implementation: ReplDirective::Exit,
1209 name: "q".to_string(),
1210 aliases: vec![],
1211 description: "Leave".to_string(),
1212 }];
1213
1214 match build_with(config) {
1215 Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, path, .. })) => {
1216 assert!(reason.contains("'q'"), "{}", reason);
1217 assert_eq!(path.as_deref(), Some("directives[0]"));
1218 }
1219 Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
1220 Ok(_) => panic!("Expected build() to reject the configuration"),
1221 }
1222 }
1223
1224 #[test]
1225 fn test_builder_build_validates_before_context_check() {
1226 // The configuration error is reported even when the context is
1227 // missing too: validation is the first step after loading.
1228 let result = CliBuilder::new().config(create_invalid_config()).build();
1229
1230 match result {
1231 Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. })) => {
1232 assert!(!reason.contains("No execution context"), "{}", reason);
1233 }
1234 Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
1235 Ok(_) => panic!("Expected build() to reject the configuration"),
1236 }
1237 }
1238
1239 #[test]
1240 fn test_builder_build_missing_context() {
1241 let config = create_test_config();
1242
1243 let result = CliBuilder::new().config(config).build();
1244
1245 assert!(result.is_err());
1246 match result.unwrap_err() {
1247 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1248 assert!(reason.contains("No execution context provided"));
1249 }
1250 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1251 }
1252 }
1253
1254 #[test]
1255 fn test_builder_build_missing_required_handler() {
1256 let config = create_test_config();
1257 let context = Box::new(TestContext::default());
1258
1259 let result = CliBuilder::new().config(config).context(context).build();
1260
1261 assert!(result.is_err());
1262 match result.unwrap_err() {
1263 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1264 assert!(reason.contains("Required command"));
1265 assert!(reason.contains("no registered handler"));
1266 }
1267 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1268 }
1269 }
1270
1271 #[test]
1272 fn test_builder_chaining() {
1273 let config = create_test_config();
1274 let context = Box::new(TestContext::default());
1275 let handler = Box::new(TestHandler {
1276 name: "test".to_string(),
1277 });
1278
1279 // Test that all methods chain correctly
1280 let app = CliBuilder::new()
1281 .config(config)
1282 .context(context)
1283 .register_sync_handler("test_handler", handler)
1284 .prompt("test")
1285 .build();
1286
1287 assert!(app.is_ok());
1288 }
1289
1290 #[test]
1291 fn test_cli_app_run_cli() {
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 // Run with test command
1306 let result = app.run_cli(vec!["test".to_string()]);
1307 assert!(result.is_ok());
1308 }
1309
1310 #[test]
1311 fn test_default_prompt_from_config() {
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 // Prompt should be taken from config
1326 assert_eq!(app.prompt, "test");
1327 }
1328
1329 #[test]
1330 fn test_override_prompt() {
1331 let config = create_test_config();
1332 let context = Box::new(TestContext::default());
1333 let handler = Box::new(TestHandler {
1334 name: "test".to_string(),
1335 });
1336
1337 let app = CliBuilder::new()
1338 .config(config)
1339 .context(context)
1340 .register_sync_handler("test_handler", handler)
1341 .prompt("custom")
1342 .build()
1343 .unwrap();
1344
1345 // Prompt should be overridden
1346 assert_eq!(app.prompt, "custom");
1347 }
1348
1349 #[test]
1350 fn test_builder_build_forwards_prompt_multiline_to_app() {
1351 let config = create_test_config();
1352 let context = Box::new(TestContext::default());
1353 let handler = Box::new(TestHandler {
1354 name: "test".to_string(),
1355 });
1356
1357 let app = CliBuilder::new()
1358 .config(config)
1359 .context(context)
1360 .register_sync_handler("test_handler", handler)
1361 .prompt("custom")
1362 .prompt_multiline("_ _ _")
1363 .build()
1364 .unwrap();
1365
1366 assert_eq!(app.prompt_multiline, Some("_ _ _".to_string()));
1367 }
1368
1369 #[test]
1370 fn test_builder_build_without_prompt_multiline_leaves_app_none() {
1371 let config = create_test_config();
1372 let context = Box::new(TestContext::default());
1373 let handler = Box::new(TestHandler {
1374 name: "test".to_string(),
1375 });
1376
1377 let app = CliBuilder::new()
1378 .config(config)
1379 .context(context)
1380 .register_sync_handler("test_handler", handler)
1381 .prompt("custom")
1382 .build()
1383 .unwrap();
1384
1385 assert_eq!(app.prompt_multiline, None);
1386 }
1387
1388 // ============================================================================
1389 // async_handlers / register_async_handler / build() TESTS — DA-022 (#8)
1390 // ============================================================================
1391
1392 #[test]
1393 fn test_builder_with_async_handler() {
1394 let handler = Box::new(TestAsyncHandler {
1395 name: "test".to_string(),
1396 });
1397
1398 let builder = CliBuilder::new().register_async_handler("test_handler", handler);
1399
1400 assert_eq!(builder.async_handlers.len(), 1);
1401 assert!(builder.handlers.is_empty());
1402 }
1403
1404 /// The core gap this session closes: an async handler registered via
1405 /// `register_async_handler()` must actually reach the built registry —
1406 /// previously `build()` only ever drained `self.handlers`.
1407 #[test]
1408 fn test_builder_build_with_async_handler_only() {
1409 let config = create_test_config();
1410 let context = Box::new(TestContext::default());
1411 let handler = Box::new(TestAsyncHandler {
1412 name: "test".to_string(),
1413 });
1414
1415 let app = CliBuilder::new()
1416 .config(config)
1417 .context(context)
1418 .register_async_handler("test_handler", handler)
1419 .build();
1420
1421 assert!(app.is_ok());
1422 }
1423
1424 /// Registering both a sync and an async handler for the same
1425 /// implementation name must fail at build() time — this is the
1426 /// conflict check `build()` didn't have before this session.
1427 #[test]
1428 fn test_builder_build_sync_and_async_conflict() {
1429 let config = create_test_config();
1430 let context = Box::new(TestContext::default());
1431 let sync_handler = Box::new(TestHandler {
1432 name: "sync".to_string(),
1433 });
1434 let async_handler = Box::new(TestAsyncHandler {
1435 name: "async".to_string(),
1436 });
1437
1438 let result = CliBuilder::new()
1439 .config(config)
1440 .context(context)
1441 .register_sync_handler("test_handler", sync_handler)
1442 .register_async_handler("test_handler", async_handler)
1443 .build();
1444
1445 assert!(result.is_err());
1446 match result.unwrap_err() {
1447 DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
1448 assert!(reason.contains("both a sync and an async handler"));
1449 }
1450 other => panic!("Expected InvalidSchema error, got: {:?}", other),
1451 }
1452 }
1453
1454 /// A required command satisfied only by an async handler must not be
1455 /// reported as "missing" — the required-handler check must look at
1456 /// both maps, not just `self.handlers`.
1457 #[test]
1458 fn test_builder_build_required_command_satisfied_by_async_handler() {
1459 let config = create_test_config(); // "test" command is `required: true`
1460 let context = Box::new(TestContext::default());
1461 let handler = Box::new(TestAsyncHandler {
1462 name: "test".to_string(),
1463 });
1464
1465 let result = CliBuilder::new()
1466 .config(config)
1467 .context(context)
1468 .register_async_handler("test_handler", handler)
1469 .build();
1470
1471 assert!(result.is_ok());
1472 }
1473
1474 #[test]
1475 fn test_cli_app_run_cli_with_async_handler() {
1476 let config = create_test_config();
1477 let context = Box::new(TestContext::default());
1478 let handler = Box::new(TestAsyncHandler {
1479 name: "test".to_string(),
1480 });
1481
1482 let app = CliBuilder::new()
1483 .config(config)
1484 .context(context)
1485 .register_async_handler("test_handler", handler)
1486 .build()
1487 .unwrap();
1488
1489 let result = app.run_cli(vec!["test".to_string()]);
1490 assert!(result.is_ok());
1491 }
1492
1493 #[test]
1494 fn test_builder_with_help_formatter() {
1495 use crate::help::DefaultHelpFormatter;
1496
1497 let formatter = Box::new(DefaultHelpFormatter::new());
1498 let builder = CliBuilder::new().help_formatter(formatter);
1499
1500 assert!(builder.help_formatter.is_some());
1501 }
1502
1503 #[test]
1504 fn test_run_cli_help_global() {
1505 let config = create_test_config();
1506 let context = Box::new(TestContext::default());
1507 let handler = Box::new(TestHandler {
1508 name: "test".to_string(),
1509 });
1510
1511 let app = CliBuilder::new()
1512 .config(config)
1513 .context(context)
1514 .register_sync_handler("test_handler", handler)
1515 .build()
1516 .unwrap();
1517
1518 // --help should return Ok(()) without dispatching to any handler.
1519 let result = app.run_cli(vec!["--help".to_string()]);
1520 assert!(result.is_ok());
1521 }
1522
1523 #[test]
1524 fn test_run_cli_help_command() {
1525 let config = create_test_config();
1526 let context = Box::new(TestContext::default());
1527 let handler = Box::new(TestHandler {
1528 name: "test".to_string(),
1529 });
1530
1531 let app = CliBuilder::new()
1532 .config(config)
1533 .context(context)
1534 .register_sync_handler("test_handler", handler)
1535 .build()
1536 .unwrap();
1537
1538 // --help <command> should return Ok(()) without dispatching.
1539 let result = app.run_cli(vec!["--help".to_string(), "test".to_string()]);
1540 assert!(result.is_ok());
1541 }
1542
1543 #[test]
1544 fn test_run_cli_help_unknown_command_still_ok() {
1545 let config = create_test_config();
1546 let context = Box::new(TestContext::default());
1547 let handler = Box::new(TestHandler {
1548 name: "test".to_string(),
1549 });
1550
1551 let app = CliBuilder::new()
1552 .config(config)
1553 .context(context)
1554 .register_sync_handler("test_handler", handler)
1555 .build()
1556 .unwrap();
1557
1558 // --help with an unknown command name: formatter handles it gracefully.
1559 let result = app.run_cli(vec!["--help".to_string(), "ghost".to_string()]);
1560 assert!(result.is_ok());
1561 }
1562
1563 fn build_help_app() -> CliApp {
1564 CliBuilder::new()
1565 .config(create_test_config())
1566 .context(Box::new(TestContext::default()))
1567 .register_sync_handler(
1568 "test_handler",
1569 Box::new(TestHandler {
1570 name: "test".to_string(),
1571 }),
1572 )
1573 .build()
1574 .unwrap()
1575 }
1576
1577 #[test]
1578 fn test_render_cli_help_app_ends_with_cli_footer() {
1579 colored::control::set_override(false);
1580 let app = build_help_app();
1581 let out = app.render_cli_help(None);
1582
1583 assert_eq!(
1584 out,
1585 format!(
1586 "{}\nRun with '--help <command>' for more information on a command.\n",
1587 DefaultHelpFormatter::new().format_app(&app.config)
1588 )
1589 );
1590 }
1591
1592 #[test]
1593 fn test_render_cli_help_command_has_no_footer() {
1594 colored::control::set_override(false);
1595 let app = build_help_app();
1596 let out = app.render_cli_help(Some("test"));
1597
1598 assert_eq!(
1599 out,
1600 DefaultHelpFormatter::new().format_command(&app.config, "test")
1601 );
1602 }
1603
1604 #[test]
1605 fn test_render_cli_help_footer_follows_custom_formatter() {
1606 struct Plain;
1607 impl HelpFormatter for Plain {
1608 fn format_app(&self, _: &CommandsConfig) -> String {
1609 "APP\n".to_string()
1610 }
1611 fn format_command(&self, _: &CommandsConfig, command: &str) -> String {
1612 format!("CMD {command}\n")
1613 }
1614 }
1615
1616 colored::control::set_override(false);
1617 let app = CliBuilder::new()
1618 .config(create_test_config())
1619 .context(Box::new(TestContext::default()))
1620 .help_formatter(Box::new(Plain))
1621 .register_sync_handler(
1622 "test_handler",
1623 Box::new(TestHandler {
1624 name: "test".to_string(),
1625 }),
1626 )
1627 .build()
1628 .unwrap();
1629
1630 assert_eq!(
1631 app.render_cli_help(None),
1632 "APP\n\nRun with '--help <command>' for more information on a command.\n"
1633 );
1634 assert_eq!(app.render_cli_help(Some("x")), "CMD x\n");
1635 }
1636}