rustrails_model/validations/
format.rs1use 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#[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 #[must_use]
24 pub fn new() -> Self {
25 Self::default()
26 }
27
28 #[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 #[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 #[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 #[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}