use std::path::PathBuf;
use std::sync::Arc;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{CompletionType, Config, Context, Editor, Helper};
use crate::config::directive::{
effective_directives, DirectiveDefinition, ReplDirective, DIRECTIVE_PREFIX,
};
use crate::config::schema::CommandsConfig;
use crate::context::ExecutionContext;
use crate::error::{display_error, DynamicCliError, ExecutionError, ParseError, Result};
use crate::help::{command_help_footer, DefaultHelpFormatter, HelpFormatter};
use crate::parser::{ParsedArgs, ReplParser};
use crate::registry::CommandRegistry;
struct DcliCompleter {
registry: Arc<CommandRegistry>,
config: Option<Arc<CommandsConfig>>,
directives: Arc<[DirectiveDefinition]>,
}
impl DcliCompleter {
fn new(
registry: Arc<CommandRegistry>,
config: Option<Arc<CommandsConfig>>,
directives: Arc<[DirectiveDefinition]>,
) -> Self {
Self {
registry,
config,
directives,
}
}
fn directives_matching(&self, typed: &str) -> Vec<String> {
self.directives
.iter()
.flat_map(|def| std::iter::once(&def.name).chain(&def.aliases))
.map(|name| format!("{}{}", DIRECTIVE_PREFIX, name))
.filter(|candidate| candidate.starts_with(typed))
.collect()
}
fn flags_for(&self, command_name: &str) -> Vec<String> {
let config = match &self.config {
Some(c) => c,
None => return vec![],
};
let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
Some(d) => d,
None => return vec![],
};
let mut flags = Vec::new();
for opt in &cmd_def.options {
if let Some(long) = &opt.long {
flags.push(format!("--{}", long));
}
if let Some(short) = &opt.short {
flags.push(format!("-{}", short));
}
}
flags
}
}
impl Completer for DcliCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let line = &line[..pos];
let tokens: Vec<&str> = line.split_whitespace().collect();
let completing_first_token =
tokens.is_empty() || (tokens.len() == 1 && !line.ends_with(' '));
if completing_first_token {
let prefix = tokens.first().copied().unwrap_or("");
let start = pos - prefix.len();
let names: Vec<String> = if prefix.starts_with(DIRECTIVE_PREFIX) {
self.directives_matching(prefix)
} else {
self.registry
.list_commands()
.into_iter()
.flat_map(|def| {
let mut names = vec![def.name.clone()];
names.extend(def.aliases.clone());
names
})
.filter(|name| name.starts_with(prefix))
.collect()
};
let mut candidates: Vec<Pair> = names
.into_iter()
.map(|name| Pair {
display: name.clone(),
replacement: name,
})
.collect();
candidates.sort_by(|a, b| a.display.cmp(&b.display));
return Ok((start, candidates));
}
let command_token = tokens[0];
let canonical = match self.registry.resolve_name(command_token) {
Some(name) => name.to_string(),
None => return Ok((pos, vec![])),
};
let current_word = if line.ends_with(' ') {
""
} else {
tokens.last().copied().unwrap_or("")
};
let is_flag_context = current_word.is_empty() || current_word.starts_with('-');
if !is_flag_context {
return Ok((pos, vec![]));
}
let start = pos - current_word.len();
let mut candidates: Vec<Pair> = self
.flags_for(&canonical)
.into_iter()
.filter(|flag| flag.starts_with(current_word))
.map(|flag| Pair {
display: flag.clone(),
replacement: flag,
})
.collect();
candidates.sort_by(|a, b| a.display.cmp(&b.display));
Ok((start, candidates))
}
}
struct DcliHelper {
completer: DcliCompleter,
}
impl DcliHelper {
fn new(
registry: Arc<CommandRegistry>,
config: Option<Arc<CommandsConfig>>,
directives: Arc<[DirectiveDefinition]>,
) -> Self {
Self {
completer: DcliCompleter::new(registry, config, directives),
}
}
}
impl Helper for DcliHelper {}
impl Completer for DcliHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
self.completer.complete(line, pos, ctx)
}
}
impl Hinter for DcliHelper {
type Hint = String;
}
impl Highlighter for DcliHelper {}
impl Validator for DcliHelper {}
pub struct ReplInterface {
registry: Arc<CommandRegistry>,
context: Box<dyn ExecutionContext>,
prompt: String,
prompt_suffix: String,
editor: Editor<DcliHelper, rustyline::history::DefaultHistory>,
history_path: Option<PathBuf>,
config: Option<Arc<CommandsConfig>>,
help_formatter: Option<Box<dyn HelpFormatter>>,
prompt_multiline: Option<String>,
directives: Arc<[DirectiveDefinition]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionControl {
Continue,
Leave { save_history: bool },
}
impl ReplInterface {
pub fn new(
registry: CommandRegistry,
context: Box<dyn ExecutionContext>,
prompt: String,
config: Option<CommandsConfig>,
help_formatter: Option<Box<dyn HelpFormatter>>,
) -> Result<Self> {
let registry = Arc::new(registry);
let prompt_suffix = config
.as_ref()
.map(|c| c.metadata.prompt_suffix.clone())
.unwrap_or_else(crate::config::schema::default_prompt_suffix);
let directives: Arc<[DirectiveDefinition]> = effective_directives(
config
.as_ref()
.map(|c| c.directives.as_slice())
.unwrap_or_default(),
)
.into();
let config: Option<Arc<CommandsConfig>> = config.map(Arc::new);
let rl_config = Config::builder()
.completion_type(CompletionType::List)
.build();
let helper = DcliHelper::new(
Arc::clone(®istry),
config.clone(),
Arc::clone(&directives),
);
let mut editor = Editor::with_config(rl_config).map_err(|e| {
ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
})?;
editor.set_helper(Some(helper));
let history_path = Self::get_history_path(&prompt);
let mut repl = Self {
registry,
context,
prompt: format!("{}{}", prompt, prompt_suffix),
prompt_suffix,
editor,
history_path,
config,
help_formatter,
prompt_multiline: None,
directives,
};
repl.load_history();
Ok(repl)
}
pub fn with_prompt_multiline(mut self, prompt: String) -> Self {
self.prompt_multiline = Some(prompt);
self
}
fn effective_prompt_multiline(&self) -> String {
match &self.prompt_multiline {
Some(base) => format!("{}{}", base, self.prompt_suffix),
None => format!("...{}", self.prompt_suffix.trim_start()),
}
}
fn render_help(&self, command: Option<&str>) -> Option<String> {
let config = self.config.as_deref()?;
let default_formatter = DefaultHelpFormatter::new();
let formatter: &dyn HelpFormatter =
self.help_formatter.as_deref().unwrap_or(&default_formatter);
Some(match command {
Some(cmd) => formatter.format_command(config, cmd),
None => {
let mut out = formatter.format_app(config);
out.push_str(&formatter.format_directives(&self.directives));
out.push_str(&command_help_footer(
"Type",
&format!(
"{}{} <command>",
DIRECTIVE_PREFIX,
self.help_directive_name()
),
));
out
}
})
}
fn try_handle_help(&self, line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed == "--help" || trimmed == "-h" {
return self.render_help(None);
}
if let Some(rest) = trimmed
.strip_prefix("--help ")
.or_else(|| trimmed.strip_prefix("-h "))
{
let cmd = rest.trim();
if !cmd.is_empty() {
return self.render_help(Some(cmd));
}
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
let last = *parts.last().unwrap();
if last == "--help" || last == "-h" {
return self.render_help(Some(parts[0]));
}
}
None
}
fn handle_line(&mut self, line: &str) -> Result<SessionControl> {
let line = line.trim();
if line.starts_with(DIRECTIVE_PREFIX) {
return self.dispatch_directive(line);
}
self.execute_line(line)?;
Ok(SessionControl::Continue)
}
fn resolve_directive(&self, name: &str) -> Option<ReplDirective> {
self.directives
.iter()
.find(|def| def.name == name || def.aliases.iter().any(|alias| alias == name))
.map(|def| def.implementation)
}
fn directive_definition(&self, directive: ReplDirective) -> &DirectiveDefinition {
self.directives
.iter()
.find(|def| def.implementation == directive)
.expect("directive table covers every ReplDirective")
}
fn dispatch_directive(&mut self, line: &str) -> Result<SessionControl> {
let body = &line[DIRECTIVE_PREFIX.len_utf8()..];
let (name, rest) = match body.split_once(char::is_whitespace) {
Some((name, rest)) => (name, rest.trim()),
None => (body, ""),
};
let directive = self
.resolve_directive(name)
.ok_or_else(|| self.unknown_directive_error(name))?;
match directive {
ReplDirective::Help => {
if rest.split_whitespace().count() > 1 {
return Err(self.usage_error(name, directive, "takes at most one command"));
}
let command = (!rest.is_empty()).then_some(rest);
let output = self.render_help(command).ok_or_else(|| {
DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
"help is not available"
)))
})?;
print!("{}", output);
Ok(SessionControl::Continue)
}
ReplDirective::Load => {
if rest.is_empty() {
return Err(self.usage_error(name, directive, "requires a file path"));
}
self.load_script(rest)?;
Ok(SessionControl::Continue)
}
ReplDirective::Quit | ReplDirective::Exit => {
if !rest.is_empty() {
return Err(self.usage_error(name, directive, "takes no argument"));
}
Ok(SessionControl::Leave {
save_history: directive == ReplDirective::Quit,
})
}
}
}
fn usage_error(&self, typed: &str, directive: ReplDirective, problem: &str) -> DynamicCliError {
DynamicCliError::Parse(ParseError::InvalidSyntax {
details: format!("`{}{}` {}", DIRECTIVE_PREFIX, typed, problem),
hint: Some(format!(
"Usage: {}",
self.directive_definition(directive).usage_line()
)),
})
}
fn unknown_directive_error(&self, typed: &str) -> DynamicCliError {
let available: Vec<String> = self
.directives
.iter()
.map(|def| format!("{}{}", DIRECTIVE_PREFIX, def.name))
.collect();
DynamicCliError::Parse(ParseError::InvalidSyntax {
details: format!("Unknown directive '{}{}'", DIRECTIVE_PREFIX, typed),
hint: Some(format!("Available directives: {}", available.join(", "))),
})
}
fn complete_unknown_command(&self, error: DynamicCliError) -> DynamicCliError {
match error {
DynamicCliError::Parse(ParseError::UnknownCommand {
command,
mut suggestions,
..
}) => {
if self.resolve_directive(&command).is_some() {
let directive = format!("{}{}", DIRECTIVE_PREFIX, command);
suggestions.retain(|s| *s != directive);
suggestions.insert(0, directive);
}
DynamicCliError::Parse(ParseError::UnknownCommand {
command,
suggestions,
hint: Some(format!(
"Type '{}{}' for available commands",
DIRECTIVE_PREFIX,
self.help_directive_name()
)),
})
}
other => other,
}
}
fn help_directive_name(&self) -> &str {
&self.directive_definition(ReplDirective::Help).name
}
fn load_script(&mut self, path: &str) -> Result<()> {
let content = std::fs::read_to_string(path).map_err(|e| {
DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
"failed to read script file {}: {}",
path,
e
)))
})?;
let mut attempted = 0usize;
let mut succeeded = 0usize;
for (idx, raw_line) in content.lines().enumerate() {
let line_number = idx + 1;
let script_line = raw_line.trim();
if script_line.is_empty() || script_line.starts_with('#') {
continue;
}
attempted += 1;
match self.handle_line(script_line) {
Ok(SessionControl::Continue) => succeeded += 1,
Ok(SessionControl::Leave { .. }) => {
let typed = script_line.split_whitespace().next().unwrap_or_default();
eprintln!(
" :load {} — line {}: '{}' cannot be used in a script; line skipped",
path, line_number, typed
);
}
Err(e) => {
eprintln!(" :load {} — line {}:", path, line_number);
display_error(&e);
}
}
}
println!(":load {path}: {succeeded}/{attempted} line(s) succeeded");
Ok(())
}
fn has_secure_arg(
&self,
command_name: &str,
parsed_args: &std::collections::HashMap<String, String>,
) -> bool {
let config = match &self.config {
Some(c) => c,
None => return false,
};
let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
Some(d) => d,
None => return false,
};
cmd_def
.arguments
.iter()
.any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
}
fn get_history_path(app_name: &str) -> Option<PathBuf> {
dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
}
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<()> {
let mut continuation_buffer: Vec<String> = Vec::new();
loop {
let prompt = if continuation_buffer.is_empty() {
self.prompt.clone()
} else {
self.effective_prompt_multiline()
};
let readline = self.editor.readline(&prompt);
match readline {
Ok(raw_line) => {
let line = match Self::accumulate_line(&mut continuation_buffer, &raw_line) {
Some(line) => line,
None => continue,
};
if line.is_empty() {
continue;
}
match self.handle_line(&line) {
Ok(SessionControl::Continue) => {}
Ok(SessionControl::Leave { save_history }) => {
self.end_session(save_history);
break;
}
Err(e) => {
display_error(&e);
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continuation_buffer.clear();
continue;
}
Err(ReadlineError::Eof) => {
println!();
self.end_session(true);
break;
}
Err(err) => {
eprintln!("Error reading input: {}", err);
break;
}
}
}
Ok(())
}
fn end_session(&mut self, save_history: bool) {
if save_history {
self.save_history();
}
println!("Goodbye!");
}
fn accumulate_line(buffer: &mut Vec<String>, raw_line: &str) -> Option<String> {
let trimmed = raw_line.trim();
if let Some(fragment) = trimmed.strip_suffix('\\') {
buffer.push(fragment.trim_end().to_string());
return None;
}
if buffer.is_empty() {
return Some(trimmed.to_string());
}
buffer.push(trimmed.to_string());
Some(std::mem::take(buffer).join(" "))
}
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)
.map_err(|e| self.complete_unknown_command(e))?;
if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
let _ = self.editor.add_history_entry(line);
}
let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
handler.execute(&mut *self.context, &parsed_args)?;
} else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
} else {
return Err(DynamicCliError::Execution(
ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{
ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
};
use rustyline::history::History;
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: &ParsedArgs) -> 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(),
continue_on_failure: false,
requires_success: false,
};
registry
.register_sync(
cmd_def,
Box::new(TestHandler {
name: "test".to_string(),
}),
)
.unwrap();
registry
}
fn make_help_config() -> CommandsConfig {
use crate::config::schema::{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![OptionDefinition {
name: "loud".to_string(),
short: Some("l".to_string()),
long: Some("loud".to_string()),
option_type: ArgumentType::Bool,
required: false,
default: Some("false".to_string()),
description: "Loud greeting".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
}],
implementation: "hello_handler".to_string(),
continue_on_failure: false,
requires_success: false,
}],
global_options: vec![],
directives: vec![],
}
}
#[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(), None, None);
assert!(repl.is_ok());
}
#[test]
fn test_repl_interface_creation_with_config() {
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(), Some(config), None);
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(), None, None).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(), None, None).unwrap();
assert!(repl.execute_line("t").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(), None, None).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_empty_line() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
assert!(repl.execute_line("").is_err());
}
#[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![],
secure: false,
}],
options: vec![],
implementation: "greet_handler".to_string(),
continue_on_failure: false,
requires_success: false,
};
struct GreetHandler;
impl crate::executor::CommandHandler for GreetHandler {
fn execute(&self, _ctx: &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 mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
assert!(repl.execute_line("greet Alice").is_ok());
}
#[test]
fn test_repl_history_path() {
let path = ReplInterface::get_history_path("myapp");
if let Some(p) = path {
let path_str = p.to_str().unwrap();
assert!(path_str.contains("myapp"), "path should contain app name");
assert!(
path_str.ends_with("history"),
"path should end with 'history', got: {}",
path_str
);
}
}
#[test]
fn test_try_handle_help_without_config_returns_none() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
assert!(repl.try_handle_help("--help").is_none());
assert!(repl.try_handle_help("-h").is_none());
}
#[test]
fn test_try_handle_help_without_formatter_uses_default() {
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(), Some(config), None).unwrap();
let formatter = DefaultHelpFormatter::new();
let expected_app = format!(
"{}{}\nType ':help <command>' for more information on a command.\n",
formatter.format_app(repl.config.as_deref().unwrap()),
formatter.format_directives(&effective_directives(&[]))
);
let expected_cmd =
DefaultHelpFormatter::new().format_command(repl.config.as_deref().unwrap(), "hello");
assert_eq!(repl.try_handle_help("--help"), Some(expected_app.clone()));
assert_eq!(repl.try_handle_help("-h"), Some(expected_app));
for line in ["--help hello", "-h hello", "hello --help", "hello -h"] {
assert_eq!(
repl.try_handle_help(line),
Some(expected_cmd.clone()),
"line: {line}"
);
}
}
#[test]
fn test_execute_line_help_without_formatter() {
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(), Some(config), None).unwrap();
assert!(repl.execute_line("--help").is_ok());
assert!(repl.execute_line("hello --help").is_ok());
}
#[test]
fn test_try_handle_help_global() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
let out = repl.try_handle_help("--help");
assert!(out.is_some());
let out = out.unwrap();
assert!(out.contains("testapp"));
assert!(out.contains("hello"));
}
#[test]
fn test_try_handle_help_short_flag() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
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() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
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() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
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() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
let out = repl.try_handle_help("--help hi");
assert!(out.is_some());
assert!(out.unwrap().contains("hello"));
}
#[test]
fn test_execute_line_help_intercepted() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
assert!(repl.execute_line("--help").is_ok());
}
#[test]
fn test_execute_line_normal_command_still_works_with_formatter() {
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(),
Some(config),
Some(Box::new(DefaultHelpFormatter::new())),
)
.unwrap();
assert!(repl.execute_line("test").is_ok());
}
fn default_directives() -> Arc<[DirectiveDefinition]> {
effective_directives(&[]).into()
}
#[test]
fn test_completer_commands_empty_input() {
let registry = Arc::new(create_test_registry());
let completer = DcliCompleter::new(Arc::clone(®istry), None, default_directives());
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
assert!(names.contains(&"test"));
assert!(names.contains(&"t"));
}
#[test]
fn test_completer_commands_prefix_filter() {
let registry = Arc::new(create_test_registry());
let completer = DcliCompleter::new(Arc::clone(®istry), None, default_directives());
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
assert!(names.contains(&"test"));
assert!(!names.contains(&"t"));
}
#[test]
fn test_completer_flags_after_command() {
let config = Arc::new(make_help_config());
let mut registry = CommandRegistry::new();
let cmd_def = make_help_config().commands.into_iter().next().unwrap();
struct DummyHandler;
impl crate::executor::CommandHandler for DummyHandler {
fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
Ok(())
}
}
registry
.register_sync(cmd_def, Box::new(DummyHandler))
.unwrap();
let registry = Arc::new(registry);
let completer = DcliCompleter::new(
Arc::clone(®istry),
Some(Arc::clone(&config)),
default_directives(),
);
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
assert!(
names.contains(&"--loud"),
"expected --loud, got {:?}",
names
);
assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
}
#[test]
fn test_completer_flags_prefix_filter() {
let config = Arc::new(make_help_config());
let mut registry = CommandRegistry::new();
let cmd_def = make_help_config().commands.into_iter().next().unwrap();
struct DummyHandler;
impl crate::executor::CommandHandler for DummyHandler {
fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
Ok(())
}
}
registry
.register_sync(cmd_def, Box::new(DummyHandler))
.unwrap();
let registry = Arc::new(registry);
let completer = DcliCompleter::new(
Arc::clone(®istry),
Some(Arc::clone(&config)),
default_directives(),
);
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
assert!(names.contains(&"--loud"));
assert!(!names.contains(&"-l"));
}
#[test]
fn test_completer_no_flags_for_unknown_command() {
let config = Arc::new(make_help_config());
let registry = Arc::new(create_test_registry());
let completer = DcliCompleter::new(
Arc::clone(®istry),
Some(Arc::clone(&config)),
default_directives(),
);
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
assert!(candidates.is_empty());
}
fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
use crate::config::schema::{CommandsConfig, Metadata};
let cmd_def = CommandDefinition {
name: "login".to_string(),
aliases: vec![],
description: "Login command".to_string(),
required: false,
arguments: vec![
ArgumentDefinition {
name: "username".to_string(),
arg_type: ArgumentType::String,
required: true,
description: "Username".to_string(),
validation: vec![],
secure: false,
},
ArgumentDefinition {
name: "password".to_string(),
arg_type: ArgumentType::String,
required: true,
description: "Password".to_string(),
validation: vec![],
secure: true,
},
],
options: vec![],
implementation: "login_handler".to_string(),
continue_on_failure: false,
requires_success: false,
};
struct LoginHandler;
impl crate::executor::CommandHandler for LoginHandler {
fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
Ok(())
}
}
let mut registry = CommandRegistry::new();
registry
.register_sync(cmd_def.clone(), Box::new(LoginHandler))
.unwrap();
let config = CommandsConfig {
metadata: Metadata {
version: "1.0.0".to_string(),
prompt: "testapp".to_string(),
prompt_suffix: " > ".to_string(),
},
commands: vec![cmd_def],
global_options: vec![],
directives: vec![],
};
(registry, config)
}
#[test]
fn test_has_secure_arg_returns_false_without_config() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let mut args = HashMap::new();
args.insert("password".to_string(), "secret".to_string());
assert!(!repl.has_secure_arg("login", &args));
}
#[test]
fn test_has_secure_arg_returns_false_when_no_secure_field() {
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(), Some(config), None).unwrap();
let mut args = HashMap::new();
args.insert("loud".to_string(), "true".to_string());
assert!(!repl.has_secure_arg("hello", &args));
}
#[test]
fn test_has_secure_arg_returns_true_when_secure_argument_present() {
let (registry, config) = make_secure_registry_and_config();
let context = Box::new(TestContext::default());
let repl =
ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
let mut args = HashMap::new();
args.insert("username".to_string(), "alice".to_string());
args.insert("password".to_string(), "secret".to_string());
assert!(repl.has_secure_arg("login", &args));
}
#[test]
fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
let (registry, config) = make_secure_registry_and_config();
let context = Box::new(TestContext::default());
let repl =
ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
let mut args = HashMap::new();
args.insert("username".to_string(), "alice".to_string());
assert!(!repl.has_secure_arg("login", &args));
}
#[test]
fn test_has_secure_arg_returns_false_for_unknown_command() {
let (registry, config) = make_secure_registry_and_config();
let context = Box::new(TestContext::default());
let repl =
ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
let mut args = HashMap::new();
args.insert("password".to_string(), "secret".to_string());
assert!(!repl.has_secure_arg("nonexistent", &args));
}
#[test]
fn test_execute_line_with_secure_arg_does_not_add_to_history() {
let (registry, config) = make_secure_registry_and_config();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
let result = repl.execute_line("login alice secret");
assert!(result.is_ok());
let history = repl.editor.history();
let in_history = (0..history.len()).any(|i| {
history
.get(i, rustyline::history::SearchDirection::Forward)
.ok()
.flatten()
.map(|e| e.entry.as_ref() == "login alice secret")
.unwrap_or(false)
});
assert!(
!in_history,
"secure command line must not be written to history"
);
}
#[test]
fn test_execute_line_without_secure_arg_adds_to_history() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let result = repl.execute_line("test");
assert!(result.is_ok());
let history = repl.editor.history();
let in_history = (0..history.len()).any(|i| {
history
.get(i, rustyline::history::SearchDirection::Forward)
.ok()
.flatten()
.map(|e| e.entry.as_ref() == "test")
.unwrap_or(false)
});
assert!(
in_history,
"non-secure command line must be written to history"
);
}
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_load_executes_each_line_via_handle_line() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let script = write_script("test\nt\n");
let line = format!(":load {}", script.path().display());
assert!(repl.handle_line(&line).is_ok());
let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
assert_eq!(ctx.executed_commands, vec!["test", "test"]);
}
#[test]
fn test_load_skips_blank_lines_and_comments() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let script = write_script("# a comment\n\ntest\n \n# another\n");
let line = format!(":load {}", script.path().display());
assert!(repl.handle_line(&line).is_ok());
let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
assert_eq!(ctx.executed_commands, vec!["test"]);
}
#[test]
fn test_load_continues_past_a_failing_line() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let script = write_script("test\nunknown_command\ntest\n");
let line = format!(":load {}", script.path().display());
let result = repl.handle_line(&line);
assert!(result.is_ok());
let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
assert_eq!(ctx.executed_commands, vec!["test", "test"]);
}
#[test]
fn test_load_missing_path_argument_is_an_error() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let result = repl.handle_line(":load");
assert!(result.is_err());
}
#[test]
fn test_load_missing_file_is_an_error() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let result = repl.handle_line(":load /nonexistent/path/to/script.txt");
assert!(result.is_err());
}
#[test]
fn test_load_line_itself_is_not_added_to_history() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let script = write_script("test\n");
let line = format!(":load {}", script.path().display());
assert!(repl.handle_line(&line).is_ok());
let history = repl.editor.history();
let load_in_history = (0..history.len()).any(|i| {
history
.get(i, rustyline::history::SearchDirection::Forward)
.ok()
.flatten()
.map(|e| e.entry.starts_with(":load"))
.unwrap_or(false)
});
assert!(
!load_in_history,
":load line itself must not be written to history"
);
}
#[test]
fn test_repl_line_is_never_chained_across_multiple_commands() {
let mut registry = CommandRegistry::new();
for name in ["first", "second"] {
let cmd_def = CommandDefinition {
name: name.to_string(),
aliases: vec![],
description: format!("Test command {}", name),
required: false,
arguments: vec![ArgumentDefinition {
name: "value".to_string(),
arg_type: ArgumentType::String,
required: true,
description: "Value".to_string(),
validation: vec![],
secure: false,
}],
options: vec![],
implementation: format!("{}_handler", name),
continue_on_failure: false,
requires_success: false,
};
registry
.register_sync(
cmd_def,
Box::new(TestHandler {
name: name.to_string(),
}),
)
.unwrap();
}
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let result = repl.execute_line("first 1 second");
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Parse(ParseError::TooManyArguments { command, .. }) => {
assert_eq!(command, "first");
}
other => panic!("Expected TooManyArguments error, got: {:?}", other),
}
let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
assert!(ctx.executed_commands.is_empty());
}
#[test]
fn test_prompt_uses_default_suffix_without_config() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None).unwrap();
assert_eq!(repl.prompt, "myapp > ");
}
#[test]
fn test_prompt_honours_configured_suffix() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut config = make_help_config();
config.metadata.prompt_suffix = " $ ".to_string();
let repl =
ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
assert_eq!(repl.prompt, "rpn $ ");
}
#[test]
fn test_effective_prompt_multiline_default_derivation() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let repl = ReplInterface::new(registry, context, "rpn".to_string(), None, None).unwrap();
assert_eq!(repl.prompt, "rpn > ");
assert_eq!(repl.effective_prompt_multiline(), "...> ");
}
#[test]
fn test_effective_prompt_multiline_uses_configured_suffix() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut config = make_help_config();
config.metadata.prompt_suffix = " $ ".to_string();
let repl =
ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
assert_eq!(repl.effective_prompt_multiline(), "...$ ");
}
#[test]
fn test_with_prompt_multiline_overrides_base_only() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut config = make_help_config();
config.metadata.prompt_suffix = " $ ".to_string();
let repl = ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None)
.unwrap()
.with_prompt_multiline("_ _ _".to_string());
assert_eq!(repl.effective_prompt_multiline(), "_ _ _ $ ");
}
#[test]
fn test_accumulate_line_single_line_no_continuation() {
let mut buffer: Vec<String> = Vec::new();
let result = ReplInterface::accumulate_line(&mut buffer, "test");
assert_eq!(result, Some("test".to_string()));
assert!(buffer.is_empty());
}
#[test]
fn test_accumulate_line_trailing_backslash_buffers_and_returns_none() {
let mut buffer: Vec<String> = Vec::new();
let result = ReplInterface::accumulate_line(&mut buffer, "push \\");
assert_eq!(result, None);
assert_eq!(buffer, vec!["push".to_string()]);
}
#[test]
fn test_accumulate_line_multi_fragment_reconstruction() {
let mut buffer: Vec<String> = Vec::new();
assert_eq!(
ReplInterface::accumulate_line(&mut buffer, "config \\"),
None
);
assert_eq!(
ReplInterface::accumulate_line(&mut buffer, "--format model \\"),
None
);
let result = ReplInterface::accumulate_line(&mut buffer, "source=model.yml");
assert_eq!(
result,
Some("config --format model source=model.yml".to_string())
);
assert!(buffer.is_empty());
}
#[test]
fn test_accumulate_line_strips_marker_and_surrounding_whitespace() {
let mut buffer: Vec<String> = Vec::new();
ReplInterface::accumulate_line(&mut buffer, "test \\");
let result = ReplInterface::accumulate_line(&mut buffer, " arg");
assert_eq!(result, Some("test arg".to_string()));
}
#[test]
fn test_run_multiline_reconstruction_reaches_execute_line_once() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let mut buffer: Vec<String> = Vec::new();
assert_eq!(ReplInterface::accumulate_line(&mut buffer, "te \\"), None);
let line = ReplInterface::accumulate_line(&mut buffer, "st").unwrap();
assert_eq!(line, "te st");
let result = repl.execute_line(&line);
assert!(result.is_err());
}
#[test]
fn test_load_line_ending_in_backslash_is_not_continued() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
let script = write_script("test \\\ntest\n");
let line = format!(":load {}", script.path().display());
assert!(repl.handle_line(&line).is_ok());
let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
assert_eq!(ctx.executed_commands, vec!["test"]);
}
use crate::config::directive::DirectiveDefinition;
fn repl_with(
config: Option<CommandsConfig>,
formatter: Option<Box<dyn HelpFormatter>>,
) -> ReplInterface {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
ReplInterface::new(registry, context, "test".to_string(), config, formatter).unwrap()
}
fn config_with_directives(directives: Vec<DirectiveDefinition>) -> CommandsConfig {
let mut config = make_help_config();
config.directives = directives;
config
}
fn override_directive(
implementation: ReplDirective,
name: &str,
aliases: &[&str],
) -> DirectiveDefinition {
DirectiveDefinition {
implementation,
name: name.to_string(),
aliases: aliases.iter().map(|a| a.to_string()).collect(),
description: "Custom".to_string(),
}
}
fn history_len(repl: &ReplInterface) -> usize {
repl.editor.history().len()
}
fn executed(repl: &ReplInterface) -> Vec<String> {
crate::context::downcast_ref::<TestContext>(&*repl.context)
.unwrap()
.executed_commands
.clone()
}
fn invalid_syntax(result: Result<SessionControl>) -> (String, String) {
match result {
Err(DynamicCliError::Parse(ParseError::InvalidSyntax { details, hint })) => {
(details, hint.unwrap_or_default())
}
other => panic!("Expected InvalidSyntax error, got {:?}", other),
}
}
fn unknown_command_suggestions(result: Result<SessionControl>) -> Vec<String> {
match result {
Err(DynamicCliError::Parse(ParseError::UnknownCommand { suggestions, .. })) => {
suggestions
}
other => panic!("Expected UnknownCommand error, got {:?}", other),
}
}
fn unknown_command_hint(result: Result<SessionControl>) -> String {
match result {
Err(DynamicCliError::Parse(ParseError::UnknownCommand { hint, .. })) => {
hint.expect("the REPL sets the hint")
}
other => panic!("Expected UnknownCommand error, got {:?}", other),
}
}
#[test]
fn test_directive_table_defaults_without_config() {
let repl = repl_with(None, None);
assert_eq!(&repl.directives[..], effective_directives(&[]).as_slice());
assert_eq!(repl.resolve_directive("q"), Some(ReplDirective::Quit));
assert_eq!(repl.resolve_directive("?"), Some(ReplDirective::Help));
assert_eq!(repl.resolve_directive("test"), None);
}
#[test]
fn test_directive_help_forms_without_formatter() {
colored::control::set_override(false);
let mut repl = repl_with(Some(make_help_config()), None);
for line in [
":help",
":h",
":?",
":help hello",
":h hi",
" :help hello ",
] {
assert_eq!(
repl.handle_line(line).unwrap(),
SessionControl::Continue,
"{}",
line
);
}
}
#[test]
fn test_directive_help_forms_with_formatter() {
colored::control::set_override(false);
let mut repl = repl_with(
Some(make_help_config()),
Some(Box::new(DefaultHelpFormatter::new())),
);
for line in [":help", ":h", ":?", ":help hello"] {
assert_eq!(
repl.handle_line(line).unwrap(),
SessionControl::Continue,
"{}",
line
);
}
}
#[test]
fn test_directive_help_renders_like_help_flag() {
colored::control::set_override(false);
let repl = repl_with(Some(make_help_config()), None);
assert_eq!(repl.render_help(None), repl.try_handle_help("--help"));
assert_eq!(
repl.render_help(Some("hello")),
repl.try_handle_help("--help hello")
);
}
#[test]
fn test_unknown_command_hint_names_help_directive() {
let mut repl = repl_with(Some(make_help_config()), None);
assert_eq!(
unknown_command_hint(repl.handle_line("frobnicate")),
"Type ':help' for available commands"
);
}
#[test]
fn test_unknown_command_hint_without_config() {
let mut repl = repl_with(None, None);
assert_eq!(
unknown_command_hint(repl.handle_line("frobnicate")),
"Type ':help' for available commands"
);
}
#[test]
fn test_unknown_command_hint_with_directive_suggestion() {
colored::control::set_override(false);
let mut repl = repl_with(Some(make_help_config()), None);
let error = repl.handle_line("quit").unwrap_err();
let formatted = crate::error::format_error(&error);
let suggestion_at = formatted.find(":quit").expect("directive suggested");
let hint_at = formatted
.find("Type ':help' for available commands")
.expect("hint shown");
assert!(suggestion_at < hint_at, "{}", formatted);
}
#[test]
fn test_help_hints_use_translated_help_directive() {
colored::control::set_override(false);
let mut repl = repl_with(
Some(config_with_directives(vec![override_directive(
ReplDirective::Help,
"aide",
&["?"],
)])),
None,
);
assert_eq!(
unknown_command_hint(repl.handle_line("frobnicate")),
"Type ':aide' for available commands"
);
let app_help = repl.render_help(None).unwrap();
assert!(
app_help.ends_with("\nType ':aide <command>' for more information on a command.\n"),
"{}",
app_help
);
assert!(!app_help.contains(":help"), "{}", app_help);
}
#[test]
fn test_app_help_footer_names_help_directive() {
colored::control::set_override(false);
let repl = repl_with(Some(make_help_config()), None);
let config = repl.config.as_deref().unwrap();
let formatter = DefaultHelpFormatter::new();
assert_eq!(
repl.render_help(None).unwrap(),
format!(
"{}{}\nType ':help <command>' for more information on a command.\n",
formatter.format_app(config),
formatter.format_directives(&repl.directives)
)
);
assert_eq!(
repl.render_help(Some("hello")).unwrap(),
DefaultHelpFormatter::new().format_command(config, "hello")
);
}
#[test]
fn test_directive_help_without_config_is_not_available() {
let mut repl = repl_with(None, None);
let err = repl.handle_line(":help").unwrap_err();
assert!(err.to_string().contains("help is not available"), "{}", err);
}
#[test]
fn test_directive_help_with_two_arguments_is_a_usage_error() {
let mut repl = repl_with(Some(make_help_config()), None);
let (details, hint) = invalid_syntax(repl.handle_line(":h hello extra"));
assert!(details.contains("`:h`"), "{}", details);
assert_eq!(hint, "Usage: :help [command]");
}
#[test]
fn test_directive_quit_and_exit_leave() {
let mut repl = repl_with(None, None);
let save = SessionControl::Leave { save_history: true };
let discard = SessionControl::Leave {
save_history: false,
};
assert_eq!(repl.handle_line(":quit").unwrap(), save);
assert_eq!(repl.handle_line(":q").unwrap(), save);
assert_eq!(repl.handle_line(":exit").unwrap(), discard);
}
#[test]
fn test_directive_quit_with_argument_is_a_usage_error() {
let mut repl = repl_with(None, None);
let (details, hint) = invalid_syntax(repl.handle_line(":q now"));
assert!(details.contains("takes no argument"), "{}", details);
assert_eq!(hint, "Usage: :quit");
}
#[test]
fn test_directive_load_without_path_shows_usage() {
let mut repl = repl_with(None, None);
let (details, hint) = invalid_syntax(repl.handle_line(":load"));
assert!(details.contains("requires a file path"), "{}", details);
assert_eq!(hint, "Usage: :load <path>");
}
static HISTORY_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn history_file_lock() -> std::sync::MutexGuard<'static, ()> {
HISTORY_FILE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn test_end_session_with_save_writes_history_file() {
let _lock = history_file_lock();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history");
let mut repl = repl_with(None, None);
repl.history_path = Some(path.clone());
assert!(repl.handle_line("test").is_ok());
repl.end_session(true);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.lines().any(|l| l == "test"), "{}", content);
}
#[test]
fn test_end_session_without_save_leaves_history_file_unchanged() {
let _lock = history_file_lock();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history");
std::fs::write(&path, "previous\n").unwrap();
let mut repl = repl_with(None, None);
repl.history_path = Some(path.clone());
assert!(repl.handle_line("test").is_ok());
repl.end_session(false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "previous\n");
}
#[test]
fn test_drop_does_not_write_history_file() {
let _lock = history_file_lock();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history");
let mut repl = repl_with(None, None);
repl.history_path = Some(path.clone());
assert!(repl.handle_line("test").is_ok());
drop(repl);
assert!(!path.exists());
}
#[test]
fn test_directive_lines_are_not_added_to_history() {
colored::control::set_override(false);
let mut repl = repl_with(Some(make_help_config()), None);
let before = history_len(&repl);
for line in [":help", ":quit", ":exit", ":unknown"] {
let _ = repl.handle_line(line);
}
assert_eq!(history_len(&repl), before);
}
#[test]
fn test_bare_quit_and_exit_no_longer_leave() {
let mut repl = repl_with(None, None);
let suggestions = unknown_command_suggestions(repl.handle_line("quit"));
assert_eq!(suggestions.first().map(String::as_str), Some(":quit"));
let suggestions = unknown_command_suggestions(repl.handle_line("exit"));
assert_eq!(suggestions.first().map(String::as_str), Some(":exit"));
let suggestions = unknown_command_suggestions(repl.handle_line("q"));
assert_eq!(suggestions.first().map(String::as_str), Some(":q"));
}
#[test]
fn test_unknown_command_unrelated_to_directives_gets_no_directive() {
let mut repl = repl_with(None, None);
let suggestions = unknown_command_suggestions(repl.handle_line("frobnicate"));
assert!(suggestions.iter().all(|s| !s.starts_with(':')));
}
#[test]
fn test_application_command_named_like_a_directive_runs() {
let mut registry = create_test_registry();
let exit = CommandDefinition {
name: "exit".to_string(),
aliases: vec![],
description: "Application exit command".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "exit_handler".to_string(),
continue_on_failure: false,
requires_success: false,
};
registry
.register_sync(
exit,
Box::new(TestHandler {
name: "exit".to_string(),
}),
)
.unwrap();
let context = Box::new(TestContext::default());
let mut repl =
ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
assert_eq!(repl.handle_line("exit").unwrap(), SessionControl::Continue);
assert_eq!(executed(&repl), vec!["exit"]);
assert_eq!(
repl.handle_line(":exit").unwrap(),
SessionControl::Leave {
save_history: false
}
);
}
#[test]
fn test_translated_directive_names_replace_defaults() {
let mut repl = repl_with(
Some(config_with_directives(vec![
override_directive(ReplDirective::Quit, "quitter", &[]),
override_directive(ReplDirective::Load, "charger", &["c"]),
])),
None,
);
assert_eq!(
repl.handle_line(":quitter").unwrap(),
SessionControl::Leave { save_history: true }
);
assert!(repl.handle_line(":quit").is_err());
assert!(repl.handle_line(":q").is_err());
assert_eq!(
repl.handle_line(":exit").unwrap(),
SessionControl::Leave {
save_history: false
}
);
let script = write_script("test\n");
let line = format!(":c {}", script.path().display());
assert!(repl.handle_line(&line).is_ok());
assert_eq!(executed(&repl), vec!["test"]);
let (_, hint) = invalid_syntax(repl.handle_line(":charger"));
assert_eq!(hint, "Usage: :charger <path>");
let suggestions = unknown_command_suggestions(repl.handle_line("quitter"));
assert_eq!(suggestions.first().map(String::as_str), Some(":quitter"));
let suggestions = unknown_command_suggestions(repl.handle_line("quit"));
assert!(!suggestions.iter().any(|s| s == ":quit"));
}
#[test]
fn test_unknown_directive_lists_effective_directives() {
let mut repl = repl_with(
Some(config_with_directives(vec![override_directive(
ReplDirective::Help,
"aide",
&[],
)])),
None,
);
let (details, hint) = invalid_syntax(repl.handle_line(":foo"));
assert_eq!(details, "Unknown directive ':foo'");
assert_eq!(hint, "Available directives: :aide, :load, :quit, :exit");
let (details, _) = invalid_syntax(repl.handle_line(":"));
assert_eq!(details, "Unknown directive ':'");
}
fn complete_with(directives: Arc<[DirectiveDefinition]>, line: &str) -> (usize, Vec<String>) {
let completer = DcliCompleter::new(Arc::new(create_test_registry()), None, directives);
let history = rustyline::history::DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (start, candidates) = completer.complete(line, line.len(), &ctx).unwrap();
(
start,
candidates
.into_iter()
.map(|pair| pair.replacement)
.collect(),
)
}
#[test]
fn test_completer_colon_lists_all_directives() {
let (start, names) = complete_with(default_directives(), ":");
assert_eq!(start, 0);
assert_eq!(
names,
vec![":?", ":exit", ":h", ":help", ":load", ":q", ":quit"]
);
}
#[test]
fn test_completer_directive_prefix() {
assert_eq!(complete_with(default_directives(), ":he").1, vec![":help"]);
assert_eq!(
complete_with(default_directives(), ":q").1,
vec![":q", ":quit"]
);
assert!(complete_with(default_directives(), ":zz").1.is_empty());
assert_eq!(
complete_with(default_directives(), " :lo"),
(2, vec![":load".to_string()])
);
}
#[test]
fn test_completer_directives_follow_configuration() {
let table: Arc<[DirectiveDefinition]> =
effective_directives(&[override_directive(ReplDirective::Help, "aide", &["a"])]).into();
assert_eq!(
complete_with(Arc::clone(&table), ":a").1,
vec![":a", ":aide"]
);
assert!(complete_with(table, ":h").1.is_empty());
}
#[test]
fn test_completer_commands_unchanged_by_directives() {
let (_, names) = complete_with(default_directives(), "");
assert!(names.iter().all(|n| !n.starts_with(':')), "{:?}", names);
assert!(names.contains(&"test".to_string()));
assert!(complete_with(default_directives(), ":help ").1.is_empty());
}
#[test]
fn test_completer_shares_repl_directive_table() {
let repl = repl_with(
Some(config_with_directives(vec![override_directive(
ReplDirective::Quit,
"quitter",
&[],
)])),
None,
);
let helper = repl.editor.helper().expect("helper installed");
assert!(Arc::ptr_eq(&helper.completer.directives, &repl.directives));
}
#[test]
fn test_app_help_lists_directives_before_footer() {
colored::control::set_override(false);
let repl = repl_with(Some(make_help_config()), None);
let help = repl.render_help(None).unwrap();
let commands_at = help.find("COMMANDS:").expect("commands");
let directives_at = help.find("DIRECTIVES:").expect("directives");
let footer_at = help.find("Type ':help <command>'").expect("footer");
assert!(
commands_at < directives_at && directives_at < footer_at,
"{}",
help
);
for usage in [":help [command]", ":load <path>", ":quit", ":exit"] {
assert!(help.contains(usage), "{}", help);
}
}
#[test]
fn test_app_help_lists_translated_directives() {
colored::control::set_override(false);
let repl = repl_with(
Some(config_with_directives(vec![
override_directive(ReplDirective::Help, "aide", &["?"]),
override_directive(ReplDirective::Load, "charger", &[]),
])),
None,
);
let help = repl.render_help(None).unwrap();
assert!(help.contains(":aide [command]"), "{}", help);
assert!(help.contains("[aliases: :?]"), "{}", help);
assert!(help.contains(":charger <path>"), "{}", help);
assert!(!help.contains(":help"), "{}", help);
assert!(!help.contains(":load"), "{}", help);
assert_eq!(Some(help), repl.try_handle_help("--help"));
}
#[test]
fn test_app_help_uses_custom_directive_listing() {
struct ListingFormatter;
impl HelpFormatter for ListingFormatter {
fn format_app(&self, _: &CommandsConfig) -> String {
"APP\n".to_string()
}
fn format_command(&self, _: &CommandsConfig, command: &str) -> String {
command.to_string()
}
fn format_directives(&self, directives: &[DirectiveDefinition]) -> String {
let names: Vec<&str> = directives.iter().map(|d| d.name.as_str()).collect();
format!("DIR {}\n", names.join(","))
}
}
colored::control::set_override(false);
let repl = repl_with(Some(make_help_config()), Some(Box::new(ListingFormatter)));
assert_eq!(
repl.render_help(None).unwrap(),
"APP\nDIR help,load,quit,exit\n\
\nType ':help <command>' for more information on a command.\n"
);
}
#[test]
fn test_command_help_has_no_directive_list() {
colored::control::set_override(false);
let repl = repl_with(Some(make_help_config()), None);
let help = repl.render_help(Some("hello")).unwrap();
assert!(!help.contains("DIRECTIVES:"), "{}", help);
}
#[test]
fn test_load_refuses_leaving_directives_and_runs_other_lines() {
colored::control::set_override(false);
let mut repl = repl_with(Some(make_help_config()), None);
let script = write_script("test\n:quit\n:q\n:exit\n:help\ntest\n");
let line = format!(":load {}", script.path().display());
assert_eq!(repl.handle_line(&line).unwrap(), SessionControl::Continue);
assert_eq!(executed(&repl), vec!["test", "test"]);
}
}