use crate::config::schema::CommandsConfig;
use crate::context::ExecutionContext;
use crate::error::{display_error, DynamicCliError, ExecutionError, Result};
use crate::help::HelpFormatter;
use crate::parser::ReplParser;
use crate::registry::CommandRegistry;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::path::PathBuf;
pub struct ReplInterface {
registry: CommandRegistry,
context: Box<dyn ExecutionContext>,
prompt: String,
editor: DefaultEditor,
history_path: Option<PathBuf>,
config: Option<CommandsConfig>,
help_formatter: Option<Box<dyn HelpFormatter>>,
}
impl ReplInterface {
pub fn new(
registry: CommandRegistry,
context: Box<dyn ExecutionContext>,
prompt: String,
) -> Result<Self> {
let editor = DefaultEditor::new().map_err(|e| {
ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
})?;
let history_path = Self::get_history_path(&prompt);
let mut repl = Self {
registry,
context,
prompt: format!("{} > ", prompt),
editor,
history_path,
config: None,
help_formatter: None,
};
repl.load_history();
Ok(repl)
}
pub fn with_help(mut self, config: CommandsConfig, formatter: Box<dyn HelpFormatter>) -> Self {
self.config = Some(config);
self.help_formatter = Some(formatter);
self
}
fn try_handle_help(&self, line: &str) -> Option<String> {
let (config, formatter) = match (&self.config, &self.help_formatter) {
(Some(c), Some(f)) => (c, f.as_ref()),
_ => return None,
};
let trimmed = line.trim();
if trimmed == "--help" || trimmed == "-h" {
return Some(formatter.format_app(config));
}
if let Some(rest) = trimmed
.strip_prefix("--help ")
.or_else(|| trimmed.strip_prefix("-h "))
{
let cmd = rest.trim();
if !cmd.is_empty() {
return Some(formatter.format_command(config, cmd));
}
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
let last = *parts.last().unwrap();
if last == "--help" || last == "-h" {
return Some(formatter.format_command(config, parts[0]));
}
}
None
}
fn get_history_path(app_name: &str) -> Option<PathBuf> {
dirs::config_dir().map(|config_dir| {
let app_dir = config_dir.join(app_name);
app_dir.join("history.txt")
})
}
fn load_history(&mut self) {
if let Some(ref path) = self.history_path {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = self.editor.load_history(path);
}
}
fn save_history(&mut self) {
if let Some(ref path) = self.history_path {
if let Err(e) = self.editor.save_history(path) {
eprintln!("Warning: Failed to save command history: {}", e);
}
}
}
pub fn run(mut self) -> Result<()> {
loop {
let readline = self.editor.readline(&self.prompt);
match readline {
Ok(line) => {
let line = line.trim();
if line.is_empty() {
continue;
}
let _ = self.editor.add_history_entry(line);
if line == "exit" || line == "quit" {
println!("Goodbye!");
break;
}
match self.execute_line(line) {
Ok(()) => {
}
Err(e) => {
display_error(&e);
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continue;
}
Err(ReadlineError::Eof) => {
println!("exit");
break;
}
Err(err) => {
eprintln!("Error reading input: {}", err);
break;
}
}
}
self.save_history();
Ok(())
}
fn execute_line(&mut self, line: &str) -> Result<()> {
if let Some(output) = self.try_handle_help(line) {
print!("{}", output);
return Ok(());
}
let parser = ReplParser::new(&self.registry);
let parsed = parser.parse_line(line)?;
let handler = self
.registry
.get_handler(&parsed.command_name)
.ok_or_else(|| {
DynamicCliError::Execution(ExecutionError::handler_not_found(
&parsed.command_name,
"unknown",
))
})?;
handler.execute(&mut *self.context, &parsed.arguments)?;
Ok(())
}
}
impl Drop for ReplInterface {
fn drop(&mut self) {
self.save_history();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{ArgumentDefinition, ArgumentType, CommandDefinition};
use std::collections::HashMap;
#[derive(Default)]
struct TestContext {
executed_commands: 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 crate::executor::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 context");
ctx.executed_commands.push(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(cmd_def, handler).unwrap();
registry
}
#[test]
fn test_repl_interface_creation() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let repl = ReplInterface::new(registry, context, "test".to_string());
assert!(repl.is_ok());
}
#[test]
fn test_repl_execute_line() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
let result = repl.execute_line("test");
assert!(result.is_ok());
let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
}
#[test]
fn test_repl_execute_with_alias() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
let result = repl.execute_line("t");
assert!(result.is_ok());
}
#[test]
fn test_repl_execute_unknown_command() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
let result = repl.execute_line("unknown");
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Parse(_) => {}
other => panic!("Expected Parse error, got: {:?}", other),
}
}
#[test]
fn test_repl_history_path() {
let path = ReplInterface::get_history_path("myapp");
if let Some(p) = path {
assert!(p.to_str().unwrap().contains("myapp"));
assert!(p.to_str().unwrap().contains("history.txt"));
}
}
#[test]
fn test_repl_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_string(),
validation: vec![],
}],
options: vec![],
implementation: "greet_handler".to_string(),
};
struct GreetHandler;
impl crate::executor::CommandHandler for GreetHandler {
fn execute(
&self,
_context: &mut dyn ExecutionContext,
args: &HashMap<String, String>,
) -> Result<()> {
assert_eq!(args.get("name"), Some(&"Alice".to_string()));
Ok(())
}
}
registry.register(cmd_def, Box::new(GreetHandler)).unwrap();
let context = Box::new(TestContext::default());
let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
let result = repl.execute_line("greet Alice");
assert!(result.is_ok());
}
#[test]
fn test_repl_empty_line() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
let result = repl.execute_line("");
assert!(result.is_err());
}
fn make_help_config() -> crate::config::schema::CommandsConfig {
use crate::config::schema::{CommandDefinition, CommandsConfig, Metadata};
CommandsConfig {
metadata: Metadata {
version: "1.0.0".to_string(),
prompt: "testapp".to_string(),
prompt_suffix: " > ".to_string(),
},
commands: vec![CommandDefinition {
name: "hello".to_string(),
aliases: vec!["hi".to_string()],
description: "Say hello".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "hello_handler".to_string(),
}],
global_options: vec![],
}
}
#[test]
fn test_try_handle_help_without_formatter_returns_none() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
assert!(repl.try_handle_help("--help").is_none());
assert!(repl.try_handle_help("-h").is_none());
}
#[test]
fn test_try_handle_help_global() {
use crate::help::DefaultHelpFormatter;
colored::control::set_override(false);
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let out = repl.try_handle_help("--help");
assert!(out.is_some());
let out = out.unwrap();
assert!(out.contains("testapp"), "should contain app prompt");
assert!(out.contains("hello"), "should list commands");
}
#[test]
fn test_try_handle_help_short_flag() {
use crate::help::DefaultHelpFormatter;
colored::control::set_override(false);
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let out = repl.try_handle_help("-h");
assert!(out.is_some());
assert!(out.unwrap().contains("testapp"));
}
#[test]
fn test_try_handle_help_with_command_prefix() {
use crate::help::DefaultHelpFormatter;
colored::control::set_override(false);
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let out = repl.try_handle_help("--help hello");
assert!(out.is_some());
assert!(out.unwrap().contains("hello"));
let out2 = repl.try_handle_help("-h hello");
assert!(out2.is_some());
}
#[test]
fn test_try_handle_help_command_suffix() {
use crate::help::DefaultHelpFormatter;
colored::control::set_override(false);
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let out = repl.try_handle_help("hello --help");
assert!(out.is_some());
assert!(out.unwrap().contains("hello"));
let out2 = repl.try_handle_help("hello -h");
assert!(out2.is_some());
}
#[test]
fn test_try_handle_help_alias() {
use crate::help::DefaultHelpFormatter;
colored::control::set_override(false);
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let out = repl.try_handle_help("--help hi");
assert!(out.is_some());
assert!(out.unwrap().contains("hello"));
}
#[test]
fn test_execute_line_help_intercepted() {
use crate::help::DefaultHelpFormatter;
colored::control::set_override(false);
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let mut repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let result = repl.execute_line("--help");
assert!(result.is_ok(), "help request must not return an error");
}
#[test]
fn test_execute_line_normal_command_still_works_with_formatter() {
use crate::help::DefaultHelpFormatter;
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let config = make_help_config();
let mut repl = ReplInterface::new(registry, context, "test".to_string())
.unwrap()
.with_help(config, Box::new(DefaultHelpFormatter::new()));
let result = repl.execute_line("test");
assert!(result.is_ok());
}
}