use crate::config::loader::load_config;
use crate::config::schema::CommandsConfig;
use crate::config::validator::validate_config;
use crate::context::ExecutionContext;
use crate::error::{ConfigError, DynamicCliError, Result};
use crate::executor::{AsyncCommandHandler, CommandHandler};
use crate::help::{command_help_footer, DefaultHelpFormatter, HelpFormatter};
use crate::interface::{CliInterface, ReplInterface};
use crate::plugin::Plugin;
use crate::registry::CommandRegistry;
use std::collections::HashMap;
use std::path::PathBuf;
pub struct CliBuilder {
config_path: Option<PathBuf>,
config: Option<CommandsConfig>,
context: Option<Box<dyn ExecutionContext>>,
handlers: HashMap<String, Box<dyn CommandHandler>>,
async_handlers: HashMap<String, Box<dyn AsyncCommandHandler>>,
plugins: Vec<Box<dyn Plugin>>,
prompt: Option<String>,
prompt_multiline: Option<String>,
help_formatter: Option<Box<dyn HelpFormatter>>,
}
impl CliBuilder {
pub fn new() -> Self {
Self {
config_path: None,
config: None,
context: None,
handlers: HashMap::new(),
async_handlers: HashMap::new(),
plugins: Vec::new(),
prompt: None,
prompt_multiline: None,
help_formatter: None,
}
}
pub fn config_file<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.config_path = Some(path.into());
self
}
pub fn config(mut self, config: CommandsConfig) -> Self {
self.config = Some(config);
self
}
pub fn context(mut self, context: Box<dyn ExecutionContext>) -> Self {
self.context = Some(context);
self
}
pub fn register_sync_handler(
mut self,
name: impl Into<String>,
handler: Box<dyn CommandHandler>,
) -> Self {
self.handlers.insert(name.into(), handler);
self
}
pub fn register_async_handler(
mut self,
name: impl Into<String>,
handler: Box<dyn AsyncCommandHandler>,
) -> Self {
self.async_handlers.insert(name.into(), handler);
self
}
pub fn register_plugin(mut self, plugin: Box<dyn Plugin>) -> Self {
self.plugins.push(plugin);
self
}
#[cfg(feature = "wasm-plugins")]
pub fn register_wasm_plugin(
self,
path: &std::path::Path,
function_map: &[(&str, &str)],
) -> Result<Self> {
let mut plugin = crate::plugin::wasm::WasmPlugin::load(path)?;
for &(impl_name, wasm_fn_name) in function_map {
plugin = plugin.with_function_map(impl_name, wasm_fn_name);
}
Ok(self.register_plugin(Box::new(plugin)))
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = Some(prompt.into());
self
}
pub fn prompt_multiline(mut self, prompt: impl Into<String>) -> Self {
self.prompt_multiline = Some(prompt.into());
self
}
pub fn help_formatter(mut self, formatter: Box<dyn HelpFormatter>) -> Self {
self.help_formatter = Some(formatter);
self
}
pub fn build(mut self) -> Result<CliApp> {
let config = if let Some(config) = self.config.take() {
config
} else if let Some(path) = self.config_path.take() {
load_config(path)?
} else {
return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
reason: "No configuration provided. Use config_file() or config()".to_string(),
path: None,
suggestion: None,
}));
};
validate_config(&config)?;
let context = self.context.take().ok_or_else(|| {
DynamicCliError::Config(ConfigError::InvalidSchema {
reason: "No execution context provided. Use context()".to_string(),
path: None,
suggestion: None,
})
})?;
let mut registry = CommandRegistry::new();
for plugin in self.plugins.drain(..) {
let plugin_name = plugin.name().to_string();
for (impl_name, handler) in plugin.handlers() {
if self.handlers.contains_key(&impl_name) {
return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
reason: format!(
"Plugin '{}' tried to register handler '{}' \
which is already registered.",
plugin_name, impl_name
),
path: None,
suggestion: Some(format!(
"Remove the duplicate call to register_sync_handler(\"{}\") \
or rename the implementation in your YAML config.",
impl_name
)),
}));
}
self.handlers.insert(impl_name, handler);
}
}
for command_def in &config.commands {
let sync_handler = self.handlers.remove(&command_def.implementation);
let async_handler = self.async_handlers.remove(&command_def.implementation);
if sync_handler.is_some() && async_handler.is_some() {
return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
reason: format!(
"Command '{}' has both a sync and an async handler \
registered for implementation '{}'.",
command_def.name, command_def.implementation
),
path: None,
suggestion: Some(
"Use either register_sync_handler() or \
register_async_handler() for this implementation, not both."
.to_string(),
),
}));
}
if command_def.required && sync_handler.is_none() && async_handler.is_none() {
return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
reason: format!(
"Required command '{}' has no registered handler (implementation: '{}'). \
Use register_sync_handler() or register_async_handler() to register it.",
command_def.name, command_def.implementation
),
path: None,
suggestion: None,
}));
}
if let Some(handler) = sync_handler {
registry.register_sync(command_def.clone(), handler)?;
} else if let Some(handler) = async_handler {
registry.register_async(command_def.clone(), handler)?;
}
}
let prompt = self
.prompt
.or_else(|| Some(config.metadata.prompt.clone()))
.unwrap_or_else(|| "cli".to_string());
Ok(CliApp {
registry,
context,
prompt,
prompt_multiline: self.prompt_multiline,
config,
help_formatter: self.help_formatter,
})
}
}
impl Default for CliBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct CliApp {
registry: CommandRegistry,
context: Box<dyn ExecutionContext>,
prompt: String,
prompt_multiline: Option<String>,
config: CommandsConfig,
help_formatter: Option<Box<dyn HelpFormatter>>,
}
impl std::fmt::Debug for CliApp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CliApp")
.field("prompt", &self.prompt)
.field("prompt_multiline", &self.prompt_multiline)
.field("registry", &"<CommandRegistry>")
.field("context", &"<ExecutionContext>")
.field("help_formatter", &"<Option<Box<dyn HelpFormatter>>>")
.finish()
}
}
impl CliApp {
pub fn run_cli(self, args: Vec<String>) -> Result<()> {
match args.as_slice() {
[flag] if flag == "--help" => {
print!("{}", self.render_cli_help(None));
return Ok(());
}
[flag, command] if flag == "--help" => {
print!("{}", self.render_cli_help(Some(command)));
return Ok(());
}
_ => {}
}
let cli = CliInterface::new(self.registry, self.context);
cli.run(args)
}
fn render_cli_help(&self, command: Option<&str>) -> String {
let default_formatter = DefaultHelpFormatter::new();
let formatter: &dyn HelpFormatter =
self.help_formatter.as_deref().unwrap_or(&default_formatter);
match command {
Some(cmd) => formatter.format_command(&self.config, cmd),
None => {
let mut out = formatter.format_app(&self.config);
out.push_str(&command_help_footer("Run with", "--help <command>"));
out
}
}
}
pub fn run_script(
self,
path: impl AsRef<std::path::Path>,
policy: crate::interface::ScriptErrorPolicy,
) -> Result<crate::interface::ScriptOutcome> {
let cli = CliInterface::new(self.registry, self.context);
cli.run_script(path, policy)
}
pub fn run_repl(self) -> Result<()> {
let mut repl = ReplInterface::new(
self.registry,
self.context,
self.prompt,
Some(self.config),
self.help_formatter,
)?;
if let Some(prompt_multiline) = self.prompt_multiline {
repl = repl.with_prompt_multiline(prompt_multiline);
}
repl.run()
}
pub fn run(self) -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
self.run_repl()
} else {
self.run_cli(args)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{CommandDefinition, Metadata};
use crate::parser::ParsedArgs;
#[derive(Default)]
struct TestContext {
executed: Vec<String>,
}
impl ExecutionContext for TestContext {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
struct TestHandler {
name: String,
}
impl CommandHandler for TestHandler {
fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
let ctx =
crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
ctx.executed.push(self.name.clone());
Ok(())
}
}
struct TestAsyncHandler {
name: String,
}
#[async_trait::async_trait]
impl AsyncCommandHandler for TestAsyncHandler {
async fn execute(
&self,
context: &mut dyn ExecutionContext,
_args: &ParsedArgs,
) -> Result<()> {
let ctx =
crate::context::downcast_mut::<TestContext>(context).expect("Failed to downcast");
ctx.executed.push(self.name.clone());
Ok(())
}
}
fn create_test_config() -> CommandsConfig {
CommandsConfig {
metadata: Metadata {
version: "1.0.0".to_string(),
prompt: "test".to_string(),
prompt_suffix: " > ".to_string(),
},
commands: vec![CommandDefinition {
name: "test".to_string(),
aliases: vec![],
description: "Test command".to_string(),
required: true,
arguments: vec![],
options: vec![],
implementation: "test_handler".to_string(),
continue_on_failure: false,
requires_success: false,
}],
global_options: vec![],
directives: vec![],
}
}
#[test]
fn test_builder_creation() {
let builder = CliBuilder::new();
assert!(builder.config.is_none());
assert!(builder.context.is_none());
}
#[test]
fn test_builder_with_config() {
let config = create_test_config();
let builder = CliBuilder::new().config(config.clone());
assert!(builder.config.is_some());
}
#[test]
fn test_builder_with_context() {
let context = Box::new(TestContext::default());
let builder = CliBuilder::new().context(context);
assert!(builder.context.is_some());
}
#[test]
fn test_builder_with_handler() {
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let builder = CliBuilder::new().register_sync_handler("test_handler", handler);
assert_eq!(builder.handlers.len(), 1);
}
#[test]
fn test_builder_with_prompt() {
let builder = CliBuilder::new().prompt("myapp");
assert_eq!(builder.prompt, Some("myapp".to_string()));
}
#[test]
fn test_builder_with_prompt_multiline() {
let builder = CliBuilder::new().prompt("rpn").prompt_multiline("_ _ _");
assert_eq!(builder.prompt_multiline, Some("_ _ _".to_string()));
}
#[test]
fn test_builder_without_prompt_multiline_defaults_to_none() {
let builder = CliBuilder::new().prompt("myapp");
assert_eq!(builder.prompt_multiline, None);
}
#[test]
fn test_builder_build_success() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.build();
assert!(app.is_ok());
}
#[test]
fn test_builder_build_missing_config() {
let context = Box::new(TestContext::default());
let result = CliBuilder::new().context(context).build();
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
assert!(reason.contains("No configuration provided"));
}
other => panic!("Expected InvalidSchema error, got: {:?}", other),
}
}
fn create_invalid_config() -> CommandsConfig {
use crate::config::schema::{ArgumentDefinition, ArgumentType};
let argument = |name: &str, required: bool| ArgumentDefinition {
name: name.to_string(),
arg_type: ArgumentType::String,
required,
description: "Test argument".to_string(),
validation: vec![],
secure: false,
};
let mut config = create_test_config();
config.commands[0].arguments = vec![argument("first", false), argument("second", true)];
config
}
fn build_with(config: CommandsConfig) -> Result<CliApp> {
CliBuilder::new()
.config(config)
.context(Box::new(TestContext::default()))
.register_sync_handler(
"test_handler",
Box::new(TestHandler {
name: "test".to_string(),
}),
)
.build()
}
#[test]
fn test_builder_build_rejects_invalid_config() {
let config = create_invalid_config();
assert!(validate_config(&config).is_err());
match build_with(config) {
Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, path, .. })) => {
assert!(reason.contains("'second'"), "{}", reason);
assert_eq!(path.as_deref(), Some("test.arguments[1]"));
}
Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
Ok(_) => panic!("Expected build() to reject the configuration"),
}
}
#[test]
fn test_builder_build_rejects_invalid_config_file() {
use std::io::Write;
let mut file = tempfile::Builder::new().suffix(".yaml").tempfile().unwrap();
let yaml = serde_yaml::to_string(&create_invalid_config()).unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let result = CliBuilder::new()
.config_file(file.path())
.context(Box::new(TestContext::default()))
.register_sync_handler(
"test_handler",
Box::new(TestHandler {
name: "test".to_string(),
}),
)
.build();
assert!(matches!(
result,
Err(DynamicCliError::Config(ConfigError::InvalidSchema { .. }))
));
}
#[test]
fn test_builder_build_rejects_directive_collision() {
use crate::config::{DirectiveDefinition, ReplDirective};
let mut config = create_test_config();
config.directives = vec![DirectiveDefinition {
implementation: ReplDirective::Exit,
name: "q".to_string(),
aliases: vec![],
description: "Leave".to_string(),
}];
match build_with(config) {
Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, path, .. })) => {
assert!(reason.contains("'q'"), "{}", reason);
assert_eq!(path.as_deref(), Some("directives[0]"));
}
Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
Ok(_) => panic!("Expected build() to reject the configuration"),
}
}
#[test]
fn test_builder_build_validates_before_context_check() {
let result = CliBuilder::new().config(create_invalid_config()).build();
match result {
Err(DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. })) => {
assert!(!reason.contains("No execution context"), "{}", reason);
}
Err(other) => panic!("Expected InvalidSchema error, got: {:?}", other),
Ok(_) => panic!("Expected build() to reject the configuration"),
}
}
#[test]
fn test_builder_build_missing_context() {
let config = create_test_config();
let result = CliBuilder::new().config(config).build();
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
assert!(reason.contains("No execution context provided"));
}
other => panic!("Expected InvalidSchema error, got: {:?}", other),
}
}
#[test]
fn test_builder_build_missing_required_handler() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let result = CliBuilder::new().config(config).context(context).build();
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
assert!(reason.contains("Required command"));
assert!(reason.contains("no registered handler"));
}
other => panic!("Expected InvalidSchema error, got: {:?}", other),
}
}
#[test]
fn test_builder_chaining() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.prompt("test")
.build();
assert!(app.is_ok());
}
#[test]
fn test_cli_app_run_cli() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.build()
.unwrap();
let result = app.run_cli(vec!["test".to_string()]);
assert!(result.is_ok());
}
#[test]
fn test_default_prompt_from_config() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.build()
.unwrap();
assert_eq!(app.prompt, "test");
}
#[test]
fn test_override_prompt() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.prompt("custom")
.build()
.unwrap();
assert_eq!(app.prompt, "custom");
}
#[test]
fn test_builder_build_forwards_prompt_multiline_to_app() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.prompt("custom")
.prompt_multiline("_ _ _")
.build()
.unwrap();
assert_eq!(app.prompt_multiline, Some("_ _ _".to_string()));
}
#[test]
fn test_builder_build_without_prompt_multiline_leaves_app_none() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.prompt("custom")
.build()
.unwrap();
assert_eq!(app.prompt_multiline, None);
}
#[test]
fn test_builder_with_async_handler() {
let handler = Box::new(TestAsyncHandler {
name: "test".to_string(),
});
let builder = CliBuilder::new().register_async_handler("test_handler", handler);
assert_eq!(builder.async_handlers.len(), 1);
assert!(builder.handlers.is_empty());
}
#[test]
fn test_builder_build_with_async_handler_only() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestAsyncHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_async_handler("test_handler", handler)
.build();
assert!(app.is_ok());
}
#[test]
fn test_builder_build_sync_and_async_conflict() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let sync_handler = Box::new(TestHandler {
name: "sync".to_string(),
});
let async_handler = Box::new(TestAsyncHandler {
name: "async".to_string(),
});
let result = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", sync_handler)
.register_async_handler("test_handler", async_handler)
.build();
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Config(ConfigError::InvalidSchema { reason, .. }) => {
assert!(reason.contains("both a sync and an async handler"));
}
other => panic!("Expected InvalidSchema error, got: {:?}", other),
}
}
#[test]
fn test_builder_build_required_command_satisfied_by_async_handler() {
let config = create_test_config(); let context = Box::new(TestContext::default());
let handler = Box::new(TestAsyncHandler {
name: "test".to_string(),
});
let result = CliBuilder::new()
.config(config)
.context(context)
.register_async_handler("test_handler", handler)
.build();
assert!(result.is_ok());
}
#[test]
fn test_cli_app_run_cli_with_async_handler() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestAsyncHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_async_handler("test_handler", handler)
.build()
.unwrap();
let result = app.run_cli(vec!["test".to_string()]);
assert!(result.is_ok());
}
#[test]
fn test_builder_with_help_formatter() {
use crate::help::DefaultHelpFormatter;
let formatter = Box::new(DefaultHelpFormatter::new());
let builder = CliBuilder::new().help_formatter(formatter);
assert!(builder.help_formatter.is_some());
}
#[test]
fn test_run_cli_help_global() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.build()
.unwrap();
let result = app.run_cli(vec!["--help".to_string()]);
assert!(result.is_ok());
}
#[test]
fn test_run_cli_help_command() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.build()
.unwrap();
let result = app.run_cli(vec!["--help".to_string(), "test".to_string()]);
assert!(result.is_ok());
}
#[test]
fn test_run_cli_help_unknown_command_still_ok() {
let config = create_test_config();
let context = Box::new(TestContext::default());
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
let app = CliBuilder::new()
.config(config)
.context(context)
.register_sync_handler("test_handler", handler)
.build()
.unwrap();
let result = app.run_cli(vec!["--help".to_string(), "ghost".to_string()]);
assert!(result.is_ok());
}
fn build_help_app() -> CliApp {
CliBuilder::new()
.config(create_test_config())
.context(Box::new(TestContext::default()))
.register_sync_handler(
"test_handler",
Box::new(TestHandler {
name: "test".to_string(),
}),
)
.build()
.unwrap()
}
#[test]
fn test_render_cli_help_app_ends_with_cli_footer() {
colored::control::set_override(false);
let app = build_help_app();
let out = app.render_cli_help(None);
assert_eq!(
out,
format!(
"{}\nRun with '--help <command>' for more information on a command.\n",
DefaultHelpFormatter::new().format_app(&app.config)
)
);
}
#[test]
fn test_render_cli_help_command_has_no_footer() {
colored::control::set_override(false);
let app = build_help_app();
let out = app.render_cli_help(Some("test"));
assert_eq!(
out,
DefaultHelpFormatter::new().format_command(&app.config, "test")
);
}
#[test]
fn test_render_cli_help_footer_follows_custom_formatter() {
struct Plain;
impl HelpFormatter for Plain {
fn format_app(&self, _: &CommandsConfig) -> String {
"APP\n".to_string()
}
fn format_command(&self, _: &CommandsConfig, command: &str) -> String {
format!("CMD {command}\n")
}
}
colored::control::set_override(false);
let app = CliBuilder::new()
.config(create_test_config())
.context(Box::new(TestContext::default()))
.help_formatter(Box::new(Plain))
.register_sync_handler(
"test_handler",
Box::new(TestHandler {
name: "test".to_string(),
}),
)
.build()
.unwrap();
assert_eq!(
app.render_cli_help(None),
"APP\n\nRun with '--help <command>' for more information on a command.\n"
);
assert_eq!(app.render_cli_help(Some("x")), "CMD x\n");
}
}