Skip to main content

stateset_core/
validation.rs

1//! Validation traits and utilities for domain models
2//!
3//! This module provides a trait-based approach to validation that domain models
4//! can implement to ensure data integrity before persistence.
5//!
6//! # Example
7//!
8//! ```rust
9//! use stateset_core::{Validate, Result, CommerceError};
10//!
11//! struct MyModel {
12//!     email: String,
13//!     quantity: i32,
14//! }
15//!
16//! impl Validate for MyModel {
17//!     fn validate(&self) -> Result<()> {
18//!         if self.email.is_empty() {
19//!             return Err(CommerceError::InvalidInput {
20//!                 field: "email".to_string(),
21//!                 message: "cannot be empty".to_string(),
22//!             });
23//!         }
24//!         if self.quantity < 0 {
25//!             return Err(CommerceError::InvalidInput {
26//!                 field: "quantity".to_string(),
27//!                 message: "cannot be negative".to_string(),
28//!             });
29//!         }
30//!         Ok(())
31//!     }
32//! }
33//! ```
34
35use crate::errors::{CommerceError, Result};
36use rust_decimal::Decimal;
37
38// ============================================================================
39// Validate Trait
40// ============================================================================
41
42/// Trait for validating domain models before persistence
43///
44/// Implement this trait to add validation logic to your domain models.
45/// The `validate()` method will be called before create/update operations.
46pub trait Validate {
47    /// Validate the model and return an error if validation fails
48    fn validate(&self) -> Result<()>;
49
50    /// Validate and return self if valid (for method chaining)
51    fn validated(self) -> Result<Self>
52    where
53        Self: Sized,
54    {
55        self.validate()?;
56        Ok(self)
57    }
58
59    /// Check if the model is valid without returning an error
60    fn is_valid(&self) -> bool {
61        self.validate().is_ok()
62    }
63}
64
65// ============================================================================
66// Validation Builder
67// ============================================================================
68
69/// A builder for composing multiple validations
70///
71/// # Example
72///
73/// ```rust
74/// use stateset_core::ValidationBuilder;
75///
76/// let result = ValidationBuilder::new()
77///     .required("email", "alice@example.com")
78///     .email("email", "alice@example.com")
79///     .max_length("name", "Alice", 100)
80///     .build();
81///
82/// assert!(result.is_ok());
83/// ```
84#[derive(Debug, Default)]
85#[must_use]
86pub struct ValidationBuilder {
87    errors: Vec<(String, String)>,
88}
89
90impl ValidationBuilder {
91    /// Create a new validation builder
92    pub const fn new() -> Self {
93        Self { errors: Vec::new() }
94    }
95
96    /// Add an error if the condition is false
97    pub fn check(mut self, field: &str, condition: bool, message: &str) -> Self {
98        if !condition {
99            self.errors.push((field.to_string(), message.to_string()));
100        }
101        self
102    }
103
104    /// Validate that a string field is not empty
105    pub fn required(self, field: &str, value: &str) -> Self {
106        self.check(field, !value.trim().is_empty(), "cannot be empty")
107    }
108
109    /// Validate that an optional string field is not empty if present
110    pub fn required_if_present(self, field: &str, value: Option<&str>) -> Self {
111        match value {
112            Some(v) => self.check(field, !v.trim().is_empty(), "cannot be empty if provided"),
113            None => self,
114        }
115    }
116
117    /// Validate email format
118    pub fn email(self, field: &str, value: &str) -> Self {
119        self.check(
120            field,
121            crate::errors::validate_email(value).is_ok(),
122            "must be a valid email address",
123        )
124    }
125
126    /// Validate optional email format
127    pub fn email_if_present(self, field: &str, value: Option<&str>) -> Self {
128        match value {
129            Some(v) if !v.is_empty() => self.email(field, v),
130            _ => self,
131        }
132    }
133
134    /// Validate string maximum length
135    pub fn max_length(self, field: &str, value: &str, max: usize) -> Self {
136        self.check(
137            field,
138            value.trim().chars().count() <= max,
139            &format!("cannot exceed {max} characters"),
140        )
141    }
142
143    /// Validate string minimum length
144    pub fn min_length(self, field: &str, value: &str, min: usize) -> Self {
145        self.check(
146            field,
147            value.trim().chars().count() >= min,
148            &format!("must be at least {min} characters"),
149        )
150    }
151
152    /// Validate string length range
153    pub fn length_range(self, field: &str, value: &str, min: usize, max: usize) -> Self {
154        self.min_length(field, value, min).max_length(field, value, max)
155    }
156
157    /// Validate a positive decimal value (> 0)
158    pub fn positive(self, field: &str, value: Decimal) -> Self {
159        self.check(field, value > Decimal::ZERO, "must be positive")
160    }
161
162    /// Validate a non-negative decimal value (>= 0)
163    pub fn non_negative(self, field: &str, value: Decimal) -> Self {
164        self.check(field, value >= Decimal::ZERO, "cannot be negative")
165    }
166
167    /// Validate a decimal value is within range
168    pub fn range(self, field: &str, value: Decimal, min: Decimal, max: Decimal) -> Self {
169        self.check(field, value >= min && value <= max, &format!("must be between {min} and {max}"))
170    }
171
172    /// Validate a positive integer value (> 0)
173    pub fn positive_i32(self, field: &str, value: i32) -> Self {
174        self.check(field, value > 0, "must be positive")
175    }
176
177    /// Validate a non-negative integer value (>= 0)
178    pub fn non_negative_i32(self, field: &str, value: i32) -> Self {
179        self.check(field, value >= 0, "cannot be negative")
180    }
181
182    /// Validate a positive integer value (> 0)
183    pub fn positive_i64(self, field: &str, value: i64) -> Self {
184        self.check(field, value > 0, "must be positive")
185    }
186
187    /// Validate a UUID is not nil
188    pub fn uuid_not_nil(self, field: &str, value: uuid::Uuid) -> Self {
189        self.check(field, !value.is_nil(), "cannot be nil")
190    }
191
192    /// Validate a list is not empty
193    pub fn non_empty_list<T>(self, field: &str, value: &[T]) -> Self {
194        self.check(field, !value.is_empty(), "cannot be empty")
195    }
196
197    /// Validate a list has at most N items
198    pub fn max_items<T>(self, field: &str, value: &[T], max: usize) -> Self {
199        self.check(field, value.len() <= max, &format!("cannot have more than {max} items"))
200    }
201
202    /// Validate a SKU format (alphanumeric, hyphens, underscores)
203    pub fn sku(self, field: &str, value: &str) -> Self {
204        let value = value.trim();
205        let is_valid = !value.is_empty()
206            && value.chars().count() <= 100
207            && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
208        self.check(field, is_valid, "must be a valid SKU (alphanumeric, hyphens, underscores)")
209    }
210
211    /// Validate a currency code (3 uppercase letters)
212    pub fn currency_code(self, field: &str, value: &str) -> Self {
213        let value = value.trim();
214        let is_valid = value.len() == 3
215            && value.chars().all(|c| c.is_ascii_uppercase() && c.is_ascii_alphabetic());
216        self.check(field, is_valid, "must be a 3-letter uppercase currency code")
217    }
218
219    /// Validate a phone number (basic validation)
220    pub fn phone(self, field: &str, value: &str) -> Self {
221        let value = value.trim();
222        if value.is_empty() {
223            self.check(field, false, "must have 7-15 digits")
224        } else {
225            let invalid_char = value.chars().any(|ch| {
226                !(ch.is_ascii_digit()
227                    || ch.is_ascii_whitespace()
228                    || matches!(ch, '+' | '-' | '(' | ')' | '.'))
229            });
230            let digit_count = value.chars().filter(char::is_ascii_digit).count();
231            self.check(
232                field,
233                !invalid_char && (7..=15).contains(&digit_count),
234                "must have 7-15 digits",
235            )
236        }
237    }
238
239    /// Validate a postal code (basic validation)
240    pub fn postal_code(self, field: &str, value: &str) -> Self {
241        let value = value.trim();
242        let is_valid = value.chars().count() >= 3
243            && value.chars().count() <= 10
244            && value.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-');
245        self.check(field, is_valid, "must be a valid postal code")
246    }
247
248    /// Validate using a custom predicate
249    pub fn custom<F>(self, field: &str, predicate: F, message: &str) -> Self
250    where
251        F: FnOnce() -> bool,
252    {
253        self.check(field, predicate(), message)
254    }
255
256    /// Build the validation result
257    ///
258    /// Returns Ok(()) if all validations passed, or the first error if any failed
259    pub fn build(self) -> Result<()> {
260        if let Some((field, message)) = self.errors.into_iter().next() {
261            Err(CommerceError::InvalidInput { field, message })
262        } else {
263            Ok(())
264        }
265    }
266
267    /// Build the validation result returning all errors
268    ///
269    /// Returns Ok(()) if all validations passed, or a validation error with all messages
270    pub fn build_all(self) -> Result<()> {
271        if self.errors.is_empty() {
272            Ok(())
273        } else {
274            let messages: Vec<String> =
275                self.errors.iter().map(|(field, msg)| format!("{field}: {msg}")).collect();
276            Err(CommerceError::ValidationError(messages.join("; ")))
277        }
278    }
279}
280
281// ============================================================================
282// Tests
283// ============================================================================
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use rust_decimal_macros::dec;
289
290    #[test]
291    fn test_validation_builder_success() {
292        let result = ValidationBuilder::new()
293            .required("name", "Alice")
294            .email("email", "alice@example.com")
295            .positive("price", dec!(10.00))
296            .build();
297
298        assert!(result.is_ok());
299    }
300
301    #[test]
302    fn test_validation_builder_required_fails() {
303        let result = ValidationBuilder::new().required("name", "").build();
304
305        assert!(result.is_err());
306        if let Err(CommerceError::InvalidInput { field, .. }) = result {
307            assert_eq!(field, "name");
308        } else {
309            panic!("Expected InvalidInput error");
310        }
311    }
312
313    #[test]
314    fn test_validation_builder_required_trims_whitespace() {
315        assert!(ValidationBuilder::new().required("name", "  ").build().is_err());
316        assert!(ValidationBuilder::new().required("name", "  Bob  ").build().is_ok());
317    }
318
319    #[test]
320    fn test_validation_builder_email_fails() {
321        let result = ValidationBuilder::new().email("email", "not-an-email").build();
322
323        assert!(result.is_err());
324    }
325
326    #[test]
327    fn test_validation_builder_email_rejects_whitespace_and_missing_parts() {
328        assert!(ValidationBuilder::new().email("email", "alice @example.com").build().is_err());
329        assert!(ValidationBuilder::new().email("email", "@example.com").build().is_err());
330        assert!(ValidationBuilder::new().email("email", "alice@").build().is_err());
331        assert!(ValidationBuilder::new().email("email", "alice@example").build().is_err());
332    }
333
334    #[test]
335    fn test_validation_builder_email_rejects_invalid_labels_and_dots() {
336        assert!(ValidationBuilder::new().email("email", "alice..bob@example.com").build().is_err());
337        assert!(ValidationBuilder::new().email("email", "alice@-example.com").build().is_err());
338        assert!(ValidationBuilder::new().email("email", "alice@example..com").build().is_err());
339    }
340
341    #[test]
342    fn test_validation_builder_positive_fails() {
343        let result = ValidationBuilder::new().positive("price", dec!(-5.00)).build();
344
345        assert!(result.is_err());
346        if let Err(CommerceError::InvalidInput { field, .. }) = result {
347            assert_eq!(field, "price");
348        } else {
349            panic!("Expected InvalidInput error");
350        }
351    }
352
353    #[test]
354    fn test_validation_builder_max_length() {
355        let result = ValidationBuilder::new().max_length("code", "ABC123", 3).build();
356
357        assert!(result.is_err());
358    }
359
360    #[test]
361    fn test_validation_builder_trimmed_lengths() {
362        assert!(ValidationBuilder::new().min_length("name", "  abc  ", 3).build().is_ok());
363        assert!(ValidationBuilder::new().max_length("name", "  abcd  ", 3).build().is_err());
364    }
365
366    #[test]
367    fn test_validation_builder_sku() {
368        // Valid SKUs
369        assert!(ValidationBuilder::new().sku("sku", "SKU-001").build().is_ok());
370        assert!(ValidationBuilder::new().sku("sku", "WIDGET_BLUE_XL").build().is_ok());
371        assert!(ValidationBuilder::new().sku("sku", " sku-001 ").build().is_ok());
372
373        // Invalid SKUs
374        assert!(ValidationBuilder::new().sku("sku", "").build().is_err());
375        assert!(ValidationBuilder::new().sku("sku", "SKU 001").build().is_err());
376        assert!(ValidationBuilder::new().sku("sku", "s-kü").build().is_err());
377    }
378
379    #[test]
380    fn test_validation_builder_currency_code_trimming() {
381        assert!(ValidationBuilder::new().currency_code("currency", " usd ").build().is_err());
382        assert!(ValidationBuilder::new().currency_code("currency", "USD").build().is_ok());
383        assert!(ValidationBuilder::new().currency_code("currency", " USD ").build().is_ok());
384    }
385
386    #[test]
387    fn test_validation_builder_build_all() {
388        let result = ValidationBuilder::new()
389            .required("name", "")
390            .email("email", "bad")
391            .positive("price", dec!(-1))
392            .build_all();
393
394        assert!(result.is_err());
395        if let Err(CommerceError::ValidationError(msg)) = result {
396            assert!(msg.contains("name:"));
397            assert!(msg.contains("email:"));
398            assert!(msg.contains("price:"));
399        } else {
400            panic!("Expected ValidationError");
401        }
402    }
403
404    #[test]
405    fn test_validation_builder_phone_rejects_invalid_characters() {
406        assert!(ValidationBuilder::new().phone("phone", "123-456-ABCD").build().is_err());
407        assert!(ValidationBuilder::new().phone("phone", "+1 (415) 555-2671").build().is_ok());
408    }
409
410    #[test]
411    fn test_validation_builder_postal_code_validates_ascii_only() {
412        assert!(ValidationBuilder::new().postal_code("postal", "12AB-34").build().is_ok());
413        assert!(ValidationBuilder::new().postal_code("postal", "123-45").build().is_err());
414    }
415
416    struct TestModel {
417        name: String,
418        price: Decimal,
419    }
420
421    impl Validate for TestModel {
422        fn validate(&self) -> Result<()> {
423            ValidationBuilder::new()
424                .required("name", &self.name)
425                .positive("price", self.price)
426                .build()
427        }
428    }
429
430    #[test]
431    fn test_validate_trait() {
432        let valid = TestModel { name: "Widget".to_string(), price: dec!(10.00) };
433        assert!(valid.validate().is_ok());
434        assert!(valid.is_valid());
435
436        let invalid = TestModel { name: String::new(), price: dec!(10.00) };
437        assert!(invalid.validate().is_err());
438        assert!(!invalid.is_valid());
439    }
440
441    #[test]
442    fn test_validated_method() {
443        let model = TestModel { name: "Widget".to_string(), price: dec!(10.00) };
444
445        let result = model.validated();
446        assert!(result.is_ok());
447        assert_eq!(result.unwrap().name, "Widget");
448    }
449}