1use crate::errors::{CommerceError, Result};
36use rust_decimal::Decimal;
37
38pub trait Validate {
47 fn validate(&self) -> Result<()>;
49
50 fn validated(self) -> Result<Self>
52 where
53 Self: Sized,
54 {
55 self.validate()?;
56 Ok(self)
57 }
58
59 fn is_valid(&self) -> bool {
61 self.validate().is_ok()
62 }
63}
64
65#[derive(Debug, Default)]
85#[must_use]
86pub struct ValidationBuilder {
87 errors: Vec<(String, String)>,
88}
89
90impl ValidationBuilder {
91 pub const fn new() -> Self {
93 Self { errors: Vec::new() }
94 }
95
96 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 pub fn required(self, field: &str, value: &str) -> Self {
106 self.check(field, !value.trim().is_empty(), "cannot be empty")
107 }
108
109 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 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 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 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 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 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 pub fn positive(self, field: &str, value: Decimal) -> Self {
159 self.check(field, value > Decimal::ZERO, "must be positive")
160 }
161
162 pub fn non_negative(self, field: &str, value: Decimal) -> Self {
164 self.check(field, value >= Decimal::ZERO, "cannot be negative")
165 }
166
167 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 pub fn positive_i32(self, field: &str, value: i32) -> Self {
174 self.check(field, value > 0, "must be positive")
175 }
176
177 pub fn non_negative_i32(self, field: &str, value: i32) -> Self {
179 self.check(field, value >= 0, "cannot be negative")
180 }
181
182 pub fn positive_i64(self, field: &str, value: i64) -> Self {
184 self.check(field, value > 0, "must be positive")
185 }
186
187 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 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 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 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 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 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 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 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 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 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#[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 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 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}