use crate::errors::{CommerceError, Result};
use rust_decimal::Decimal;
pub trait Validate {
fn validate(&self) -> Result<()>;
fn validated(self) -> Result<Self>
where
Self: Sized,
{
self.validate()?;
Ok(self)
}
fn is_valid(&self) -> bool {
self.validate().is_ok()
}
}
#[derive(Debug, Default)]
#[must_use]
pub struct ValidationBuilder {
errors: Vec<(String, String)>,
}
impl ValidationBuilder {
pub const fn new() -> Self {
Self { errors: Vec::new() }
}
pub fn check(mut self, field: &str, condition: bool, message: &str) -> Self {
if !condition {
self.errors.push((field.to_string(), message.to_string()));
}
self
}
pub fn required(self, field: &str, value: &str) -> Self {
self.check(field, !value.trim().is_empty(), "cannot be empty")
}
pub fn required_if_present(self, field: &str, value: Option<&str>) -> Self {
match value {
Some(v) => self.check(field, !v.trim().is_empty(), "cannot be empty if provided"),
None => self,
}
}
pub fn email(self, field: &str, value: &str) -> Self {
self.check(
field,
crate::errors::validate_email(value).is_ok(),
"must be a valid email address",
)
}
pub fn email_if_present(self, field: &str, value: Option<&str>) -> Self {
match value {
Some(v) if !v.is_empty() => self.email(field, v),
_ => self,
}
}
pub fn max_length(self, field: &str, value: &str, max: usize) -> Self {
self.check(
field,
value.trim().chars().count() <= max,
&format!("cannot exceed {max} characters"),
)
}
pub fn min_length(self, field: &str, value: &str, min: usize) -> Self {
self.check(
field,
value.trim().chars().count() >= min,
&format!("must be at least {min} characters"),
)
}
pub fn length_range(self, field: &str, value: &str, min: usize, max: usize) -> Self {
self.min_length(field, value, min).max_length(field, value, max)
}
pub fn positive(self, field: &str, value: Decimal) -> Self {
self.check(field, value > Decimal::ZERO, "must be positive")
}
pub fn non_negative(self, field: &str, value: Decimal) -> Self {
self.check(field, value >= Decimal::ZERO, "cannot be negative")
}
pub fn range(self, field: &str, value: Decimal, min: Decimal, max: Decimal) -> Self {
self.check(field, value >= min && value <= max, &format!("must be between {min} and {max}"))
}
pub fn positive_i32(self, field: &str, value: i32) -> Self {
self.check(field, value > 0, "must be positive")
}
pub fn non_negative_i32(self, field: &str, value: i32) -> Self {
self.check(field, value >= 0, "cannot be negative")
}
pub fn positive_i64(self, field: &str, value: i64) -> Self {
self.check(field, value > 0, "must be positive")
}
pub fn uuid_not_nil(self, field: &str, value: uuid::Uuid) -> Self {
self.check(field, !value.is_nil(), "cannot be nil")
}
pub fn non_empty_list<T>(self, field: &str, value: &[T]) -> Self {
self.check(field, !value.is_empty(), "cannot be empty")
}
pub fn max_items<T>(self, field: &str, value: &[T], max: usize) -> Self {
self.check(field, value.len() <= max, &format!("cannot have more than {max} items"))
}
pub fn sku(self, field: &str, value: &str) -> Self {
let value = value.trim();
let is_valid = !value.is_empty()
&& value.chars().count() <= 100
&& value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
self.check(field, is_valid, "must be a valid SKU (alphanumeric, hyphens, underscores)")
}
pub fn currency_code(self, field: &str, value: &str) -> Self {
let value = value.trim();
let is_valid = value.len() == 3
&& value.chars().all(|c| c.is_ascii_uppercase() && c.is_ascii_alphabetic());
self.check(field, is_valid, "must be a 3-letter uppercase currency code")
}
pub fn phone(self, field: &str, value: &str) -> Self {
let value = value.trim();
if value.is_empty() {
self.check(field, false, "must have 7-15 digits")
} else {
let invalid_char = value.chars().any(|ch| {
!(ch.is_ascii_digit()
|| ch.is_ascii_whitespace()
|| matches!(ch, '+' | '-' | '(' | ')' | '.'))
});
let digit_count = value.chars().filter(char::is_ascii_digit).count();
self.check(
field,
!invalid_char && (7..=15).contains(&digit_count),
"must have 7-15 digits",
)
}
}
pub fn postal_code(self, field: &str, value: &str) -> Self {
let value = value.trim();
let is_valid = value.chars().count() >= 3
&& value.chars().count() <= 10
&& value.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-');
self.check(field, is_valid, "must be a valid postal code")
}
pub fn custom<F>(self, field: &str, predicate: F, message: &str) -> Self
where
F: FnOnce() -> bool,
{
self.check(field, predicate(), message)
}
pub fn build(self) -> Result<()> {
if let Some((field, message)) = self.errors.into_iter().next() {
Err(CommerceError::InvalidInput { field, message })
} else {
Ok(())
}
}
pub fn build_all(self) -> Result<()> {
if self.errors.is_empty() {
Ok(())
} else {
let messages: Vec<String> =
self.errors.iter().map(|(field, msg)| format!("{field}: {msg}")).collect();
Err(CommerceError::ValidationError(messages.join("; ")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_validation_builder_success() {
let result = ValidationBuilder::new()
.required("name", "Alice")
.email("email", "alice@example.com")
.positive("price", dec!(10.00))
.build();
assert!(result.is_ok());
}
#[test]
fn test_validation_builder_required_fails() {
let result = ValidationBuilder::new().required("name", "").build();
assert!(result.is_err());
if let Err(CommerceError::InvalidInput { field, .. }) = result {
assert_eq!(field, "name");
} else {
panic!("Expected InvalidInput error");
}
}
#[test]
fn test_validation_builder_required_trims_whitespace() {
assert!(ValidationBuilder::new().required("name", " ").build().is_err());
assert!(ValidationBuilder::new().required("name", " Bob ").build().is_ok());
}
#[test]
fn test_validation_builder_email_fails() {
let result = ValidationBuilder::new().email("email", "not-an-email").build();
assert!(result.is_err());
}
#[test]
fn test_validation_builder_email_rejects_whitespace_and_missing_parts() {
assert!(ValidationBuilder::new().email("email", "alice @example.com").build().is_err());
assert!(ValidationBuilder::new().email("email", "@example.com").build().is_err());
assert!(ValidationBuilder::new().email("email", "alice@").build().is_err());
assert!(ValidationBuilder::new().email("email", "alice@example").build().is_err());
}
#[test]
fn test_validation_builder_email_rejects_invalid_labels_and_dots() {
assert!(ValidationBuilder::new().email("email", "alice..bob@example.com").build().is_err());
assert!(ValidationBuilder::new().email("email", "alice@-example.com").build().is_err());
assert!(ValidationBuilder::new().email("email", "alice@example..com").build().is_err());
}
#[test]
fn test_validation_builder_positive_fails() {
let result = ValidationBuilder::new().positive("price", dec!(-5.00)).build();
assert!(result.is_err());
if let Err(CommerceError::InvalidInput { field, .. }) = result {
assert_eq!(field, "price");
} else {
panic!("Expected InvalidInput error");
}
}
#[test]
fn test_validation_builder_max_length() {
let result = ValidationBuilder::new().max_length("code", "ABC123", 3).build();
assert!(result.is_err());
}
#[test]
fn test_validation_builder_trimmed_lengths() {
assert!(ValidationBuilder::new().min_length("name", " abc ", 3).build().is_ok());
assert!(ValidationBuilder::new().max_length("name", " abcd ", 3).build().is_err());
}
#[test]
fn test_validation_builder_sku() {
assert!(ValidationBuilder::new().sku("sku", "SKU-001").build().is_ok());
assert!(ValidationBuilder::new().sku("sku", "WIDGET_BLUE_XL").build().is_ok());
assert!(ValidationBuilder::new().sku("sku", " sku-001 ").build().is_ok());
assert!(ValidationBuilder::new().sku("sku", "").build().is_err());
assert!(ValidationBuilder::new().sku("sku", "SKU 001").build().is_err());
assert!(ValidationBuilder::new().sku("sku", "s-kü").build().is_err());
}
#[test]
fn test_validation_builder_currency_code_trimming() {
assert!(ValidationBuilder::new().currency_code("currency", " usd ").build().is_err());
assert!(ValidationBuilder::new().currency_code("currency", "USD").build().is_ok());
assert!(ValidationBuilder::new().currency_code("currency", " USD ").build().is_ok());
}
#[test]
fn test_validation_builder_build_all() {
let result = ValidationBuilder::new()
.required("name", "")
.email("email", "bad")
.positive("price", dec!(-1))
.build_all();
assert!(result.is_err());
if let Err(CommerceError::ValidationError(msg)) = result {
assert!(msg.contains("name:"));
assert!(msg.contains("email:"));
assert!(msg.contains("price:"));
} else {
panic!("Expected ValidationError");
}
}
#[test]
fn test_validation_builder_phone_rejects_invalid_characters() {
assert!(ValidationBuilder::new().phone("phone", "123-456-ABCD").build().is_err());
assert!(ValidationBuilder::new().phone("phone", "+1 (415) 555-2671").build().is_ok());
}
#[test]
fn test_validation_builder_postal_code_validates_ascii_only() {
assert!(ValidationBuilder::new().postal_code("postal", "12AB-34").build().is_ok());
assert!(ValidationBuilder::new().postal_code("postal", "123-45").build().is_err());
}
struct TestModel {
name: String,
price: Decimal,
}
impl Validate for TestModel {
fn validate(&self) -> Result<()> {
ValidationBuilder::new()
.required("name", &self.name)
.positive("price", self.price)
.build()
}
}
#[test]
fn test_validate_trait() {
let valid = TestModel { name: "Widget".to_string(), price: dec!(10.00) };
assert!(valid.validate().is_ok());
assert!(valid.is_valid());
let invalid = TestModel { name: String::new(), price: dec!(10.00) };
assert!(invalid.validate().is_err());
assert!(!invalid.is_valid());
}
#[test]
fn test_validated_method() {
let model = TestModel { name: "Widget".to_string(), price: dec!(10.00) };
let result = model.validated();
assert!(result.is_ok());
assert_eq!(result.unwrap().name, "Widget");
}
}