use rust_decimal::Decimal;
use thiserror::Error;
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum PositiveError {
#[error("invalid positive value '{value}': {reason}")]
InvalidValue {
value: String,
reason: String,
},
#[error("arithmetic error during {operation}: {reason}")]
ArithmeticError {
operation: String,
reason: String,
},
#[error("failed to convert from {from_type} to {to_type}: {reason}")]
ConversionError {
from_type: String,
to_type: String,
reason: String,
},
#[error("value {value} is out of bounds (min: {min}, max: {max})")]
OutOfBounds {
value: Decimal,
min: Decimal,
max: Decimal,
},
#[error("invalid precision {precision}: {reason}")]
InvalidPrecision {
precision: u32,
reason: String,
},
}
pub type PositiveResult<T> = Result<T, PositiveError>;
impl PositiveError {
#[cold]
#[inline(never)]
#[must_use]
pub fn invalid_value(value: &str, reason: &str) -> Self {
PositiveError::InvalidValue {
value: value.to_string(),
reason: reason.to_string(),
}
}
#[cold]
#[inline(never)]
#[must_use]
pub fn arithmetic_error(operation: &str, reason: &str) -> Self {
PositiveError::ArithmeticError {
operation: operation.to_string(),
reason: reason.to_string(),
}
}
#[cold]
#[inline(never)]
#[must_use]
pub fn conversion_error(from_type: &str, to_type: &str, reason: &str) -> Self {
PositiveError::ConversionError {
from_type: from_type.to_string(),
to_type: to_type.to_string(),
reason: reason.to_string(),
}
}
#[cold]
#[inline(never)]
#[must_use]
pub fn out_of_bounds(value: Decimal, min: Decimal, max: Decimal) -> Self {
PositiveError::OutOfBounds { value, min, max }
}
#[cold]
#[inline(never)]
#[must_use]
pub fn invalid_precision(precision: u32, reason: &str) -> Self {
PositiveError::InvalidPrecision {
precision,
reason: reason.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_invalid_value_error() {
let error = PositiveError::invalid_value("-1", "value cannot be negative");
assert!(matches!(error, PositiveError::InvalidValue { .. }));
assert_eq!(
error.to_string(),
"invalid positive value '-1': value cannot be negative"
);
}
#[test]
fn test_arithmetic_error() {
let error = PositiveError::arithmetic_error("subtraction", "result would be negative");
assert!(matches!(error, PositiveError::ArithmeticError { .. }));
assert_eq!(
error.to_string(),
"arithmetic error during subtraction: result would be negative"
);
}
#[test]
fn test_conversion_error() {
let error = PositiveError::conversion_error("f64", "Positive", "value out of range");
assert!(matches!(error, PositiveError::ConversionError { .. }));
assert_eq!(
error.to_string(),
"failed to convert from f64 to Positive: value out of range"
);
}
#[test]
fn test_out_of_bounds_error_keeps_exact_decimal() {
let value = dec!(-0.0000000000000000000000000001);
let error = PositiveError::out_of_bounds(value, Decimal::ZERO, Decimal::MAX);
match &error {
PositiveError::OutOfBounds { value: v, min, max } => {
assert_eq!(*v, value);
assert_eq!(*min, Decimal::ZERO);
assert_eq!(*max, Decimal::MAX);
}
other => panic!("expected OutOfBounds, got {other:?}"),
}
assert!(
error
.to_string()
.starts_with("value -0.0000000000000000000000000001 is out of bounds")
);
}
#[test]
fn test_invalid_precision_error() {
let error = PositiveError::invalid_precision(29, "decimal supports at most 28 places");
assert!(matches!(error, PositiveError::InvalidPrecision { .. }));
assert_eq!(
error.to_string(),
"invalid precision 29: decimal supports at most 28 places"
);
}
#[test]
fn test_all_messages_start_lowercase() {
let errors = [
PositiveError::invalid_value("NaN", "not a number"),
PositiveError::arithmetic_error("division", "division by zero"),
PositiveError::conversion_error("f64", "Positive", "out of range"),
PositiveError::out_of_bounds(dec!(-1), Decimal::ZERO, Decimal::MAX),
PositiveError::invalid_precision(29, "too large"),
];
for error in &errors {
let rendered = error.to_string();
let first = rendered
.chars()
.next()
.expect("error message must not be empty");
assert!(
first.is_lowercase(),
"message does not start lowercase: {rendered}"
);
}
}
#[test]
fn test_error_is_clone_and_eq() {
let error = PositiveError::arithmetic_error("division", "division by zero");
assert_eq!(error.clone(), error);
assert_ne!(
error,
PositiveError::arithmetic_error("division", "overflow")
);
}
}