use crate::config::loader::load_config;
use crate::config::schema::CommandsConfig;
use crate::context::ExecutionContext;
use crate::error::{ConfigError, DynamicCliError, Result};
use crate::executor::CommandHandler;
use crate::help::{DefaultHelpFormatter, HelpFormatter};
use crate::interface::{CliInterface, ReplInterface};
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>>,
prompt: 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(),
prompt: 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_handler(
mut self,
name: impl Into<String>,
handler: Box<dyn CommandHandler>,
) -> Self {
self.handlers.insert(name.into(), handler);
self
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = 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,
}));
};
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 command_def in &config.commands {
let handler = self.handlers.remove(&command_def.implementation);
if command_def.required && handler.is_none() {
return Err(DynamicCliError::Config(ConfigError::InvalidSchema {
reason: format!(
"Required command '{}' has no registered handler (implementation: '{}'). \
Use register_handler() to register it.",
command_def.name, command_def.implementation
),
path: None,
suggestion: None,
}));
}
if let Some(handler) = handler {
registry.register(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,
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,
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("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" => {
let formatter: Box<dyn HelpFormatter> = self
.help_formatter
.unwrap_or_else(|| Box::new(DefaultHelpFormatter::new()));
print!("{}", formatter.format_app(&self.config));
return Ok(());
}
[flag, command] if flag == "--help" => {
let formatter: Box<dyn HelpFormatter> = self
.help_formatter
.unwrap_or_else(|| Box::new(DefaultHelpFormatter::new()));
print!("{}", formatter.format_command(&self.config, command));
return Ok(());
}
_ => {}
}
let cli = CliInterface::new(self.registry, self.context);
cli.run(args)
}
pub fn run_repl(self) -> Result<()> {
let mut repl = ReplInterface::new(self.registry, self.context, self.prompt)?;
if let Some(formatter) = self.help_formatter {
repl = repl.with_help(self.config, formatter);
}
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::{ArgumentDefinition, ArgumentType, CommandDefinition, Metadata};
#[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: &HashMap<String, String>,
) -> 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(),
}],
global_options: 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_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_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_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),
}
}
#[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_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_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_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_handler("test_handler", handler)
.prompt("custom")
.build()
.unwrap();
assert_eq!(app.prompt, "custom");
}
#[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_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_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_handler("test_handler", handler)
.build()
.unwrap();
let result = app.run_cli(vec!["--help".to_string(), "ghost".to_string()]);
assert!(result.is_ok());
}
}