use core::fmt;
use thiserror::Error;
#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::string::String;
use core::fmt::Write;
#[derive(Debug, Error, PartialEq, Eq, Clone)]
#[non_exhaustive]
pub enum KeyParseError {
#[error("Key cannot be empty or whitespace")]
Empty,
#[error("Invalid character '{character}' at position {position}")]
InvalidCharacter {
character: char,
position: usize,
expected: Option<&'static str>,
},
#[error("Key is too long (max {max_length} characters, got {actual_length})")]
TooLong {
max_length: usize,
actual_length: usize,
},
#[error("Key is too short (min {min_length} characters, got {actual_length})")]
TooShort {
min_length: usize,
actual_length: usize,
},
#[error("Key has invalid structure: {reason}")]
InvalidStructure {
reason: &'static str,
},
#[error("Domain '{domain}' validation failed: {message}")]
DomainValidation {
domain: &'static str,
message: String,
},
#[error("Custom validation error (code: {code}): {message}")]
Custom {
code: u32,
message: String,
},
}
impl KeyParseError {
pub fn domain_error(domain: &'static str, message: impl Into<String>) -> Self {
Self::DomainValidation {
domain,
message: message.into(),
}
}
pub fn domain_error_generic(message: impl Into<String>) -> Self {
Self::DomainValidation {
domain: "unknown",
message: message.into(),
}
}
#[cfg(feature = "std")]
pub fn domain_error_with_source(
domain: &'static str,
message: impl Into<String>,
source: &(dyn std::error::Error + Send + Sync),
) -> Self {
let full_message = format!("{}: {}", message.into(), source);
Self::DomainValidation {
domain,
message: full_message,
}
}
pub fn custom(code: u32, message: impl Into<String>) -> Self {
Self::Custom {
code,
message: message.into(),
}
}
#[cfg(feature = "std")]
pub fn custom_with_source(
code: u32,
message: impl Into<String>,
source: &(dyn std::error::Error + Send + Sync),
) -> Self {
let full_message = format!("{}: {}", message.into(), source);
Self::Custom {
code,
message: full_message,
}
}
#[must_use]
pub const fn code(&self) -> u32 {
match self {
Self::Empty => 1001,
Self::InvalidCharacter { .. } => 1002,
Self::TooLong { .. } => 1003,
Self::InvalidStructure { .. } => 1004,
Self::TooShort { .. } => 1005,
Self::DomainValidation { .. } => 2000,
Self::Custom { code, .. } => *code,
}
}
#[must_use]
pub const fn category(&self) -> ErrorCategory {
match self {
Self::Empty | Self::TooLong { .. } | Self::TooShort { .. } => ErrorCategory::Length,
Self::InvalidCharacter { .. } => ErrorCategory::Character,
Self::InvalidStructure { .. } => ErrorCategory::Structure,
Self::DomainValidation { .. } => ErrorCategory::Domain,
Self::Custom { code, .. } => match code {
1002 => ErrorCategory::Character,
1003 => ErrorCategory::Length,
1004 => ErrorCategory::Structure,
_ => ErrorCategory::Custom,
},
}
}
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Empty => "Key cannot be empty or contain only whitespace characters",
Self::InvalidCharacter { .. } => {
"Key contains characters that are not allowed by the domain"
}
Self::TooLong { .. } => "Key exceeds the maximum length allowed by the domain",
Self::TooShort { .. } => {
"Key is shorter than the minimum length required by the domain"
}
Self::InvalidStructure { .. } => "Key has invalid structure or formatting",
Self::DomainValidation { .. } => "Key fails domain-specific validation rules",
Self::Custom { .. } => "Key fails custom validation rules",
}
}
#[must_use]
pub fn suggestions(&self) -> &'static [&'static str] {
match self {
Self::Empty => &[
"Provide a non-empty key",
"Remove leading/trailing whitespace",
],
Self::InvalidCharacter { .. } => &[
"Use only allowed characters (check domain rules)",
"Remove or replace invalid characters",
],
Self::TooLong { .. } => &[
"Shorten the key to fit within length limits",
"Consider using abbreviated forms",
],
Self::TooShort { .. } => &["Lengthen the key to meet the minimum length requirement"],
Self::InvalidStructure { .. } => &[
"Avoid consecutive special characters",
"Don't start or end with special characters",
"Follow the expected key format",
],
Self::DomainValidation { .. } => &[
"Check domain-specific validation rules",
"Refer to domain documentation",
],
Self::Custom { .. } => &[
"Check application-specific validation rules",
"Contact system administrator if needed",
],
}
}
#[must_use]
pub const fn is_recoverable(&self) -> bool {
match self {
Self::Empty
| Self::InvalidCharacter { .. }
| Self::TooLong { .. }
| Self::TooShort { .. }
| Self::InvalidStructure { .. }
| Self::DomainValidation { .. } => true,
Self::Custom { .. } => false, }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCategory {
Length,
Character,
Structure,
Domain,
Custom,
}
impl ErrorCategory {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Length => "Length",
Self::Character => "Character",
Self::Structure => "Structure",
Self::Domain => "Domain",
Self::Custom => "Custom",
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::Length => "Errors related to key length (empty, too long, etc.)",
Self::Character => "Errors related to invalid characters in the key",
Self::Structure => "Errors related to key structure and formatting",
Self::Domain => "Errors from domain-specific validation rules",
Self::Custom => "Custom application-specific validation errors",
}
}
}
impl fmt::Display for ErrorCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name())
}
}
#[derive(Debug, Error, PartialEq, Eq, Clone)]
#[non_exhaustive]
pub enum IdParseError {
#[error("ID cannot be zero")]
Zero,
#[error("Invalid numeric ID: {0}")]
InvalidNumber(#[from] core::num::ParseIntError),
}
impl IdParseError {
#[must_use]
pub fn user_message(&self) -> &'static str {
match self {
Self::Zero => "Identifier cannot be zero",
Self::InvalidNumber(_) => "Value must be a positive integer",
}
}
}
#[cfg(feature = "uuid")]
#[derive(Debug, Error, Clone)]
#[non_exhaustive]
pub enum UuidParseError {
#[error("Invalid UUID: {0}")]
InvalidUuid(#[from] ::uuid::Error),
}
#[cfg(feature = "ulid")]
#[derive(Debug, Error, PartialEq, Eq, Clone)]
#[non_exhaustive]
pub enum UlidParseError {
#[error("invalid ULID prefix: expected `{expected_prefix}_...`")]
WrongPrefix {
expected_prefix: &'static str,
},
#[error("invalid ULID: {0}")]
InvalidUlid(::ulid::DecodeError),
}
#[derive(Debug)]
pub struct ErrorBuilder {
category: ErrorCategory,
code: Option<u32>,
domain: Option<&'static str>,
message: String,
context: Option<String>,
}
impl ErrorBuilder {
#[must_use]
pub fn new(category: ErrorCategory) -> Self {
Self {
category,
code: None,
domain: None,
message: String::new(),
context: None,
}
}
#[must_use]
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}
#[must_use]
pub fn code(mut self, code: u32) -> Self {
self.code = Some(code);
self
}
#[must_use]
pub fn domain(mut self, domain: &'static str) -> Self {
self.domain = Some(domain);
self
}
#[must_use]
pub fn context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
#[must_use]
pub fn build(self) -> KeyParseError {
let message = if let Some(context) = self.context {
format!("{} (Context: {})", self.message, context)
} else {
self.message
};
match self.category {
ErrorCategory::Custom => KeyParseError::custom(self.code.unwrap_or(0), message),
ErrorCategory::Domain => {
KeyParseError::domain_error(self.domain.unwrap_or("unknown"), message)
}
ErrorCategory::Structure => KeyParseError::custom(1004, message),
ErrorCategory::Length => KeyParseError::custom(1003, message),
ErrorCategory::Character => KeyParseError::custom(1002, message),
}
}
}
#[must_use]
pub fn format_user_error(error: &KeyParseError) -> String {
let mut output = format!("Error: {error}");
let suggestions = error.suggestions();
if !suggestions.is_empty() {
output.push_str("\n\nSuggestions:");
for suggestion in suggestions {
write!(output, "\n - {suggestion}").unwrap();
}
}
output
}
#[must_use]
pub fn format_debug_error(error: &KeyParseError) -> String {
format!(
"[{}:{}] {} (Category: {})",
error.code(),
error.category().name(),
error,
error.description()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "std"))]
use alloc::string::ToString;
#[test]
fn each_variant_has_unique_error_code() {
assert_eq!(KeyParseError::Empty.code(), 1001);
assert_eq!(
KeyParseError::InvalidCharacter {
character: 'x',
position: 0,
expected: None
}
.code(),
1002
);
assert_eq!(
KeyParseError::TooLong {
max_length: 10,
actual_length: 20
}
.code(),
1003
);
assert_eq!(
KeyParseError::InvalidStructure { reason: "test" }.code(),
1004
);
assert_eq!(
KeyParseError::TooShort {
min_length: 5,
actual_length: 2
}
.code(),
1005
);
assert_eq!(
KeyParseError::DomainValidation {
domain: "test",
message: "msg".to_string()
}
.code(),
2000
);
assert_eq!(
KeyParseError::Custom {
code: 42,
message: "msg".to_string()
}
.code(),
42
);
}
#[test]
fn variants_map_to_correct_category() {
assert_eq!(KeyParseError::Empty.category(), ErrorCategory::Length);
assert_eq!(
KeyParseError::InvalidCharacter {
character: 'x',
position: 0,
expected: None
}
.category(),
ErrorCategory::Character
);
assert_eq!(
KeyParseError::TooLong {
max_length: 10,
actual_length: 20
}
.category(),
ErrorCategory::Length
);
assert_eq!(
KeyParseError::InvalidStructure { reason: "test" }.category(),
ErrorCategory::Structure
);
assert_eq!(
KeyParseError::TooShort {
min_length: 5,
actual_length: 2
}
.category(),
ErrorCategory::Length
);
assert_eq!(
KeyParseError::DomainValidation {
domain: "test",
message: "msg".to_string()
}
.category(),
ErrorCategory::Domain
);
assert_eq!(
KeyParseError::Custom {
code: 42,
message: "msg".to_string()
}
.category(),
ErrorCategory::Custom
);
}
#[test]
fn empty_error_provides_recovery_suggestions() {
let error = KeyParseError::Empty;
let suggestions = error.suggestions();
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.contains("non-empty")));
}
#[test]
fn builder_produces_custom_error_with_code_and_context() {
let error = ErrorBuilder::new(ErrorCategory::Custom)
.message("Test error")
.code(1234)
.context("In test function")
.build();
assert_eq!(error.code(), 1234);
assert_eq!(error.category(), ErrorCategory::Custom);
}
#[test]
fn variants_carry_correct_payloads() {
let error1 = KeyParseError::InvalidCharacter {
character: '!',
position: 5,
expected: Some("alphanumeric"),
};
assert!(matches!(
error1,
KeyParseError::InvalidCharacter {
character: '!',
position: 5,
..
}
));
let error2 = KeyParseError::TooLong {
max_length: 32,
actual_length: 64,
};
assert!(matches!(
error2,
KeyParseError::TooLong {
max_length: 32,
actual_length: 64
}
));
let error3 = KeyParseError::InvalidStructure {
reason: "consecutive underscores",
};
assert!(matches!(
error3,
KeyParseError::InvalidStructure {
reason: "consecutive underscores"
}
));
let error4 = KeyParseError::domain_error("test", "Invalid format");
assert!(matches!(
error4,
KeyParseError::DomainValidation { domain: "test", .. }
));
}
#[test]
fn user_and_debug_formats_include_expected_sections() {
let error = KeyParseError::Empty;
let user_format = format_user_error(&error);
let debug_format = format_debug_error(&error);
assert!(user_format.contains("Error:"));
assert!(user_format.contains("Suggestions:"));
assert!(debug_format.contains("1001"));
assert!(debug_format.contains("Length"));
}
#[test]
fn recoverable_errors_distinguished_from_non_recoverable() {
assert!(KeyParseError::Empty.is_recoverable());
assert!(KeyParseError::InvalidCharacter {
character: 'x',
position: 0,
expected: None
}
.is_recoverable());
assert!(KeyParseError::TooShort {
min_length: 5,
actual_length: 2
}
.is_recoverable());
assert!(!KeyParseError::Custom {
code: 42,
message: "msg".to_string()
}
.is_recoverable());
}
#[test]
fn category_display_and_description_are_populated() {
assert_eq!(ErrorCategory::Length.to_string(), "Length");
assert_eq!(ErrorCategory::Character.name(), "Character");
assert!(ErrorCategory::Domain
.description()
.contains("domain-specific"));
}
}