Skip to main content

rustrails_model/validations/
format.rs

1use regex::Regex;
2use serde_json::Value;
3
4use super::{Validator, ValidatorOptions};
5use crate::errors::{ErrorType, Errors};
6
7#[derive(Debug, Clone)]
8enum MatchMode {
9    With(Regex),
10    Without(Regex),
11}
12
13/// Validates values against a regular expression.
14#[derive(Debug, Clone, Default)]
15pub struct FormatValidator {
16    mode: Option<MatchMode>,
17    message: Option<String>,
18    pub(crate) options: ValidatorOptions,
19}
20
21impl FormatValidator {
22    /// Creates a new format validator.
23    #[must_use]
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Creates a validator that requires values to match `pattern`.
29    #[must_use]
30    pub fn with_pattern(pattern: &str) -> Self {
31        Self::new().with(pattern)
32    }
33
34    crate::validations::impl_common_validator_methods!();
35
36    /// Requires values to match `pattern`.
37    #[must_use]
38    pub fn with(mut self, pattern: &str) -> Self {
39        self.mode = Some(MatchMode::With(Self::compile(pattern)));
40        self
41    }
42
43    /// Requires values not to match `pattern`.
44    #[must_use]
45    pub fn without(mut self, pattern: &str) -> Self {
46        self.mode = Some(MatchMode::Without(Self::compile(pattern)));
47        self
48    }
49
50    /// Overrides the default invalid-format message.
51    #[must_use]
52    pub fn message(mut self, message: impl Into<String>) -> Self {
53        self.message = Some(message.into());
54        self
55    }
56
57    fn compile(pattern: &str) -> Regex {
58        match Regex::new(pattern) {
59            Ok(regex) => regex,
60            Err(error) => panic!("invalid format pattern '{pattern}': {error}"),
61        }
62    }
63
64    fn error_message(&self) -> String {
65        self.message
66            .clone()
67            .unwrap_or_else(|| String::from("is invalid"))
68    }
69
70    fn value_as_text(value: Option<&Value>) -> String {
71        match value {
72            None | Some(Value::Null) => String::new(),
73            Some(Value::String(text)) => text.clone(),
74            Some(other) => other.to_string(),
75        }
76    }
77}
78
79impl Validator for FormatValidator {
80    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
81        let Some(mode) = &self.mode else {
82            return;
83        };
84
85        let text = Self::value_as_text(value);
86        let invalid = match mode {
87            MatchMode::With(regex) => !regex.is_match(&text),
88            MatchMode::Without(regex) => regex.is_match(&text),
89        };
90
91        if invalid {
92            errors.add(attribute, ErrorType::Invalid, self.error_message());
93        }
94    }
95
96    fn name(&self) -> &str {
97        "format"
98    }
99
100    fn options(&self) -> &ValidatorOptions {
101        &self.options
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use std::collections::HashMap;
108
109    use serde_json::json;
110
111    use super::FormatValidator;
112    use crate::{
113        errors::{ErrorType, Errors},
114        validations::{ValidationSet, Validator},
115    };
116
117    fn validate_format(validator: FormatValidator, value: Option<serde_json::Value>) -> Errors {
118        let mut errors = Errors::new();
119        validator.validate("field", value.as_ref(), &mut errors);
120        errors
121    }
122
123    #[test]
124    fn matching_pattern_passes() {
125        let validator = FormatValidator::with_pattern(r"^[a-z]+$");
126        let mut errors = Errors::new();
127
128        validator.validate("slug", Some(&json!("alpha")), &mut errors);
129
130        assert!(errors.is_empty());
131    }
132
133    #[test]
134    fn non_matching_pattern_fails() {
135        let validator = FormatValidator::with_pattern(r"^[a-z]+$");
136        let mut errors = Errors::new();
137
138        validator.validate("slug", Some(&json!("Alpha1")), &mut errors);
139
140        assert_eq!(errors.on("slug")[0].error_type, ErrorType::Invalid);
141    }
142
143    #[test]
144    fn without_pattern_rejects_matches() {
145        let validator = FormatValidator::new().without("spam");
146        let mut errors = Errors::new();
147
148        validator.validate("body", Some(&json!("contains spam")), &mut errors);
149
150        assert_eq!(errors.on("body")[0].message, "is invalid");
151    }
152
153    #[test]
154    fn custom_message_is_used() {
155        let validator = FormatValidator::with_pattern(r"^\d+$").message("digits only");
156        let mut errors = Errors::new();
157
158        validator.validate("pin", Some(&json!("12ab")), &mut errors);
159
160        assert_eq!(errors.on("pin")[0].message, "digits only");
161    }
162
163    #[test]
164    fn non_string_values_are_stringified() {
165        let validator = FormatValidator::with_pattern(r"^42$");
166        let mut errors = Errors::new();
167
168        validator.validate("answer", Some(&json!(42)), &mut errors);
169
170        assert!(errors.is_empty());
171    }
172
173    #[test]
174    fn without_pattern_allows_non_matching_text() {
175        let errors = validate_format(FormatValidator::new().without("spam"), Some(json!("ham")));
176
177        assert!(errors.is_empty());
178    }
179
180    #[test]
181    fn allow_nil_skips_missing_values_in_validation_set() {
182        let mut set = ValidationSet::new();
183        set.add(
184            "slug",
185            FormatValidator::with_pattern(r"^[a-z]+$").allow_nil(),
186        );
187        let mut errors = Errors::new();
188
189        let _ = set.validate(&|_| None, &mut errors);
190
191        assert!(errors.is_empty());
192    }
193
194    #[test]
195    fn allow_nil_skips_null_values_in_validation_set() {
196        let mut set = ValidationSet::new();
197        set.add(
198            "slug",
199            FormatValidator::with_pattern(r"^[a-z]+$").allow_nil(),
200        );
201        let attrs = HashMap::from([("slug".to_string(), json!(null))]);
202        let mut errors = Errors::new();
203
204        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
205
206        assert!(errors.is_empty());
207    }
208
209    #[test]
210    fn allow_blank_skips_whitespace_values_in_validation_set() {
211        let mut set = ValidationSet::new();
212        set.add(
213            "slug",
214            FormatValidator::with_pattern(r"^[a-z]+$").allow_blank(),
215        );
216        let attrs = HashMap::from([("slug".to_string(), json!("   "))]);
217        let mut errors = Errors::new();
218
219        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
220
221        assert!(errors.is_empty());
222    }
223
224    #[test]
225    fn allow_blank_skips_empty_arrays_in_validation_set() {
226        let mut set = ValidationSet::new();
227        set.add(
228            "slug",
229            FormatValidator::with_pattern(r"^[a-z]+$").allow_blank(),
230        );
231        let attrs = HashMap::from([("slug".to_string(), json!([]))]);
232        let mut errors = Errors::new();
233
234        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
235
236        assert!(errors.is_empty());
237    }
238
239    #[test]
240    fn validator_without_mode_is_no_op() {
241        let errors = validate_format(FormatValidator::new(), Some(json!("anything")));
242
243        assert!(errors.is_empty());
244    }
245
246    #[test]
247    fn null_value_can_match_empty_pattern() {
248        let errors = validate_format(FormatValidator::with_pattern(r"^$"), Some(json!(null)));
249
250        assert!(errors.is_empty());
251    }
252
253    #[test]
254    fn null_value_fails_non_empty_pattern() {
255        let errors = validate_format(FormatValidator::with_pattern(r".+"), Some(json!(null)));
256
257        assert_eq!(errors.on("field")[0].error_type, ErrorType::Invalid);
258    }
259
260    #[test]
261    fn boolean_values_are_stringified() {
262        let errors = validate_format(
263            FormatValidator::with_pattern(r"^false$"),
264            Some(json!(false)),
265        );
266
267        assert!(errors.is_empty());
268    }
269
270    #[test]
271    fn custom_message_is_used_for_without_failures() {
272        let errors = validate_format(
273            FormatValidator::new()
274                .without("spam")
275                .message("forbidden phrase"),
276            Some(json!("spam")),
277        );
278
279        assert_eq!(errors.on("field")[0].message, "forbidden phrase");
280    }
281
282    #[test]
283    fn multiline_text_fails_without_dotall() {
284        let errors = validate_format(FormatValidator::with_pattern(r"^.+$"), Some(json!("a\nb")));
285
286        assert_eq!(errors.on("field")[0].error_type, ErrorType::Invalid);
287    }
288
289    #[test]
290    fn multiline_text_passes_with_dotall() {
291        let errors = validate_format(
292            FormatValidator::with_pattern(r"(?s)^.+$"),
293            Some(json!("a\nb")),
294        );
295
296        assert!(errors.is_empty());
297    }
298
299    #[test]
300    #[should_panic(expected = "invalid format pattern")]
301    fn invalid_patterns_panic() {
302        let _validator = FormatValidator::with_pattern("(");
303    }
304
305    #[test]
306    fn allow_blank_does_not_skip_non_blank_invalid_values() {
307        let mut set = ValidationSet::new();
308        set.add(
309            "slug",
310            FormatValidator::with_pattern(r"^[a-z]+$").allow_blank(),
311        );
312        let attrs = HashMap::from([("slug".to_string(), json!("Alpha1"))]);
313        let mut errors = Errors::new();
314
315        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
316
317        assert_eq!(errors.on("slug")[0].error_type, ErrorType::Invalid);
318    }
319
320    #[test]
321    fn full_message_humanizes_attribute_name() {
322        let mut errors = Errors::new();
323        FormatValidator::with_pattern(r"^[a-z]+$").validate(
324            "email_address",
325            Some(&json!("INVALID")),
326            &mut errors,
327        );
328
329        assert_eq!(
330            errors.full_messages(),
331            vec!["Email address is invalid".to_string()]
332        );
333    }
334}