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),
#[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>,
},
}
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_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_handler(\"{command}\", ...) before running."
)),
}
}
}
#[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_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_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")); }
}