Skip to main content

valence_core/
validation.rs

1//! Field validation framework
2//!
3//! Provides built-in validators and support for custom validation functions.
4//! Validators are defined in schema definitions and enforced in generated code.
5//!
6//! # Built-in Validators
7//!
8//! - `email` - Valid email address format
9//! - `phone` - Phone number (E.164 format)
10//! - `url` - Valid URL
11//! - `non_empty` - String is not empty
12//! - `min_length:N` - String has at least N characters
13//! - `max_length:N` - String has at most N characters
14//! - `min:N` - Number is at least N
15//! - `max:N` - Number is at most N
16//! - `range:MIN,MAX` - Number is between MIN and MAX (inclusive)
17//! - `positive` - Number is greater than 0
18//! - `non_negative` - Number is greater than or equal to 0
19//! - `enum:A,B,C` - Value is one of the listed options
20//! - `pattern:REGEX` - String matches regex pattern
21//!
22//! # Custom Validators
23//!
24//! Use `fn:function_name` to call a custom validation function:
25//!
26//! ```text
27//! // In schema:
28//! // validations = ["fn:validate_business_name"]
29//!
30//! // In code:
31//! fn validate_business_name(value: &str) -> Result<(), String> {
32//!     if value.len() < 3 {
33//!         return Err("Business name must be at least 3 characters".into());
34//!     }
35//!     Ok(())
36//! }
37//! ```
38//!
39//! # Inline Expressions
40//!
41//! Use `expr:EXPRESSION` for simple inline validations:
42//!
43//! ```text
44//! // In schema:
45//! // validations = ["expr:$value > 0"]
46//! ```
47
48use regex::Regex;
49use std::sync::OnceLock;
50
51/// Validation error
52#[derive(Debug, Clone)]
53pub struct ValidationError {
54    pub field: String,
55    pub message: String,
56}
57
58impl ValidationError {
59    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
60        Self {
61            field: field.into(),
62            message: message.into(),
63        }
64    }
65}
66
67impl std::fmt::Display for ValidationError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(
70            f,
71            "Validation error on field '{}': {}",
72            self.field, self.message
73        )
74    }
75}
76
77impl std::error::Error for ValidationError {}
78
79/// Email validation regex (simplified RFC 5322)
80fn email_regex() -> &'static Regex {
81    static EMAIL_REGEX: OnceLock<Regex> = OnceLock::new();
82    EMAIL_REGEX
83        .get_or_init(|| Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap())
84}
85
86/// URL validation regex (simplified)
87fn url_regex() -> &'static Regex {
88    static URL_REGEX: OnceLock<Regex> = OnceLock::new();
89    URL_REGEX.get_or_init(|| Regex::new(r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$").unwrap())
90}
91
92/// Phone validation regex (E.164 format: +[country code][number])
93fn phone_regex() -> &'static Regex {
94    static PHONE_REGEX: OnceLock<Regex> = OnceLock::new();
95    PHONE_REGEX.get_or_init(|| Regex::new(r"^\+[1-9]\d{1,14}$").unwrap())
96}
97
98/// Built-in validators for strings
99pub mod string {
100    use super::*;
101
102    pub fn email(value: &str) -> Result<(), String> {
103        if email_regex().is_match(value) {
104            Ok(())
105        } else {
106            Err("Invalid email format".into())
107        }
108    }
109
110    pub fn phone(value: &str) -> Result<(), String> {
111        if phone_regex().is_match(value) {
112            Ok(())
113        } else {
114            Err("Invalid phone number (use E.164 format: +1234567890)".into())
115        }
116    }
117
118    pub fn url(value: &str) -> Result<(), String> {
119        if url_regex().is_match(value) {
120            Ok(())
121        } else {
122            Err("Invalid URL format".into())
123        }
124    }
125
126    pub fn non_empty(value: &str) -> Result<(), String> {
127        if !value.is_empty() {
128            Ok(())
129        } else {
130            Err("Value cannot be empty".into())
131        }
132    }
133
134    pub fn min_length(value: &str, min: usize) -> Result<(), String> {
135        if value.len() >= min {
136            Ok(())
137        } else {
138            Err(format!("Value must be at least {min} characters"))
139        }
140    }
141
142    pub fn max_length(value: &str, max: usize) -> Result<(), String> {
143        if value.len() <= max {
144            Ok(())
145        } else {
146            Err(format!("Value must be at most {max} characters"))
147        }
148    }
149
150    pub fn pattern(value: &str, pattern: &str) -> Result<(), String> {
151        let regex = Regex::new(pattern).map_err(|e| format!("Invalid regex pattern: {e}"))?;
152
153        if regex.is_match(value) {
154            Ok(())
155        } else {
156            Err(format!("Value does not match pattern: {pattern}"))
157        }
158    }
159
160    pub fn enum_values(value: &str, allowed: &[&str]) -> Result<(), String> {
161        if allowed.contains(&value) {
162            Ok(())
163        } else {
164            Err(format!("Value must be one of: {}", allowed.join(", ")))
165        }
166    }
167}
168
169/// Built-in validators for integers
170pub mod integer {
171    pub fn min(value: i64, min: i64) -> Result<(), String> {
172        if value >= min {
173            Ok(())
174        } else {
175            Err(format!("Value must be at least {min}"))
176        }
177    }
178
179    pub fn max(value: i64, max: i64) -> Result<(), String> {
180        if value <= max {
181            Ok(())
182        } else {
183            Err(format!("Value must be at most {max}"))
184        }
185    }
186
187    pub fn range(value: i64, min: i64, max: i64) -> Result<(), String> {
188        if value >= min && value <= max {
189            Ok(())
190        } else {
191            Err(format!("Value must be between {min} and {max}"))
192        }
193    }
194
195    pub fn positive(value: i64) -> Result<(), String> {
196        if value > 0 {
197            Ok(())
198        } else {
199            Err("Value must be positive".into())
200        }
201    }
202
203    pub fn non_negative(value: i64) -> Result<(), String> {
204        if value >= 0 {
205            Ok(())
206        } else {
207            Err("Value must be non-negative".into())
208        }
209    }
210}
211
212/// Built-in validators for floats
213pub mod decimal {
214    pub fn min(value: f64, min: f64) -> Result<(), String> {
215        if value >= min {
216            Ok(())
217        } else {
218            Err(format!("Value must be at least {min}"))
219        }
220    }
221
222    pub fn max(value: f64, max: f64) -> Result<(), String> {
223        if value <= max {
224            Ok(())
225        } else {
226            Err(format!("Value must be at most {max}"))
227        }
228    }
229
230    pub fn range(value: f64, min: f64, max: f64) -> Result<(), String> {
231        if value >= min && value <= max {
232            Ok(())
233        } else {
234            Err(format!("Value must be between {min} and {max}"))
235        }
236    }
237
238    pub fn positive(value: f64) -> Result<(), String> {
239        if value > 0.0 {
240            Ok(())
241        } else {
242            Err("Value must be positive".into())
243        }
244    }
245
246    pub fn non_negative(value: f64) -> Result<(), String> {
247        if value >= 0.0 {
248            Ok(())
249        } else {
250            Err("Value must be non-negative".into())
251        }
252    }
253}
254
255fn on_validation_err(validator: &str) -> impl FnOnce(String) -> String {
256    let validator = validator.to_string();
257    move |e: String| {
258        crate::instrumentation::record_validation_failure("", "", &validator);
259        e
260    }
261}
262
263// Convenient top-level wrappers for common validations
264pub fn validate_email(value: &str) -> Result<(), String> {
265    string::email(value).map_err(on_validation_err("email"))
266}
267
268pub fn validate_phone(value: &str) -> Result<(), String> {
269    string::phone(value).map_err(on_validation_err("phone"))
270}
271
272pub fn validate_non_empty(value: &str) -> Result<(), String> {
273    string::non_empty(value).map_err(on_validation_err("non_empty"))
274}
275
276pub fn validate_min_length(value: &str, min: usize) -> Result<(), String> {
277    string::min_length(value, min).map_err(on_validation_err("min_length"))
278}
279
280pub fn validate_max_length(value: &str, max: usize) -> Result<(), String> {
281    string::max_length(value, max).map_err(on_validation_err("max_length"))
282}
283
284pub fn validate_enum(value: &str, allowed: &[&str]) -> Result<(), String> {
285    string::enum_values(value, allowed).map_err(on_validation_err("enum"))
286}
287
288pub fn validate_pattern(value: &str, pattern: &str) -> Result<(), String> {
289    string::pattern(value, pattern).map_err(on_validation_err("pattern"))
290}
291
292pub fn validate_non_negative(value: &i64) -> Result<(), String> {
293    integer::non_negative(*value).map_err(on_validation_err("non_negative"))
294}
295
296pub fn validate_positive(value: &i64) -> Result<(), String> {
297    integer::positive(*value).map_err(on_validation_err("positive"))
298}
299
300pub fn validate_min(value: &i64, min: i64) -> Result<(), String> {
301    integer::min(*value, min).map_err(on_validation_err("min"))
302}
303
304pub fn validate_max(value: &i64, max: i64) -> Result<(), String> {
305    integer::max(*value, max).map_err(on_validation_err("max"))
306}
307
308pub fn validate_range(value: &i64, min: i64, max: i64) -> Result<(), String> {
309    integer::range(*value, min, max).map_err(on_validation_err("range"))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_email_validator() {
318        assert!(string::email("test@example.com").is_ok());
319        assert!(string::email("user.name+tag@example.co.uk").is_ok());
320        assert!(string::email("invalid").is_err());
321        assert!(string::email("@example.com").is_err());
322        assert!(string::email("test@").is_err());
323    }
324
325    #[test]
326    fn test_phone_validator() {
327        assert!(string::phone("+12345678901").is_ok());
328        assert!(string::phone("+442071234567").is_ok());
329        assert!(string::phone("123456").is_err());
330        assert!(string::phone("+0123456").is_err()); // Invalid: starts with 0
331    }
332
333    #[test]
334    fn test_url_validator() {
335        assert!(string::url("https://example.com").is_ok());
336        assert!(string::url("http://example.com/path").is_ok());
337        assert!(string::url("example.com").is_err());
338        assert!(string::url("ftp://example.com").is_err());
339    }
340
341    #[test]
342    fn test_string_length() {
343        assert!(string::min_length("hello", 3).is_ok());
344        assert!(string::min_length("hi", 3).is_err());
345
346        assert!(string::max_length("hello", 10).is_ok());
347        assert!(string::max_length("hello world!", 5).is_err());
348    }
349
350    #[test]
351    fn test_integer_validators() {
352        assert!(integer::min(10, 5).is_ok());
353        assert!(integer::min(3, 5).is_err());
354
355        assert!(integer::max(10, 15).is_ok());
356        assert!(integer::max(20, 15).is_err());
357
358        assert!(integer::range(10, 5, 15).is_ok());
359        assert!(integer::range(3, 5, 15).is_err());
360
361        assert!(integer::positive(1).is_ok());
362        assert!(integer::positive(0).is_err());
363        assert!(integer::positive(-1).is_err());
364
365        assert!(integer::non_negative(0).is_ok());
366        assert!(integer::non_negative(1).is_ok());
367        assert!(integer::non_negative(-1).is_err());
368    }
369
370    #[test]
371    fn test_enum_validator() {
372        let allowed = &["active", "inactive", "pending"];
373
374        assert!(string::enum_values("active", allowed).is_ok());
375        assert!(string::enum_values("invalid", allowed).is_err());
376    }
377
378    #[test]
379    fn test_pattern_validator() {
380        assert!(string::pattern("ABC123", r"^[A-Z]{3}\d{3}$").is_ok());
381        assert!(string::pattern("abc123", r"^[A-Z]{3}\d{3}$").is_err());
382    }
383}