Skip to main content

rosace_forms/
validator.rs

1/// A validation rule that can be applied to a string value.
2pub trait Validator: Send + Sync + 'static {
3    /// Returns `None` if valid, or an error message string if invalid.
4    fn validate(&self, value: &str) -> Option<String>;
5
6    /// Human-readable name for this rule (used in error messages).
7    fn name(&self) -> &'static str;
8}
9
10/// Field value must be non-empty (after trimming).
11pub struct Required;
12impl Validator for Required {
13    fn validate(&self, value: &str) -> Option<String> {
14        if value.trim().is_empty() { Some("This field is required.".into()) } else { None }
15    }
16    fn name(&self) -> &'static str { "Required" }
17}
18
19/// Minimum character length.
20pub struct MinLength(pub usize);
21impl Validator for MinLength {
22    fn validate(&self, value: &str) -> Option<String> {
23        if value.len() < self.0 { Some(format!("Must be at least {} characters.", self.0)) } else { None }
24    }
25    fn name(&self) -> &'static str { "MinLength" }
26}
27
28/// Maximum character length.
29pub struct MaxLength(pub usize);
30impl Validator for MaxLength {
31    fn validate(&self, value: &str) -> Option<String> {
32        if value.len() > self.0 { Some(format!("Must be no more than {} characters.", self.0)) } else { None }
33    }
34    fn name(&self) -> &'static str { "MaxLength" }
35}
36
37/// Simple pattern check (contains substring — no regex dep needed).
38pub struct Contains(pub &'static str);
39impl Validator for Contains {
40    fn validate(&self, value: &str) -> Option<String> {
41        if !value.contains(self.0) { Some(format!("Must contain '{}'.", self.0)) } else { None }
42    }
43    fn name(&self) -> &'static str { "Contains" }
44}
45
46/// Email-like validation: must contain @ and a dot after @.
47pub struct Email;
48impl Validator for Email {
49    fn validate(&self, value: &str) -> Option<String> {
50        let valid = value.contains('@') && value.split('@').nth(1).map(|d| d.contains('.')).unwrap_or(false);
51        if valid { None } else { Some("Must be a valid email address.".into()) }
52    }
53    fn name(&self) -> &'static str { "Email" }
54}
55
56/// Numeric range validator for string-encoded numbers.
57pub struct Range {
58    pub min: f64,
59    pub max: f64,
60}
61impl Range {
62    pub fn new(min: f64, max: f64) -> Self { Self { min, max } }
63}
64impl Validator for Range {
65    fn validate(&self, value: &str) -> Option<String> {
66        match value.trim().parse::<f64>() {
67            Err(_) => Some("Must be a number.".into()),
68            Ok(n) if n < self.min => Some(format!("Must be at least {}.", self.min)),
69            Ok(n) if n > self.max => Some(format!("Must be no more than {}.", self.max)),
70            Ok(_) => None,
71        }
72    }
73    fn name(&self) -> &'static str { "Range" }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn required_rejects_empty() {
82        assert!(Required.validate("").is_some());
83    }
84
85    #[test]
86    fn required_rejects_whitespace() {
87        assert!(Required.validate("   ").is_some());
88    }
89
90    #[test]
91    fn required_accepts_non_empty() {
92        assert!(Required.validate("hello").is_none());
93    }
94
95    #[test]
96    fn min_length_rejects_short() {
97        assert!(MinLength(5).validate("abc").is_some());
98    }
99
100    #[test]
101    fn min_length_accepts_exact() {
102        assert!(MinLength(3).validate("abc").is_none());
103    }
104
105    #[test]
106    fn max_length_rejects_long() {
107        assert!(MaxLength(3).validate("abcd").is_some());
108    }
109
110    #[test]
111    fn max_length_accepts_exact() {
112        assert!(MaxLength(3).validate("abc").is_none());
113    }
114
115    #[test]
116    fn contains_rejects_missing() {
117        assert!(Contains("@").validate("nodomain").is_some());
118    }
119
120    #[test]
121    fn contains_accepts_present() {
122        assert!(Contains("@").validate("user@example.com").is_none());
123    }
124
125    #[test]
126    fn email_rejects_no_at() {
127        assert!(Email.validate("nodomain.com").is_some());
128    }
129
130    #[test]
131    fn email_rejects_no_dot_after_at() {
132        assert!(Email.validate("user@nodot").is_some());
133    }
134
135    #[test]
136    fn email_accepts_valid() {
137        assert!(Email.validate("user@example.com").is_none());
138    }
139
140    #[test]
141    fn range_rejects_below() {
142        assert!(Range::new(1.0, 10.0).validate("0").is_some());
143    }
144
145    #[test]
146    fn range_rejects_above() {
147        assert!(Range::new(1.0, 10.0).validate("11").is_some());
148    }
149
150    #[test]
151    fn range_rejects_non_numeric() {
152        assert!(Range::new(1.0, 10.0).validate("abc").is_some());
153    }
154
155    #[test]
156    fn range_accepts_within() {
157        assert!(Range::new(1.0, 10.0).validate("5").is_none());
158    }
159}