use crate::context::ExecutionContext;
use crate::error::{display_error, DynamicCliError, ExecutionError, Result};
use crate::parser::{CliParser, ParsedArgs, ReplParser};
use crate::registry::CommandRegistry;
use std::path::Path;
use std::process;
pub struct CliInterface {
registry: CommandRegistry,
context: Box<dyn ExecutionContext>,
}
impl CliInterface {
pub fn new(registry: CommandRegistry, context: Box<dyn ExecutionContext>) -> Self {
Self { registry, context }
}
pub fn run(mut self, args: Vec<String>) -> Result<()> {
if args.is_empty() {
return Err(DynamicCliError::Parse(
crate::error::ParseError::InvalidSyntax {
details: "No command specified".to_string(),
hint: Some("Try 'help' to see available commands".to_string()),
},
));
}
self.dispatch(&args)
}
fn dispatch(&mut self, args: &[String]) -> Result<()> {
let command_name = &args[0];
let resolved_name = self.registry.resolve_name(command_name).ok_or_else(|| {
crate::error::ParseError::unknown_command_with_suggestions(
command_name,
&self
.registry
.list_commands()
.iter()
.map(|cmd| cmd.name.clone())
.collect::<Vec<_>>(),
)
})?;
let definition = self.registry.get_definition(resolved_name).ok_or_else(|| {
DynamicCliError::Registry(crate::error::RegistryError::missing_handler(resolved_name))
})?;
let parser = CliParser::new(definition);
let parsed_args = ParsedArgs::new(parser.parse_typed(&args[1..])?);
if let Some(handler) = self.registry.get_handler_sync(resolved_name) {
handler.execute(&mut *self.context, &parsed_args)?;
} else if let Some(handler) = self.registry.get_handler_async(resolved_name) {
futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
} else {
return Err(DynamicCliError::Execution(
crate::error::ExecutionError::handler_not_found(
resolved_name,
&definition.implementation,
),
));
}
Ok(())
}
pub fn run_and_exit(self, args: Vec<String>) -> ! {
match self.run(args) {
Ok(()) => process::exit(0),
Err(e) => {
display_error(&e);
let exit_code = match e {
DynamicCliError::Parse(_) => 2,
DynamicCliError::Validation(_) => 2,
DynamicCliError::Execution(_) => 1,
_ => 3,
};
process::exit(exit_code);
}
}
}
pub fn run_script(
mut self,
path: impl AsRef<Path>,
policy: ScriptErrorPolicy,
) -> Result<ScriptOutcome> {
let path = path.as_ref();
let content = std::fs::read_to_string(path).map_err(|e| {
DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
"failed to read script file {}: {}",
path.display(),
e
)))
})?;
let mut outcome = ScriptOutcome {
lines_executed: 0,
lines_succeeded: 0,
failures: Vec::new(),
};
for (idx, raw_line) in content.lines().enumerate() {
let line_number = idx + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
outcome.lines_executed += 1;
let tokens_result = {
let tokenizer = ReplParser::new(&self.registry);
tokenizer.tokenize(line)
};
let tokens = match tokens_result {
Ok(t) => t,
Err(e) => {
let wrapped = wrap_line_error(line_number, e);
if policy == ScriptErrorPolicy::Abort {
return Err(wrapped);
}
outcome.failures.push((line_number, wrapped));
continue;
}
};
if tokens.is_empty() {
continue;
}
match self.dispatch(&tokens) {
Ok(()) => outcome.lines_succeeded += 1,
Err(e) => {
let wrapped = wrap_line_error(line_number, e);
if policy == ScriptErrorPolicy::Abort {
return Err(wrapped);
}
outcome.failures.push((line_number, wrapped));
}
}
}
Ok(outcome)
}
}
fn wrap_line_error(line_number: usize, source: DynamicCliError) -> DynamicCliError {
DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
"line {}: {}",
line_number,
source
)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptErrorPolicy {
Abort,
Continue,
}
#[derive(Debug)]
pub struct ScriptOutcome {
pub lines_executed: usize,
pub lines_succeeded: usize,
pub failures: Vec<(usize, DynamicCliError)>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{ArgumentDefinition, ArgumentType, CommandDefinition};
#[derive(Default)]
struct TestContext {
executed_command: Option<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 crate::executor::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 context");
ctx.executed_command = Some(self.name.clone());
Ok(())
}
}
fn create_test_registry() -> CommandRegistry {
let mut registry = CommandRegistry::new();
let cmd_def = CommandDefinition {
name: "test".to_string(),
aliases: vec!["t".to_string()],
description: "Test command".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "test_handler".to_string(),
};
let handler = Box::new(TestHandler {
name: "test".to_string(),
});
registry
.register_sync(cmd_def, handler)
.expect("Failed to register command");
registry
}
#[test]
fn test_cli_interface_creation() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let _cli = CliInterface::new(registry, context);
}
#[test]
fn test_cli_run_simple_command() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let result = cli.run(vec!["test".to_string()]);
assert!(result.is_ok());
}
#[test]
fn test_cli_run_with_alias() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let result = cli.run(vec!["t".to_string()]);
assert!(result.is_ok());
}
#[test]
fn test_cli_empty_args() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let result = cli.run(vec![]);
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Parse(crate::error::ParseError::InvalidSyntax { .. }) => {}
other => panic!("Expected InvalidSyntax error, got: {:?}", other),
}
}
#[test]
fn test_cli_unknown_command() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let result = cli.run(vec!["unknown".to_string()]);
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Parse(crate::error::ParseError::UnknownCommand { .. }) => {}
other => panic!("Expected UnknownCommand error, got: {:?}", other),
}
}
#[test]
fn test_cli_command_with_args() {
let mut registry = CommandRegistry::new();
let cmd_def = CommandDefinition {
name: "greet".to_string(),
aliases: vec![],
description: "Greet someone".to_string(),
required: false,
arguments: vec![ArgumentDefinition {
name: "name".to_string(),
arg_type: ArgumentType::String,
required: true,
description: "Name to greet".to_string(),
validation: vec![],
secure: false,
}],
options: vec![],
implementation: "greet_handler".to_string(),
};
struct GreetHandler;
impl crate::executor::CommandHandler for GreetHandler {
fn execute(
&self,
_context: &mut dyn ExecutionContext,
args: &ParsedArgs,
) -> Result<()> {
assert_eq!(args.get_scalar("name"), Some("Alice"));
Ok(())
}
}
registry
.register_sync(cmd_def, Box::new(GreetHandler))
.unwrap();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let result = cli.run(vec!["greet".to_string(), "Alice".to_string()]);
assert!(result.is_ok());
}
fn write_script(content: &str) -> tempfile::NamedTempFile {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
file.write_all(content.as_bytes())
.expect("failed to write temp script file");
file
}
#[test]
fn test_run_script_all_lines_succeed() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let script = write_script("test\nt\ntest\n");
let outcome = cli
.run_script(script.path(), ScriptErrorPolicy::Abort)
.expect("run_script should succeed when every line succeeds");
assert_eq!(outcome.lines_executed, 3);
assert_eq!(outcome.lines_succeeded, 3);
assert!(outcome.failures.is_empty());
}
#[test]
fn test_run_script_skips_blank_lines_and_comments() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let script = write_script("# a comment\n\ntest\n \n# another\nt\n");
let outcome = cli
.run_script(script.path(), ScriptErrorPolicy::Abort)
.expect("run_script should succeed");
assert_eq!(outcome.lines_executed, 2);
assert_eq!(outcome.lines_succeeded, 2);
}
#[test]
fn test_run_script_continue_policy_records_failures_and_keeps_going() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let script = write_script("test\nunknown_command\ntest\n");
let outcome = cli
.run_script(script.path(), ScriptErrorPolicy::Continue)
.expect("Continue policy should return Ok even with a failing line");
assert_eq!(outcome.lines_executed, 3);
assert_eq!(outcome.lines_succeeded, 2);
assert_eq!(outcome.failures.len(), 1);
assert_eq!(outcome.failures[0].0, 2); }
#[test]
fn test_run_script_abort_policy_stops_at_first_failure() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let script = write_script("test\nunknown_command\ntest\n");
let result = cli.run_script(script.path(), ScriptErrorPolicy::Abort);
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Execution(ExecutionError::CommandFailed(e)) => {
assert!(e.to_string().contains("line 2"));
}
other => panic!("Expected wrapped CommandFailed error, got: {:?}", other),
}
}
#[test]
fn test_run_script_respects_quoted_tokens() {
let mut registry = CommandRegistry::new();
let cmd_def = CommandDefinition {
name: "greet".to_string(),
aliases: vec![],
description: "Greet someone".to_string(),
required: false,
arguments: vec![ArgumentDefinition {
name: "name".to_string(),
arg_type: ArgumentType::String,
required: true,
description: "Name to greet".to_string(),
validation: vec![],
secure: false,
}],
options: vec![],
implementation: "greet_handler".to_string(),
};
struct GreetHandler;
impl crate::executor::CommandHandler for GreetHandler {
fn execute(
&self,
_context: &mut dyn ExecutionContext,
args: &ParsedArgs,
) -> Result<()> {
assert_eq!(args.get_scalar("name"), Some("Alice Wonderland"));
Ok(())
}
}
registry
.register_sync(cmd_def, Box::new(GreetHandler))
.unwrap();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let script = write_script(r#"greet "Alice Wonderland""#);
let outcome = cli
.run_script(script.path(), ScriptErrorPolicy::Abort)
.expect("quoted argument should tokenize as a single value");
assert_eq!(outcome.lines_succeeded, 1);
}
#[test]
fn test_run_script_missing_file() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let result = cli.run_script("/nonexistent/path/to/script.txt", ScriptErrorPolicy::Abort);
assert!(result.is_err());
}
}