use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DynamicCliError {
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Parse(#[from] ParseError),
#[error(transparent)]
Validation(#[from] ValidationError),
#[error(transparent)]
Execution(#[from] ExecutionError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[cfg(feature = "wasm-plugins")]
#[error(transparent)]
Wasm(#[from] WasmError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Configuration file not found: {path:?}")]
FileNotFound {
path: PathBuf,
suggestion: Option<String>,
},
#[error("Unsupported file format: '{extension}'. Supported: .yaml, .yml, .json")]
UnsupportedFormat {
extension: String,
suggestion: Option<String>,
},
#[error("Failed to parse YAML configuration at line {line:?}, column {column:?}: {source}")]
YamlParse {
#[source]
source: serde_yaml::Error,
line: Option<usize>,
column: Option<usize>,
},
#[error("Failed to parse JSON configuration at line {line}, column {column}: {source}")]
JsonParse {
#[source]
source: serde_json::Error,
line: usize,
column: usize,
},
#[error("Invalid configuration schema: {reason} (at {path:?})")]
InvalidSchema {
reason: String,
path: Option<String>,
suggestion: Option<String>,
},
#[error("Duplicate command name or alias: '{name}'")]
DuplicateCommand {
name: String,
suggestion: Option<String>,
},
#[error("Unknown argument type: '{type_name}' in {context}")]
UnknownType {
type_name: String,
context: String,
suggestion: Option<String>,
},
#[error("Configuration inconsistency: {details}")]
Inconsistency {
details: String,
suggestion: Option<String>,
},
}
#[derive(Debug, Error)]
pub enum ParseError {
#[error("Unknown command: '{command}'. Type 'help' for available commands.")]
UnknownCommand {
command: String,
suggestions: Vec<String>,
},
#[error("Missing required argument: {argument} for command '{command}'")]
MissingArgument {
argument: String,
command: String,
suggestion: Option<String>,
},
#[error("Missing required option: --{option} for command '{command}'")]
MissingOption {
option: String,
command: String,
suggestion: Option<String>,
},
#[error("Too many arguments for command '{command}'. Expected {expected}, got {got}")]
TooManyArguments {
command: String,
expected: usize,
got: usize,
suggestion: Option<String>,
},
#[error("Unknown option: {flag} for command '{command}'")]
UnknownOption {
flag: String,
command: String,
suggestions: Vec<String>,
},
#[error("Failed to parse {arg_name} as {expected_type}: '{value}'{}",
.details.as_ref().map(|d| format!(" ({})", d)).unwrap_or_default())]
TypeParseError {
arg_name: String,
expected_type: String,
value: String,
details: Option<String>,
},
#[error("Invalid value for {arg_name}: '{value}'. Must be one of: {}",
.choices.join(", "))]
InvalidChoice {
arg_name: String,
value: String,
choices: Vec<String>,
},
#[error("Invalid command syntax: {details}{}",
.hint.as_ref().map(|h| format!("\nHint: {}", h)).unwrap_or_default())]
InvalidSyntax {
details: String,
hint: Option<String>,
},
}
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("File not found for argument '{arg_name}': {path:?}")]
FileNotFound {
path: PathBuf,
arg_name: String,
suggestion: Option<String>,
},
#[error("Invalid file extension for {arg_name}: {path:?}. Expected: {}",
.expected.join(", "))]
InvalidExtension {
arg_name: String,
path: PathBuf,
expected: Vec<String>,
},
#[error("{arg_name} must be between {min} and {max}, got {value}")]
OutOfRange {
arg_name: String,
value: f64,
min: f64,
max: f64,
suggestion: Option<String>,
},
#[error("Validation failed for {arg_name}: {reason}")]
CustomConstraint {
arg_name: String,
reason: String,
suggestion: Option<String>,
},
#[error("{arg_name} requires {required_arg} to be specified")]
MissingDependency {
arg_name: String,
required_arg: String,
suggestion: Option<String>,
},
#[error("Options {arg1} and {arg2} cannot be used together")]
MutuallyExclusive {
arg1: String,
arg2: String,
suggestion: Option<String>,
},
}
#[derive(Debug, Error)]
pub enum ExecutionError {
#[error("No handler registered for command '{command}' (implementation: '{implementation}')")]
HandlerNotFound {
command: String,
implementation: String,
suggestion: Option<String>,
},
#[error("Failed to downcast execution context to expected type: {expected_type}")]
ContextDowncastFailed {
expected_type: String,
suggestion: Option<String>,
},
#[error("Invalid context state: {reason}")]
InvalidContextState {
reason: String,
suggestion: Option<String>,
},
#[error("Command execution failed: {0}")]
CommandFailed(#[source] anyhow::Error),
#[error("Command interrupted by user")]
Interrupted,
}
#[derive(Debug, Error)]
pub enum RegistryError {
#[error("Command '{name}' is already registered")]
DuplicateRegistration {
name: String,
suggestion: Option<String>,
},
#[error("Alias '{alias}' is already used by command '{existing_command}'")]
DuplicateAlias {
alias: String,
existing_command: String,
suggestion: Option<String>,
},
#[error("No handler provided for command '{command}'")]
MissingHandler {
command: String,
suggestion: Option<String>,
},
}
#[cfg(feature = "wasm-plugins")]
#[derive(Debug, Error)]
pub enum WasmError {
#[error("Failed to load WASM module: {path:?}: {source}")]
LoadFailed {
path: PathBuf,
#[source]
source: anyhow::Error,
suggestion: Option<String>,
},
#[error("WASM function '{function}' not found in module '{module}'")]
FunctionNotFound {
function: String,
module: String,
suggestion: Option<String>,
},
#[error("WASM guest returned error code {code}{}",
.message.as_ref().map(|m| format!(": {m}")).unwrap_or_default())]
GuestError {
code: i32,
message: Option<String>,
},
#[error("Failed to serialize arguments for WASM call: {0}")]
SerializationFailed(String),
#[error("Failed to access WASM guest memory: {reason}")]
MemoryAccessFailed { reason: String },
}
impl ParseError {
pub fn unknown_command_with_suggestions(command: &str, available: &[String]) -> Self {
let suggestions = crate::error::find_similar_strings(command, available, 3);
Self::UnknownCommand {
command: command.to_string(),
suggestions,
}
}
pub fn unknown_option_with_suggestions(
flag: &str,
command: &str,
available: &[String],
) -> Self {
let suggestions = crate::error::find_similar_strings(flag, available, 2);
Self::UnknownOption {
flag: flag.to_string(),
command: command.to_string(),
suggestions,
}
}
pub fn missing_argument(argument: &str, command: &str) -> Self {
Self::MissingArgument {
argument: argument.to_string(),
command: command.to_string(),
suggestion: Some(format!("Run --help {command} to see required arguments.")),
}
}
pub fn missing_option(option: &str, command: &str) -> Self {
Self::MissingOption {
option: option.to_string(),
command: command.to_string(),
suggestion: Some(format!("Run --help {command} to see required options.")),
}
}
pub fn too_many_arguments(command: &str, expected: usize, got: usize) -> Self {
Self::TooManyArguments {
command: command.to_string(),
expected,
got,
suggestion: Some(format!("Run --help {command} for the expected usage.")),
}
}
}
impl ConfigError {
pub fn file_not_found(path: PathBuf) -> Self {
Self::FileNotFound {
path,
suggestion: Some("Verify the path and file permissions.".to_string()),
}
}
pub fn unsupported_format(extension: &str) -> Self {
Self::UnsupportedFormat {
extension: extension.to_string(),
suggestion: Some("Rename the file with a .yaml, .yml or .json extension.".to_string()),
}
}
pub fn yaml_parse_with_location(source: serde_yaml::Error) -> Self {
let location = source.location();
Self::YamlParse {
source,
line: location.as_ref().map(|l| l.line()),
column: location.map(|l| l.column()),
}
}
pub fn json_parse_with_location(source: serde_json::Error) -> Self {
Self::JsonParse {
line: source.line(),
column: source.column(),
source,
}
}
}
impl ExecutionError {
pub fn handler_not_found(command: &str, implementation: &str) -> Self {
Self::HandlerNotFound {
command: command.to_string(),
implementation: implementation.to_string(),
suggestion: Some(format!(
"Ensure .register_sync_handler(\"{implementation}\", ...) or \
.register_async_handler(\"{implementation}\", ...) was called before running."
)),
}
}
}
impl RegistryError {
pub fn missing_handler(command: &str) -> Self {
Self::MissingHandler {
command: command.to_string(),
suggestion: Some(format!(
"Call .register_sync_handler(\"{command}\", ...) before running."
)),
}
}
}
#[cfg(feature = "wasm-plugins")]
impl WasmError {
pub fn missing_mandatory_export(function: &str, module: &str) -> Self {
let signature = match function {
"dcli_alloc" => "fn dcli_alloc(size: i32) -> i32",
"dcli_dealloc" => "fn dcli_dealloc(ptr: i32, size: i32)",
"memory" => "(memory (export \"memory\") ...)",
other => other,
};
Self::FunctionNotFound {
function: function.to_string(),
module: module.to_string(),
suggestion: Some(format!(
"Export `{signature}` from the WASM module. \
See WASM_PLUGIN_INTERFACE.md for the full contract."
)),
}
}
pub fn guest_error_without_message(code: i32) -> Self {
Self::GuestError {
code,
message: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_file_not_found_display() {
let error = ConfigError::FileNotFound {
path: PathBuf::from("/path/to/config.yaml"),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("not found"));
assert!(msg.contains("config.yaml"));
}
#[test]
fn test_config_file_not_found_helper_has_suggestion() {
let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
match error {
ConfigError::FileNotFound { suggestion, .. } => {
assert!(suggestion.is_some(), "helper must populate suggestion");
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_config_unsupported_format_helper_has_suggestion() {
let error = ConfigError::unsupported_format(".toml");
match error {
ConfigError::UnsupportedFormat {
suggestion,
extension,
..
} => {
assert_eq!(extension, ".toml");
assert!(suggestion.is_some());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_config_duplicate_command_display() {
let error = ConfigError::DuplicateCommand {
name: "run".to_string(),
suggestion: Some("Rename one of the conflicting commands.".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains("run"));
assert!(!msg.contains("Rename"));
}
#[test]
fn test_config_unknown_type_display() {
let error = ConfigError::UnknownType {
type_name: "datetime".to_string(),
context: "commands[0]".to_string(),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("datetime"));
}
#[test]
fn test_config_inconsistency_display() {
let error = ConfigError::Inconsistency {
details: "default not in choices".to_string(),
suggestion: Some("hint".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains("default not in choices"));
assert!(!msg.contains("hint")); }
#[test]
fn test_config_invalid_schema_display() {
let error = ConfigError::InvalidSchema {
reason: "missing field".to_string(),
path: Some("commands[0]".to_string()),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("missing field"));
}
#[test]
fn test_parse_unknown_command_with_suggestions() {
let available = vec!["simulate".to_string(), "validate".to_string()];
let error = ParseError::unknown_command_with_suggestions("simulat", &available);
match error {
ParseError::UnknownCommand {
command,
suggestions,
} => {
assert_eq!(command, "simulat");
assert!(suggestions.contains(&"simulate".to_string()));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_parse_missing_argument_helper_has_suggestion() {
let error = ParseError::missing_argument("filename", "process");
match error {
ParseError::MissingArgument {
suggestion,
command,
..
} => {
assert_eq!(command, "process");
let s = suggestion.unwrap();
assert!(s.contains("process"));
assert!(s.contains("--help"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_parse_missing_option_helper_has_suggestion() {
let error = ParseError::missing_option("output", "export");
match error {
ParseError::MissingOption {
suggestion, option, ..
} => {
assert_eq!(option, "output");
let s = suggestion.unwrap();
assert!(s.contains("export"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_parse_too_many_arguments_helper_has_suggestion() {
let error = ParseError::too_many_arguments("run", 1, 3);
match error {
ParseError::TooManyArguments {
suggestion,
expected,
got,
..
} => {
assert_eq!(expected, 1);
assert_eq!(got, 3);
assert!(suggestion.is_some());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_parse_missing_argument_suggestion_none_by_default() {
let error = ParseError::MissingArgument {
argument: "file".to_string(),
command: "run".to_string(),
suggestion: None,
};
match error {
ParseError::MissingArgument { suggestion, .. } => assert!(suggestion.is_none()),
_ => panic!("wrong variant"),
}
}
#[test]
fn test_validation_out_of_range_display() {
let error = ValidationError::OutOfRange {
arg_name: "percentage".to_string(),
value: 150.0,
min: 0.0,
max: 100.0,
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("percentage"));
assert!(msg.contains("150"));
assert!(msg.contains("0"));
assert!(msg.contains("100"));
}
#[test]
fn test_validation_out_of_range_suggestion_not_in_display() {
let error = ValidationError::OutOfRange {
arg_name: "percentage".to_string(),
value: 150.0,
min: 0.0,
max: 100.0,
suggestion: Some("Value must be between 0 and 100.".to_string()),
};
let msg = format!("{}", error);
assert!(!msg.contains("Value must be between")); }
#[test]
fn test_validation_file_not_found_suggestion() {
let error = ValidationError::FileNotFound {
path: PathBuf::from("data.csv"),
arg_name: "input".to_string(),
suggestion: Some("Check that the file exists.".to_string()),
};
match error {
ValidationError::FileNotFound { suggestion, .. } => {
assert!(suggestion.is_some());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_validation_missing_dependency_suggestion() {
let error = ValidationError::MissingDependency {
arg_name: "format".to_string(),
required_arg: "output".to_string(),
suggestion: Some("Add --output to your command.".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains("format"));
assert!(msg.contains("output"));
}
#[test]
fn test_validation_mutually_exclusive_suggestion() {
let error = ValidationError::MutuallyExclusive {
arg1: "--verbose".to_string(),
arg2: "--quiet".to_string(),
suggestion: Some("Remove one of the two conflicting options.".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains("--verbose"));
assert!(msg.contains("--quiet"));
}
#[test]
fn test_execution_handler_not_found_helper_interpolates_impl() {
let error = ExecutionError::handler_not_found("run", "run_handler");
match error {
ExecutionError::HandlerNotFound {
suggestion,
implementation,
..
} => {
assert_eq!(implementation, "run_handler");
let s = suggestion.unwrap();
assert!(s.contains("run_handler"));
assert!(s.contains("register_sync_handler"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_execution_context_downcast_failed_display() {
let error = ExecutionError::ContextDowncastFailed {
expected_type: "MyAppContext".to_string(),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("MyAppContext"));
}
#[test]
fn test_execution_invalid_context_state_suggestion() {
let error = ExecutionError::InvalidContextState {
reason: "pool not ready".to_string(),
suggestion: Some("Ensure context is initialised.".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains("pool not ready"));
}
#[test]
fn test_registry_missing_handler_helper_interpolates_command() {
let error = RegistryError::missing_handler("export");
match error {
RegistryError::MissingHandler {
suggestion,
command,
} => {
assert_eq!(command, "export");
let s = suggestion.unwrap();
assert!(s.contains("export"));
assert!(s.contains("register_sync_handler"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn test_registry_duplicate_registration_display() {
let error = RegistryError::DuplicateRegistration {
name: "run".to_string(),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("run"));
}
#[test]
fn test_registry_duplicate_alias_display() {
let error = RegistryError::DuplicateAlias {
alias: "r".to_string(),
existing_command: "run".to_string(),
suggestion: Some("Choose a different alias.".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains("run"));
assert!(!msg.contains("Choose")); }
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_load_failed_display() {
let error = WasmError::LoadFailed {
path: PathBuf::from("plugin.wasm"),
source: anyhow::anyhow!("invalid magic number"),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("plugin.wasm"));
assert!(msg.contains("invalid magic number"));
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_function_not_found_display() {
let error = WasmError::FunctionNotFound {
function: "dcli_alloc".to_string(),
module: "plugin.wasm".to_string(),
suggestion: None,
};
let msg = format!("{}", error);
assert!(msg.contains("dcli_alloc"));
assert!(msg.contains("plugin.wasm"));
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_missing_mandatory_export_helper_has_suggestion() {
let error = WasmError::missing_mandatory_export("dcli_dealloc", "plugin.wasm");
match error {
WasmError::FunctionNotFound {
suggestion,
function,
..
} => {
assert_eq!(function, "dcli_dealloc");
let s = suggestion.unwrap();
assert!(s.contains("dcli_dealloc"));
assert!(s.contains("WASM_PLUGIN_INTERFACE.md"));
}
_ => panic!("wrong variant"),
}
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_guest_error_with_message_display() {
let error = WasmError::GuestError {
code: 1,
message: Some("invalid argument".to_string()),
};
let msg = format!("{}", error);
assert!(msg.contains('1'));
assert!(msg.contains("invalid argument"));
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_guest_error_without_message_display() {
let error = WasmError::guest_error_without_message(2);
let msg = format!("{}", error);
assert!(msg.contains('2'));
match error {
WasmError::GuestError { message, .. } => assert!(message.is_none()),
_ => panic!("wrong variant"),
}
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_serialization_failed_display() {
let error = WasmError::SerializationFailed("unsupported map key type".to_string());
let msg = format!("{}", error);
assert!(msg.contains("unsupported map key type"));
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_memory_access_failed_display() {
let error = WasmError::MemoryAccessFailed {
reason: "write out of bounds".to_string(),
};
let msg = format!("{}", error);
assert!(msg.contains("out of bounds"));
}
#[cfg(feature = "wasm-plugins")]
#[test]
fn test_wasm_error_converts_into_dynamic_cli_error() {
let wasm_err = WasmError::guest_error_without_message(1);
let err: DynamicCliError = wasm_err.into();
match err {
DynamicCliError::Wasm(WasmError::GuestError { code, .. }) => assert_eq!(code, 1),
_ => panic!("wrong variant"),
}
}
}