use std::fmt;
use thiserror::Error;
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum CruxiError {
#[error("cruxi: unauthorized")]
Unauthorized,
}
#[derive(Debug, Clone)]
pub struct CodedError {
code: String,
details: Box<CodedErrorDetails>,
}
#[derive(Debug, Clone)]
struct CodedErrorDetails {
instance: Option<String>,
class: ErrorClass,
retryability: RetryabilityHint,
title: Option<String>,
user_message: Option<String>,
diagnostic_message: Option<String>,
source: Option<Box<CodedError>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorClass {
Validation,
Authentication,
Authorization,
NotFound,
Conflict,
RateLimited,
Timeout,
Unavailable,
Internal,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryabilityHint {
Never,
Always,
Maybe,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageExposure {
Safe,
Opaque,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ErrorMappingContext {
pub class: ErrorClass,
pub retryability: RetryabilityHint,
pub message_exposure: MessageExposure,
}
pub trait ErrorClassMapper {
type Decision;
fn map(&self, context: ErrorMappingContext) -> Self::Decision;
}
impl CodedError {
#[must_use]
pub fn new(code: impl Into<String>) -> Self {
Self {
code: code.into(),
details: Box::new(CodedErrorDetails {
instance: None,
class: ErrorClass::Unknown,
retryability: RetryabilityHint::Maybe,
title: None,
user_message: None,
diagnostic_message: None,
source: None,
}),
}
}
#[must_use]
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.details.instance = Some(instance.into());
self
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.details.title = Some(title.into());
self
}
#[must_use]
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.details.user_message = Some(reason.into());
self
}
#[must_use]
pub fn with_user_message(mut self, message: impl Into<String>) -> Self {
self.details.user_message = Some(message.into());
self
}
#[must_use]
pub fn with_diagnostic_message(mut self, message: impl Into<String>) -> Self {
self.details.diagnostic_message = Some(message.into());
self
}
#[must_use]
pub fn with_class(mut self, class: ErrorClass) -> Self {
self.details.class = class;
self
}
#[must_use]
pub fn with_retryability(mut self, retryability: RetryabilityHint) -> Self {
self.details.retryability = retryability;
self
}
#[must_use]
pub fn with_source(mut self, source: CodedError) -> Self {
self.details.source = Some(Box::new(source));
self
}
#[must_use]
pub fn code(&self) -> &str {
&self.code
}
#[must_use]
pub fn instance(&self) -> Option<&str> {
self.details.instance.as_deref()
}
#[must_use]
pub fn title(&self) -> Option<&str> {
self.details.title.as_deref()
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
self.details.user_message.as_deref()
}
#[must_use]
pub fn user_message(&self) -> Option<&str> {
self.details.user_message.as_deref()
}
#[must_use]
pub fn diagnostic_message(&self) -> Option<&str> {
self.details.diagnostic_message.as_deref()
}
#[must_use]
pub fn class(&self) -> ErrorClass {
self.details.class
}
#[must_use]
pub fn retryability(&self) -> RetryabilityHint {
self.details.retryability
}
#[must_use]
pub fn mapping_context(&self) -> ErrorMappingContext {
let message_exposure = match self.class() {
ErrorClass::Internal | ErrorClass::Unknown => MessageExposure::Opaque,
ErrorClass::Validation
| ErrorClass::Authentication
| ErrorClass::Authorization
| ErrorClass::NotFound
| ErrorClass::Conflict
| ErrorClass::RateLimited
| ErrorClass::Timeout
| ErrorClass::Unavailable => MessageExposure::Safe,
};
ErrorMappingContext {
class: self.class(),
retryability: self.retryability(),
message_exposure,
}
}
}
#[must_use]
pub fn map_coded_error<M>(error: &CodedError, mapper: &M) -> M::Decision
where
M: ErrorClassMapper + ?Sized,
{
mapper.map(error.mapping_context())
}
impl fmt::Display for CodedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (&self.details.user_message, &self.details.title) {
(Some(reason), _) => write!(f, "{reason}"),
(None, Some(title)) => write!(f, "{title}"),
(None, None) => write!(f, "cruxi: coded error [{}]", self.code),
}
}
}
impl std::error::Error for CodedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.details
.source
.as_ref()
.map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
}
}
#[derive(Debug, Clone, Error)]
#[error("cruxi: validation error: {field}: {message}")]
pub struct ValidationError {
field: String,
message: String,
}
impl ValidationError {
#[must_use]
pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
message: message.into(),
}
}
#[must_use]
pub fn field(&self) -> &str {
&self.field
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn coded_error_display_with_reason() {
let err = CodedError::new("TEST")
.with_reason("detailed reason")
.with_title("Title");
assert_eq!(err.to_string(), "detailed reason");
}
#[test]
fn coded_error_display_with_title_only() {
let err = CodedError::new("TEST").with_title("Title Only");
assert_eq!(err.to_string(), "Title Only");
}
#[test]
fn coded_error_display_fallback() {
let err = CodedError::new("TEST_CODE");
assert_eq!(err.to_string(), "cruxi: coded error [TEST_CODE]");
}
#[test]
fn coded_error_chain() {
let inner = CodedError::new("INNER").with_reason("inner cause");
let outer = CodedError::new("OUTER")
.with_reason("outer reason")
.with_source(inner);
assert!(outer.source().is_some());
}
#[test]
fn coded_error_defaults_class_and_retryability() {
let err = CodedError::new("TEST");
assert_eq!(err.class(), ErrorClass::Unknown);
assert_eq!(err.retryability(), RetryabilityHint::Maybe);
}
#[test]
fn coded_error_supports_class_and_retryability_overrides() {
let err = CodedError::new("TEST")
.with_class(ErrorClass::Validation)
.with_retryability(RetryabilityHint::Never);
assert_eq!(err.class(), ErrorClass::Validation);
assert_eq!(err.retryability(), RetryabilityHint::Never);
}
#[test]
fn coded_error_supports_user_and_diagnostic_channels() {
let err = CodedError::new("TEST")
.with_user_message("safe for caller")
.with_diagnostic_message("db pool timed out");
assert_eq!(err.user_message(), Some("safe for caller"));
assert_eq!(err.reason(), Some("safe for caller"));
assert_eq!(err.diagnostic_message(), Some("db pool timed out"));
}
#[test]
fn mapping_context_marks_internal_as_opaque() {
let err = CodedError::new("TEST")
.with_class(ErrorClass::Internal)
.with_retryability(RetryabilityHint::Never);
assert_eq!(
err.mapping_context(),
ErrorMappingContext {
class: ErrorClass::Internal,
retryability: RetryabilityHint::Never,
message_exposure: MessageExposure::Opaque,
}
);
}
#[test]
fn mapping_context_marks_validation_as_safe() {
let err = CodedError::new("TEST")
.with_class(ErrorClass::Validation)
.with_retryability(RetryabilityHint::Never);
assert_eq!(
err.mapping_context(),
ErrorMappingContext {
class: ErrorClass::Validation,
retryability: RetryabilityHint::Never,
message_exposure: MessageExposure::Safe,
}
);
}
#[test]
fn map_coded_error_uses_mapper_contract() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
Client,
Server,
}
struct DemoMapper;
impl ErrorClassMapper for DemoMapper {
type Decision = Decision;
fn map(&self, context: ErrorMappingContext) -> Self::Decision {
match context.class {
ErrorClass::Validation
| ErrorClass::Authentication
| ErrorClass::Authorization
| ErrorClass::NotFound
| ErrorClass::Conflict
| ErrorClass::RateLimited => Decision::Client,
ErrorClass::Timeout
| ErrorClass::Unavailable
| ErrorClass::Internal
| ErrorClass::Unknown => Decision::Server,
}
}
}
let mapper = DemoMapper;
let validation = CodedError::new("VALIDATION").with_class(ErrorClass::Validation);
let internal = CodedError::new("INTERNAL").with_class(ErrorClass::Internal);
assert_eq!(map_coded_error(&validation, &mapper), Decision::Client);
assert_eq!(map_coded_error(&internal, &mapper), Decision::Server);
}
#[test]
fn validation_error_display() {
let err = ValidationError::new("email", "required");
assert_eq!(err.to_string(), "cruxi: validation error: email: required");
}
}