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