use crate::context::ExecutionContext;
use crate::error::{display_error, format_error, DynamicCliError, ExecutionError, Result};
use crate::parser::{CliParser, ParsedArgs, ReplParser};
use crate::registry::CommandRegistry;
use std::path::Path;
use std::process;
#[derive(Debug)]
struct ResolvedSegment {
name: String,
parsed: ParsedArgs,
}
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 segments = self.segment(args)?;
if segments.len() == 1 {
return self.execute_segment(&segments[0]);
}
self.execute_chain(&segments)
}
fn execute_chain(&mut self, segments: &[ResolvedSegment]) -> Result<()> {
let total = segments.len();
let mut chain_has_failure = false;
let mut triggering_failure: Option<DynamicCliError> = None;
for (idx, segment) in segments.iter().enumerate() {
let position = idx + 1;
let (requires_success, continue_on_failure) = self
.registry
.get_definition(&segment.name)
.map(|d| (d.requires_success, d.continue_on_failure))
.unwrap_or((false, false));
if chain_has_failure && requires_success {
eprintln!(
"Skipped: command {}/{} ('{}') — a preceding command failed",
position, total, segment.name
);
continue;
}
if let Err(e) = self.execute_segment(segment) {
let wrapped = wrap_chain_error(position, total, &segment.name, e);
if !chain_has_failure {
triggering_failure = Some(wrapped);
}
chain_has_failure = true;
if !continue_on_failure {
break;
}
}
}
match triggering_failure {
Some(e) => Err(e),
None => Ok(()),
}
}
fn segment(&self, args: &[String]) -> Result<Vec<ResolvedSegment>> {
let mut segments = Vec::new();
let mut offset = 0;
loop {
let command_name = &args[offset];
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_map, consumed) = parser.parse_typed_segment(&args[offset + 1..])?;
segments.push(ResolvedSegment {
name: resolved_name.to_string(),
parsed: ParsedArgs::new(parsed_map),
});
let next = offset + 1 + consumed;
if next == args.len() {
break;
}
if self.registry.resolve_name(&args[next]).is_none() {
return Err(crate::error::ParseError::too_many_arguments(
&definition.name,
definition.arguments.len(),
definition.arguments.len() + 1,
)
.into());
}
offset = next;
}
Ok(segments)
}
fn execute_segment(&mut self, segment: &ResolvedSegment) -> Result<()> {
if let Some(handler) = self.registry.get_handler_sync(&segment.name) {
handler.execute(&mut *self.context, &segment.parsed)?;
} else if let Some(handler) = self.registry.get_handler_async(&segment.name) {
futures::executor::block_on(handler.execute(&mut *self.context, &segment.parsed))?;
} else {
let implementation = self
.registry
.get_definition(&segment.name)
.map(|d| d.implementation.as_str())
.unwrap_or("");
return Err(DynamicCliError::Execution(
crate::error::ExecutionError::handler_not_found(&segment.name, 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
)))
}
fn wrap_chain_error(
position: usize,
total: usize,
name: &str,
source: DynamicCliError,
) -> DynamicCliError {
DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
"Error in command {}/{} ('{}'): {}",
position,
total,
name,
format_error(&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>,
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_command = Some(self.name.clone());
ctx.executed_commands.push(self.name.clone());
Ok(())
}
}
struct FailingHandler {
name: String,
}
impl crate::executor::CommandHandler for FailingHandler {
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());
Err(DynamicCliError::Execution(ExecutionError::CommandFailed(
anyhow::anyhow!("{} deliberately failed", self.name),
)))
}
}
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,
};
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(),
continue_on_failure: false,
requires_success: false,
};
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(),
continue_on_failure: false,
requires_success: false,
};
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());
}
fn register_arity_command(registry: &mut CommandRegistry, name: &str, arity: usize) {
let arguments = (0..arity)
.map(|i| ArgumentDefinition {
name: format!("arg{}", i),
arg_type: ArgumentType::String,
required: true,
description: format!("Argument {}", i),
validation: vec![],
secure: false,
})
.collect();
let cmd_def = CommandDefinition {
name: name.to_string(),
aliases: vec![],
description: format!("Test command {}", name),
required: false,
arguments,
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(),
}),
)
.expect("Failed to register command");
}
#[test]
fn test_segment_single_command_produces_one_segment() {
let mut registry = CommandRegistry::new();
register_arity_command(&mut registry, "greet", 1);
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let args = vec!["greet".to_string(), "Alice".to_string()];
let segments = cli.segment(&args).unwrap();
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].name, "greet");
assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("Alice"));
}
#[test]
fn test_segment_single_command_overflow_still_raises_too_many_arguments() {
let mut registry = CommandRegistry::new();
register_arity_command(&mut registry, "greet", 1);
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let args = vec![
"greet".to_string(),
"Alice".to_string(),
"extra".to_string(),
];
let result = cli.segment(&args);
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Parse(crate::error::ParseError::TooManyArguments {
command,
expected,
got,
..
}) => {
assert_eq!(command, "greet");
assert_eq!(expected, 1);
assert_eq!(got, 2);
}
other => panic!("Expected TooManyArguments error, got: {:?}", other),
}
}
#[test]
fn test_segment_multi_command_chain_produces_three_segments() {
let mut registry = CommandRegistry::new();
register_arity_command(&mut registry, "first", 1);
register_arity_command(&mut registry, "second", 1);
register_arity_command(&mut registry, "third", 0);
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let args = vec![
"first".to_string(),
"1".to_string(),
"second".to_string(),
"2".to_string(),
"third".to_string(),
];
let segments = cli.segment(&args).unwrap();
assert_eq!(segments.len(), 3);
assert_eq!(segments[0].name, "first");
assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("1"));
assert_eq!(segments[1].name, "second");
assert_eq!(segments[1].parsed.get_scalar("arg0"), Some("2"));
assert_eq!(segments[2].name, "third");
}
#[test]
fn test_segment_unknown_command_produces_unknown_command_error() {
let registry = create_test_registry();
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let args = vec!["nope".to_string()];
let result = cli.segment(&args);
assert!(result.is_err());
match result.unwrap_err() {
DynamicCliError::Parse(crate::error::ParseError::UnknownCommand { .. }) => {}
other => panic!("Expected UnknownCommand error, got: {:?}", other),
}
}
#[test]
fn test_segment_repeated_command_name_resolves_each_occurrence_independently() {
let mut registry = CommandRegistry::new();
register_arity_command(&mut registry, "source", 1);
register_arity_command(&mut registry, "run", 0);
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let args = vec![
"source".to_string(),
"modelfile".to_string(),
"source".to_string(),
"solverfile".to_string(),
"run".to_string(),
];
let segments = cli.segment(&args).unwrap();
assert_eq!(segments.len(), 3);
assert_eq!(segments[0].name, "source");
assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("modelfile"));
assert_eq!(segments[1].name, "source");
assert_eq!(segments[1].parsed.get_scalar("arg0"), Some("solverfile"));
assert_eq!(segments[2].name, "run");
}
#[test]
fn test_dispatch_executes_chain_in_order() {
let mut registry = CommandRegistry::new();
register_arity_command(&mut registry, "first", 1);
register_arity_command(&mut registry, "second", 1);
register_arity_command(&mut registry, "third", 0);
let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec![
"first".to_string(),
"1".to_string(),
"second".to_string(),
"2".to_string(),
"third".to_string(),
];
cli.dispatch(&args).expect("chain should execute fully");
let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context)
.expect("Failed to downcast context");
assert_eq!(
ctx.executed_commands,
vec![
"first".to_string(),
"second".to_string(),
"third".to_string()
]
);
}
#[test]
fn test_segment_known_limitation_extra_token_matching_command_name_is_silently_absorbed() {
let mut registry = CommandRegistry::new();
register_arity_command(&mut registry, "greet", 1);
register_arity_command(&mut registry, "run", 0);
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let args = vec!["greet".to_string(), "Alice".to_string(), "run".to_string()];
let segments = cli
.segment(&args)
.expect("known limitation: no error is raised here, by design");
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].name, "greet");
assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("Alice"));
assert_eq!(segments[1].name, "run");
}
fn register_chain_command(
registry: &mut CommandRegistry,
name: &str,
continue_on_failure: bool,
requires_success: bool,
fails: bool,
) {
let cmd_def = CommandDefinition {
name: name.to_string(),
aliases: vec![],
description: format!("Test command {}", name),
required: false,
arguments: vec![],
options: vec![],
implementation: format!("{}_handler", name),
continue_on_failure,
requires_success,
};
let handler: Box<dyn crate::executor::CommandHandler> = if fails {
Box::new(FailingHandler {
name: name.to_string(),
})
} else {
Box::new(TestHandler {
name: name.to_string(),
})
};
registry
.register_sync(cmd_def, handler)
.expect("Failed to register command");
}
#[test]
fn test_execute_chain_continue_on_failure_false_stops_chain() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "a", false, false, true); register_chain_command(&mut registry, "b", false, false, false);
let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec!["a".to_string(), "b".to_string()];
let result = cli.dispatch(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Error in command 1/2 ('a')"));
let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
assert_eq!(
ctx.executed_commands,
vec!["a".to_string()],
"'b' must never run once 'a' stops the chain"
);
}
#[test]
fn test_execute_chain_continue_on_failure_true_proceeds_and_still_errors() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "a", true, false, true); register_chain_command(&mut registry, "b", false, false, false);
let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec!["a".to_string(), "b".to_string()];
let result = cli.dispatch(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Error in command 1/2 ('a')"));
let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
assert_eq!(
ctx.executed_commands,
vec!["a".to_string(), "b".to_string()],
"'b' must still run: 'a''s failure was absorbed"
);
}
#[test]
fn test_execute_chain_requires_success_skips_after_earlier_failure() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "a", true, false, true); register_chain_command(&mut registry, "b", false, true, false); let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec!["a".to_string(), "b".to_string()];
let result = cli.dispatch(&args);
assert!(result.is_err());
let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
assert_eq!(
ctx.executed_commands,
vec!["a".to_string()],
"'b' must be skipped, not executed, once 'a' has failed"
);
}
#[test]
fn test_execute_chain_requires_success_runs_normally_without_a_preceding_failure() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "a", false, false, false); register_chain_command(&mut registry, "b", false, true, false); let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec!["a".to_string(), "b".to_string()];
cli.dispatch(&args)
.expect("no failure anywhere in the chain");
let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
assert_eq!(
ctx.executed_commands,
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn test_execute_chain_reports_repeated_command_name_by_position_not_name_early() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "ok", false, false, false);
register_chain_command(&mut registry, "source", true, false, true); let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec![
"ok".to_string(),
"source".to_string(),
"ok".to_string(),
"ok".to_string(),
];
let result = cli.dispatch(&args);
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(message.contains("Error in command 2/4 ('source')"));
assert!(!message.contains("4/4"));
}
#[test]
fn test_execute_chain_reports_repeated_command_name_by_position_not_name_late() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "ok", false, false, false);
register_chain_command(&mut registry, "source", true, false, true); let context = Box::new(TestContext::default());
let mut cli = CliInterface::new(registry, context);
let args = vec![
"ok".to_string(),
"ok".to_string(),
"ok".to_string(),
"source".to_string(),
];
let result = cli.dispatch(&args);
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(message.contains("Error in command 4/4 ('source')"));
assert!(!message.contains("2/4"));
}
#[test]
fn test_run_script_chain_failure_reports_chain_position_and_line_number() {
let mut registry = CommandRegistry::new();
register_chain_command(&mut registry, "a", false, false, true); register_chain_command(&mut registry, "b", false, false, false);
let context = Box::new(TestContext::default());
let cli = CliInterface::new(registry, context);
let script = write_script("a b\n");
let outcome = cli
.run_script(script.path(), ScriptErrorPolicy::Continue)
.expect("Continue policy should return Ok even with a failing line");
assert_eq!(outcome.failures.len(), 1);
let (line_number, error) = &outcome.failures[0];
assert_eq!(*line_number, 1);
let message = error.to_string();
assert!(message.contains("line 1"));
assert!(message.contains("Error in command 1/2 ('a')"));
}
}