ocre/validate.rs
1//! Field validations with Rails-style messages.
2//!
3//! ```ignore
4//! let mut v = Validator::new();
5//! v.required("title", &input.title);
6//! v.max_length("title", &input.title, 100);
7//! v.range("pages", input.pages, 1..=10_000);
8//! v.finish()?; // Error::Invalid with every failed field
9//! ```
10
11use std::{fmt, ops::RangeInclusive, str::FromStr};
12
13use serde::Serialize;
14
15use crate::{Error, MAX_SAFE_INTEGER, names::humanize};
16
17/// One failed validation: the field name plus the message without it, as in Rails' `errors`.
18///
19/// [`Error::Invalid`] carries a list of them; JSON answers group the messages
20/// by field, HTML pages show [`full_message`](Self::full_message). Serializes
21/// as `{"field": "title", "message": "can't be blank"}`; `Display` writes the
22/// full message.
23///
24/// # Examples
25///
26/// ```
27/// use ocre::FieldError;
28///
29/// let error = FieldError::new("published_at", "is not a valid date");
30/// assert_eq!(error.full_message(), "Published at is not a valid date");
31/// assert_eq!(error.to_string(), error.full_message());
32/// ```
33#[derive(Debug, Clone, Serialize)]
34pub struct FieldError {
35 /// Field name as in the form or JSON body (`"title"`, `"author_id"`).
36 pub field: String,
37 /// Message without the field name (`"can't be blank"`).
38 pub message: String,
39 /// Rails' translation key of the check (`blank`, `too_long`...), for
40 /// [`I18n::error_message`](crate::i18n::I18n::error_message).
41 #[serde(skip)]
42 key: Option<&'static str>,
43 /// `%{count}` of the message (a bound), or the confirmed field of `confirmation`.
44 #[serde(skip)]
45 detail: Option<String>,
46}
47
48/// Compares the field and the message only, as they are what users see.
49impl PartialEq for FieldError {
50 fn eq(&self, other: &Self) -> bool {
51 self.field == other.field && self.message == other.message
52 }
53}
54
55impl FieldError {
56 /// Builds an error for `field` with `message` (without the field name).
57 ///
58 /// # Examples
59 ///
60 /// ```
61 /// let error = ocre::FieldError::new("title", "can't be blank");
62 /// assert_eq!((error.field.as_str(), error.message.as_str()), ("title", "can't be blank"));
63 /// ```
64 pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
65 Self { field: field.into(), message: message.into(), key: None, detail: None }
66 }
67
68 /// The Rails translation key of the check that failed (`"blank"`, `"too_long"`...), if any.
69 ///
70 /// Set by each [`Validator`] check; `None` for [`FieldError::new`],
71 /// [`Validator::check`] and after [`Validator::message`].
72 /// [`I18n::error_message`](crate::i18n::I18n::error_message) looks it up
73 /// under `errors.messages.<key>` (and more specific keys).
74 ///
75 /// # Examples
76 ///
77 /// ```
78 /// let mut v = ocre::Validator::new();
79 /// v.required("title", "").max_length("body", "long text", 3);
80 /// let keys: Vec<_> = v.errors().iter().map(|e| e.key()).collect();
81 /// assert_eq!(keys, [Some("blank"), Some("too_long")]);
82 /// assert_eq!(ocre::FieldError::new("title", "is odd").key(), None);
83 /// ```
84 pub fn key(&self) -> Option<&'static str> {
85 self.key
86 }
87
88 /// `%{count}` for the message, or the confirmed field of `confirmation`.
89 pub(crate) fn detail(&self) -> Option<&str> {
90 self.detail.as_deref()
91 }
92
93 /// The message prefixed with the humanized field name: `"Title can't be blank"`.
94 ///
95 /// Like Rails' `full_messages`: underscores become spaces, a trailing
96 /// `_id` is dropped and the first letter is capitalized.
97 ///
98 /// # Examples
99 ///
100 /// ```
101 /// let error = ocre::FieldError::new("title", "can't be blank");
102 /// assert_eq!(error.full_message(), "Title can't be blank");
103 /// ```
104 pub fn full_message(&self) -> String {
105 format!("{} {}", humanize(&self.field), self.message)
106 }
107}
108
109impl fmt::Display for FieldError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 f.write_str(&self.full_message())
112 }
113}
114
115/// Collects field errors with Rails-style messages; [`finish`](Self::finish) fails with all of them.
116///
117/// Every check adds at most one error per failed rule and returns `&mut Self`,
118/// so checks chain. Nothing stops at the first failure: the user sees every
119/// problem at once, as [`Error::Invalid`] (422). Pure Rust: no binding call,
120/// no D1 rows. Uniqueness and foreign keys need the database; generated
121/// models check them with [`Db::exists`](crate::Db::exists) and
122/// [`check`](Self::check).
123///
124/// # Examples
125///
126/// ```
127/// use ocre::{Error, Validator};
128///
129/// let mut v = Validator::new();
130/// v.required("title", " ").max_length("title", " ", 100).range("pages", 0, 1..=10_000);
131/// let Err(Error::Invalid(errors)) = v.finish() else { panic!("expected errors") };
132/// let messages: Vec<String> = errors.iter().map(|e| e.full_message()).collect();
133/// assert_eq!(messages, ["Title can't be blank", "Pages must be greater than or equal to 1"]);
134/// ```
135#[derive(Debug, Default)]
136pub struct Validator {
137 errors: Vec<FieldError>,
138 /// Whether the last check failed, for [`message`](Self::message).
139 last_failed: bool,
140}
141
142impl Validator {
143 /// Creates a validator with no errors.
144 ///
145 /// # Examples
146 ///
147 /// ```
148 /// assert!(ocre::Validator::new().is_valid());
149 /// ```
150 pub fn new() -> Self {
151 Self::default()
152 }
153
154 /// Adds `message` for `field` when `failed` is true: the building block for custom rules.
155 ///
156 /// # Examples
157 ///
158 /// ```
159 /// let mut v = ocre::Validator::new();
160 /// v.check("ends_at", 10 < 5, "must be after the start").check("title", true, "is reserved");
161 /// assert!(!v.is_valid());
162 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Title is reserved");
163 /// ```
164 pub fn check(&mut self, field: &str, failed: bool, message: impl Into<String>) -> &mut Self {
165 self.last_failed = failed;
166 if failed {
167 self.errors.push(FieldError::new(field, message));
168 }
169 self
170 }
171
172 /// [`check`](Self::check) recording Rails' translation `key` and the `%{count}` (or confirmed field) `detail`.
173 fn fail(
174 &mut self,
175 field: &str,
176 failed: bool,
177 key: &'static str,
178 detail: Option<&dyn fmt::Display>,
179 message: impl Into<String>,
180 ) -> &mut Self {
181 self.check(field, failed, message);
182 if failed && let Some(error) = self.errors.last_mut() {
183 error.key = Some(key);
184 error.detail = detail.map(ToString::to_string);
185 }
186 self
187 }
188
189 /// Checks that `value` is not empty after trimming whitespace ("can't be blank").
190 ///
191 /// # Examples
192 ///
193 /// ```
194 /// let mut v = ocre::Validator::new();
195 /// v.required("title", "Hello");
196 /// assert!(v.is_valid());
197 /// v.required("body", " \n");
198 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Body can't be blank");
199 /// ```
200 pub fn required(&mut self, field: &str, value: &str) -> &mut Self {
201 self.fail(field, value.trim().is_empty(), "blank", None, "can't be blank")
202 }
203
204 /// Checks that `value` has at most `max` characters (Unicode scalar values, not bytes).
205 ///
206 /// Message: "is too long (maximum is N characters)".
207 ///
208 /// # Examples
209 ///
210 /// ```
211 /// let mut v = ocre::Validator::new();
212 /// v.max_length("title", "été", 3);
213 /// assert!(v.is_valid());
214 /// v.max_length("title", "summer", 3);
215 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Title is too long (maximum is 3 characters)");
216 /// ```
217 pub fn max_length(&mut self, field: &str, value: &str, max: usize) -> &mut Self {
218 let too_long = value.chars().count() > max;
219 self.fail(field, too_long, "too_long", Some(&max), format!("is too long (maximum is {max} characters)"))
220 }
221
222 /// Checks that `value` has at least `min` characters (Unicode scalar values, not bytes).
223 ///
224 /// Message: "is too short (minimum is N characters)".
225 ///
226 /// # Examples
227 ///
228 /// ```
229 /// let mut v = ocre::Validator::new();
230 /// v.min_length("password", "hunter2", 12);
231 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Password is too short (minimum is 12 characters)");
232 /// ```
233 pub fn min_length(&mut self, field: &str, value: &str, min: usize) -> &mut Self {
234 let too_short = value.chars().count() < min;
235 self.fail(field, too_short, "too_short", Some(&min), format!("is too short (minimum is {min} characters)"))
236 }
237
238 /// Checks that `value` is within `range`, bounds included.
239 ///
240 /// Messages: "must be greater than or equal to MIN" or "must be less than
241 /// or equal to MAX".
242 ///
243 /// # Examples
244 ///
245 /// ```
246 /// let mut v = ocre::Validator::new();
247 /// v.range("rating", 3, 1..=5).range("price", 12.5, 0.0..=10.0);
248 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Price must be less than or equal to 10");
249 /// ```
250 pub fn range<T: PartialOrd + fmt::Display>(
251 &mut self,
252 field: &str,
253 value: T,
254 range: RangeInclusive<T>,
255 ) -> &mut Self {
256 let (below, above) = (value < *range.start(), value > *range.end());
257 self.bounds(field, below, above, range.start(), range.end())
258 }
259
260 fn bounds(
261 &mut self,
262 field: &str,
263 below: bool,
264 above: bool,
265 min: &dyn fmt::Display,
266 max: &dyn fmt::Display,
267 ) -> &mut Self {
268 if below {
269 self.fail(
270 field,
271 true,
272 "greater_than_or_equal_to",
273 Some(min),
274 format!("must be greater than or equal to {min}"),
275 )
276 } else {
277 self.fail(field, above, "less_than_or_equal_to", Some(max), format!("must be less than or equal to {max}"))
278 }
279 }
280
281 /// Checks that `value` is an integer D1 can store and return exactly (±2^53 - 1).
282 ///
283 /// See [`MAX_SAFE_INTEGER`]; same messages as [`range`](Self::range).
284 ///
285 /// # Examples
286 ///
287 /// ```
288 /// let mut v = ocre::Validator::new();
289 /// v.safe_integer("views", ocre::MAX_SAFE_INTEGER);
290 /// assert!(v.is_valid());
291 /// v.safe_integer("views", i64::MAX);
292 /// assert!(!v.is_valid());
293 /// ```
294 pub fn safe_integer(&mut self, field: &str, value: i64) -> &mut Self {
295 self.range(field, value, -MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER)
296 }
297
298 /// Checks that `value` is one of `allowed` ("is not included in the list").
299 ///
300 /// # Examples
301 ///
302 /// ```
303 /// let mut v = ocre::Validator::new();
304 /// v.inclusion("status", "draft", &["draft", "published"]);
305 /// assert!(v.is_valid());
306 /// v.inclusion("status", "archived", &["draft", "published"]);
307 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Status is not included in the list");
308 /// ```
309 pub fn inclusion(&mut self, field: &str, value: &str, allowed: &[&str]) -> &mut Self {
310 self.fail(field, !allowed.contains(&value), "inclusion", None, "is not included in the list")
311 }
312
313 /// Checks that `value` is not one of `forbidden` ("is reserved"), like Rails' `exclusion`.
314 ///
315 /// # Examples
316 ///
317 /// ```
318 /// let mut v = ocre::Validator::new();
319 /// v.exclusion("username", "ada", &["admin", "root"]);
320 /// assert!(v.is_valid());
321 /// v.exclusion("username", "admin", &["admin", "root"]);
322 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Username is reserved");
323 /// ```
324 pub fn exclusion(&mut self, field: &str, value: &str, forbidden: &[&str]) -> &mut Self {
325 self.fail(field, forbidden.contains(&value), "exclusion", None, "is reserved")
326 }
327
328 /// Checks that `value` has exactly `length` characters (Unicode scalar
329 /// values): "is the wrong length (should be N characters)". For a range
330 /// (Rails' `in:`), chain [`min_length`](Self::min_length) and
331 /// [`max_length`](Self::max_length).
332 ///
333 /// # Examples
334 ///
335 /// ```
336 /// let mut v = ocre::Validator::new();
337 /// v.length("zip", "75001", 5);
338 /// assert!(v.is_valid());
339 /// v.length("zip", "7500", 5);
340 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Zip is the wrong length (should be 5 characters)");
341 /// ```
342 pub fn length(&mut self, field: &str, value: &str, length: usize) -> &mut Self {
343 let wrong = value.chars().count() != length;
344 let message = format!("is the wrong length (should be {length} characters)");
345 self.fail(field, wrong, "wrong_length", Some(&length), message)
346 }
347
348 /// Checks that `value > than` ("must be greater than N"), like Rails' `comparison`.
349 ///
350 /// Works for numbers and for `YYYY-MM-DD` dates or datetimes as text,
351 /// which compare in time order.
352 ///
353 /// # Examples
354 ///
355 /// ```
356 /// let mut v = ocre::Validator::new();
357 /// v.greater_than("ends_on", "2026-10-02", "2026-10-01").greater_than("quantity", 0, 0);
358 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Quantity must be greater than 0");
359 /// ```
360 pub fn greater_than<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, than: T) -> &mut Self {
361 self.fail(field, value <= than, "greater_than", Some(&than), format!("must be greater than {than}"))
362 }
363
364 /// Checks that `value >= min` ("must be greater than or equal to N").
365 ///
366 /// # Examples
367 ///
368 /// ```
369 /// let mut v = ocre::Validator::new();
370 /// v.greater_than_or_equal_to("age", 17, 18);
371 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Age must be greater than or equal to 18");
372 /// ```
373 pub fn greater_than_or_equal_to<T: PartialOrd + fmt::Display>(
374 &mut self,
375 field: &str,
376 value: T,
377 min: T,
378 ) -> &mut Self {
379 let message = format!("must be greater than or equal to {min}");
380 self.fail(field, value < min, "greater_than_or_equal_to", Some(&min), message)
381 }
382
383 /// Checks that `value < than` ("must be less than N").
384 ///
385 /// # Examples
386 ///
387 /// ```
388 /// let mut v = ocre::Validator::new();
389 /// v.less_than("discount", 1.0, 1.0);
390 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Discount must be less than 1");
391 /// ```
392 pub fn less_than<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, than: T) -> &mut Self {
393 self.fail(field, value >= than, "less_than", Some(&than), format!("must be less than {than}"))
394 }
395
396 /// Checks that `value <= max` ("must be less than or equal to N").
397 ///
398 /// # Examples
399 ///
400 /// ```
401 /// let mut v = ocre::Validator::new();
402 /// v.less_than_or_equal_to("seats", 9, 8);
403 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Seats must be less than or equal to 8");
404 /// ```
405 pub fn less_than_or_equal_to<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, max: T) -> &mut Self {
406 self.fail(
407 field,
408 value > max,
409 "less_than_or_equal_to",
410 Some(&max),
411 format!("must be less than or equal to {max}"),
412 )
413 }
414
415 /// Checks that `value != other` ("must be other than N").
416 ///
417 /// # Examples
418 ///
419 /// ```
420 /// let mut v = ocre::Validator::new();
421 /// v.other_than("parent_id", 4, 4);
422 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Parent must be other than 4");
423 /// ```
424 pub fn other_than<T: PartialEq + fmt::Display>(&mut self, field: &str, value: T, other: T) -> &mut Self {
425 self.fail(field, value == other, "other_than", Some(&other), format!("must be other than {other}"))
426 }
427
428 /// Checks that `confirmation` equals `value`, like Rails' `confirmation`:
429 /// the error goes on `<field>_confirmation` ("doesn't match Password").
430 ///
431 /// # Examples
432 ///
433 /// ```
434 /// let mut v = ocre::Validator::new();
435 /// v.confirmation("password", "s3cret-pass", "s3cret-pass");
436 /// assert!(v.is_valid());
437 /// v.confirmation("password", "s3cret-pass", "typo");
438 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Password confirmation doesn't match Password");
439 /// ```
440 pub fn confirmation(&mut self, field: &str, value: &str, confirmation: &str) -> &mut Self {
441 let message = format!("doesn't match {}", humanize(field));
442 self.fail(&format!("{field}_confirmation"), value != confirmation, "confirmation", Some(&field), message)
443 }
444
445 /// Checks that a checkbox was ticked ("must be accepted"), like Rails' `acceptance`.
446 ///
447 /// The value is not stored: take it as a `bool` in the form or JSON
448 /// struct (`#[serde(default)]`, since an unticked checkbox sends nothing).
449 ///
450 /// # Examples
451 ///
452 /// ```
453 /// let mut v = ocre::Validator::new();
454 /// v.acceptance("terms_of_service", false);
455 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Terms of service must be accepted");
456 /// ```
457 pub fn acceptance(&mut self, field: &str, accepted: bool) -> &mut Self {
458 self.fail(field, !accepted, "accepted", None, "must be accepted")
459 }
460
461 /// Checks that `value` is blank: empty or only whitespace ("must be blank"), like Rails' `absence`.
462 ///
463 /// For an `Option`, check `value.is_some()` with [`check`](Self::check).
464 ///
465 /// # Examples
466 ///
467 /// ```
468 /// let mut v = ocre::Validator::new();
469 /// v.absence("nickname", " ");
470 /// assert!(v.is_valid());
471 /// v.absence("nickname", "bot");
472 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Nickname must be blank");
473 /// ```
474 pub fn absence(&mut self, field: &str, value: &str) -> &mut Self {
475 self.fail(field, !value.trim().is_empty(), "present", None, "must be blank")
476 }
477
478 /// Checks that every character of `value` passes `allowed` ("is invalid"):
479 /// Rails' `format`, without regular expressions (no regex engine in the
480 /// WebAssembly binary). Combine with [`min_length`](Self::min_length) to
481 /// refuse an empty value, and with [`check`](Self::check) for position
482 /// rules (`value.starts_with(..)`).
483 ///
484 /// # Examples
485 ///
486 /// ```
487 /// let mut v = ocre::Validator::new();
488 /// let slug = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
489 /// v.format("slug", "hello-2026", slug);
490 /// assert!(v.is_valid());
491 /// v.format("slug", "Hello World", slug);
492 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Slug is invalid");
493 /// ```
494 pub fn format(&mut self, field: &str, value: &str, allowed: impl Fn(char) -> bool) -> &mut Self {
495 let invalid = !value.chars().all(allowed);
496 self.fail(field, invalid, "invalid", None, "is invalid")
497 }
498
499 /// Replaces the message of the check just before, if it failed (Rails' `message:` option).
500 ///
501 /// Only the last check counts: call it right after the check it changes.
502 ///
503 /// # Examples
504 ///
505 /// ```
506 /// let mut v = ocre::Validator::new();
507 /// v.required("title", "Dune").message("needs a title");
508 /// v.required("body", "").message("write something first");
509 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Body write something first");
510 /// ```
511 pub fn message(&mut self, message: impl Into<String>) -> &mut Self {
512 if self.last_failed
513 && let Some(error) = self.errors.last_mut()
514 {
515 error.message = message.into();
516 error.key = None;
517 error.detail = None;
518 }
519 self
520 }
521
522 /// The errors collected so far, in the order the checks ran (Rails' `errors`).
523 ///
524 /// # Examples
525 ///
526 /// ```
527 /// let mut v = ocre::Validator::new();
528 /// v.required("title", "").required("body", "");
529 /// let fields: Vec<&str> = v.errors().iter().map(|e| e.field.as_str()).collect();
530 /// assert_eq!(fields, ["title", "body"]);
531 /// ```
532 pub fn errors(&self) -> &[FieldError] {
533 &self.errors
534 }
535
536 /// Checks that `value` looks like an e-mail address ("is invalid").
537 ///
538 /// One `@`, text on both sides, a dot in the domain, no spaces or `<>,`.
539 /// Delivery is the only real check. [`mail::send`](crate::mail::send)
540 /// applies the same rule.
541 ///
542 /// # Examples
543 ///
544 /// ```
545 /// let mut v = ocre::Validator::new();
546 /// v.email("email", "ada@example.com");
547 /// assert!(v.is_valid());
548 /// v.email("email", "ada@localhost");
549 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Email is invalid");
550 /// ```
551 pub fn email(&mut self, field: &str, value: &str) -> &mut Self {
552 self.fail(field, !is_email(value), "invalid", None, "is invalid")
553 }
554
555 /// Parses a required number typed as text (HTML forms), adding "is not a number" when it does not parse.
556 ///
557 /// Surrounding whitespace is ignored. Returns the parsed value, or `None`
558 /// after adding the error, so parsing and validation happen in one pass.
559 ///
560 /// # Examples
561 ///
562 /// ```
563 /// let mut v = ocre::Validator::new();
564 /// assert_eq!(v.number::<i64>("pages", " 42 "), Some(42));
565 /// assert_eq!(v.number::<i64>("year", ""), None);
566 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Year is not a number");
567 /// ```
568 pub fn number<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T> {
569 let parsed = text.trim().parse().ok();
570 self.fail(field, parsed.is_none(), "not_a_number", None, "is not a number");
571 parsed
572 }
573
574 /// Parses an optional number typed as text: blank text is `None` without an error.
575 ///
576 /// Otherwise like [`number`](Self::number).
577 ///
578 /// # Examples
579 ///
580 /// ```
581 /// let mut v = ocre::Validator::new();
582 /// assert_eq!(v.optional_number::<u32>("pages", " "), None);
583 /// assert!(v.is_valid());
584 /// assert_eq!(v.optional_number::<u32>("pages", "-1"), None);
585 /// assert!(!v.is_valid());
586 /// ```
587 pub fn optional_number<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T> {
588 if text.trim().is_empty() { None } else { self.number(field, text) }
589 }
590
591 /// Parses form text into one of an enum's values, like
592 /// [`number`](Self::number): `None` plus "is not included in the list"
593 /// when `T::from_str` refuses it. Generated scaffolds use it for `enum`
594 /// fields.
595 ///
596 /// # Examples
597 ///
598 /// ```
599 /// #[derive(Debug, PartialEq)]
600 /// enum Status {
601 /// Draft,
602 /// }
603 ///
604 /// impl std::str::FromStr for Status {
605 /// type Err = ();
606 /// fn from_str(text: &str) -> Result<Self, ()> {
607 /// if text == "draft" { Ok(Status::Draft) } else { Err(()) }
608 /// }
609 /// }
610 ///
611 /// let mut v = ocre::Validator::new();
612 /// assert_eq!(v.one_of::<Status>("status", "draft"), Some(Status::Draft));
613 /// assert_eq!(v.one_of::<Status>("status", "archived"), None);
614 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Status is not included in the list");
615 /// ```
616 pub fn one_of<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T> {
617 let parsed = text.parse().ok();
618 self.fail(field, parsed.is_none(), "inclusion", None, "is not included in the list");
619 parsed
620 }
621
622 /// Like [`one_of`](Self::one_of) for an optional field: blank text is `None` without error.
623 ///
624 /// # Examples
625 ///
626 /// ```
627 /// let mut v = ocre::Validator::new();
628 /// assert_eq!(v.optional_one_of::<bool>("flag", " "), None);
629 /// assert_eq!(v.optional_one_of::<bool>("flag", "true"), Some(true));
630 /// assert!(v.is_valid());
631 /// ```
632 pub fn optional_one_of<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T> {
633 if text.trim().is_empty() { None } else { self.one_of(field, text) }
634 }
635
636 /// Parses a required JSON value typed as text (a `<textarea>`), adding "is not valid JSON" when it does not parse.
637 ///
638 /// Returns the parsed value, or `None` after adding the error.
639 ///
640 /// # Examples
641 ///
642 /// ```
643 /// let mut v = ocre::Validator::new();
644 /// assert_eq!(v.json("metadata", r#"{"a": 1}"#), Some(serde_json::json!({"a": 1})));
645 /// assert_eq!(v.json("metadata", "{a: 1}"), None);
646 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Metadata is not valid JSON");
647 /// ```
648 pub fn json(&mut self, field: &str, text: &str) -> Option<serde_json::Value> {
649 let parsed = serde_json::from_str(text).ok();
650 self.fail(field, parsed.is_none(), "not_json", None, "is not valid JSON");
651 parsed
652 }
653
654 /// Parses an optional JSON value typed as text: blank text is `None` without an error.
655 ///
656 /// Otherwise like [`json`](Self::json).
657 ///
658 /// # Examples
659 ///
660 /// ```
661 /// let mut v = ocre::Validator::new();
662 /// assert_eq!(v.optional_json("metadata", ""), None);
663 /// assert!(v.is_valid());
664 /// ```
665 pub fn optional_json(&mut self, field: &str, text: &str) -> Option<serde_json::Value> {
666 if text.trim().is_empty() { None } else { self.json(field, text) }
667 }
668
669 /// Checks that `value` is a real calendar date written `YYYY-MM-DD` ("is not a valid date").
670 ///
671 /// Month lengths and leap years are checked.
672 ///
673 /// # Examples
674 ///
675 /// ```
676 /// let mut v = ocre::Validator::new();
677 /// v.date("born_on", "2024-02-29");
678 /// assert!(v.is_valid());
679 /// v.date("born_on", "2023-02-29");
680 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Born on is not a valid date");
681 /// ```
682 pub fn date(&mut self, field: &str, value: &str) -> &mut Self {
683 self.fail(field, !is_date(value), "not_a_date", None, "is not a valid date")
684 }
685
686 /// Checks that `value` is `YYYY-MM-DD HH:MM[:SS]`, with a space or `T` (HTML `datetime-local`).
687 ///
688 /// Message: "is not a valid date and time". No time zone is accepted.
689 ///
690 /// # Examples
691 ///
692 /// ```
693 /// let mut v = ocre::Validator::new();
694 /// v.datetime("starts_at", "2026-09-29T18:30").datetime("ends_at", "2026-09-29 19:00:00");
695 /// assert!(v.is_valid());
696 /// v.datetime("starts_at", "2026-09-29");
697 /// assert!(!v.is_valid());
698 /// ```
699 pub fn datetime(&mut self, field: &str, value: &str) -> &mut Self {
700 let valid = value.len() >= 16
701 && value.is_char_boundary(10)
702 && is_date(&value[..10])
703 && matches!(value.as_bytes()[10], b' ' | b'T')
704 && is_time(&value[11..]);
705 self.fail(field, !valid, "not_a_datetime", None, "is not a valid date and time")
706 }
707
708 /// Checks that `value` is a time of day written `HH:MM` or `HH:MM:SS` (HTML `<input type="time">`).
709 ///
710 /// Message: "is not a valid time". No time zone is accepted.
711 ///
712 /// # Examples
713 ///
714 /// ```
715 /// let mut v = ocre::Validator::new();
716 /// v.time("opens_at", "09:30").time("closes_at", "18:00:00");
717 /// assert!(v.is_valid());
718 /// v.time("opens_at", "24:00");
719 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Opens at is not a valid time");
720 /// ```
721 pub fn time(&mut self, field: &str, value: &str) -> &mut Self {
722 self.fail(field, !is_time(value), "not_a_time", None, "is not a valid time")
723 }
724
725 /// Checks that `value` is a UUID in its hyphenated form, any case ("is not a valid UUID").
726 ///
727 /// # Examples
728 ///
729 /// ```
730 /// let mut v = ocre::Validator::new();
731 /// v.uuid("token", "67e55044-10b1-426f-9247-bb680e5fe0c8");
732 /// assert!(v.is_valid());
733 /// v.uuid("token", "67e55044");
734 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Token is not a valid UUID");
735 /// ```
736 pub fn uuid(&mut self, field: &str, value: &str) -> &mut Self {
737 let groups: Vec<&str> = value.split('-').collect();
738 let valid = groups.len() == 5
739 && groups
740 .iter()
741 .zip([8, 4, 4, 4, 12])
742 .all(|(group, len)| group.len() == len && group.bytes().all(|b| b.is_ascii_hexdigit()));
743 self.fail(field, !valid, "not_a_uuid", None, "is not a valid UUID")
744 }
745
746 /// Checks that `value` is an exact decimal number such as `-12.50` ("is not a decimal number").
747 ///
748 /// An optional sign, digits, then optionally a dot and digits: no
749 /// exponent, no spaces. Decimal columns are stored as this text, so money
750 /// keeps every digit (a `REAL` would round `0.1 + 0.2`).
751 ///
752 /// # Examples
753 ///
754 /// ```
755 /// let mut v = ocre::Validator::new();
756 /// v.decimal("price", "19.99").decimal("balance", "-3");
757 /// assert!(v.is_valid());
758 /// v.decimal("price", "1e3");
759 /// assert_eq!(v.finish().unwrap_err().to_string(), "invalid: Price is not a decimal number");
760 /// ```
761 pub fn decimal(&mut self, field: &str, value: &str) -> &mut Self {
762 let unsigned = value.strip_prefix(['-', '+']).unwrap_or(value);
763 let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, "0"));
764 let valid = [whole, fraction].iter().all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()));
765 self.fail(field, !valid, "not_a_decimal", None, "is not a decimal number")
766 }
767
768 /// Adds the errors collected by `other`, e.g. a model's `validate()` after parsing a form.
769 ///
770 /// # Examples
771 ///
772 /// ```
773 /// let mut model = ocre::Validator::new();
774 /// model.required("title", "");
775 /// let mut form = ocre::Validator::new();
776 /// form.merge(model);
777 /// assert!(!form.is_valid());
778 /// ```
779 pub fn merge(&mut self, mut other: Validator) -> &mut Self {
780 self.errors.append(&mut other.errors);
781 self
782 }
783
784 /// Whether no error has been collected so far.
785 ///
786 /// # Examples
787 ///
788 /// ```
789 /// let mut v = ocre::Validator::new();
790 /// assert!(v.is_valid());
791 /// v.required("title", "");
792 /// assert!(!v.is_valid());
793 /// ```
794 pub fn is_valid(&self) -> bool {
795 self.errors.is_empty()
796 }
797
798 /// Returns `Ok(())` when every check passed, and takes the collected errors otherwise.
799 ///
800 /// The validator is empty afterwards, so it can be reused.
801 ///
802 /// # Errors
803 ///
804 /// [`Error::Invalid`] (422) with every collected [`FieldError`], in the
805 /// order the checks ran.
806 ///
807 /// # Examples
808 ///
809 /// ```
810 /// use ocre::{Error, FieldError, Validator};
811 ///
812 /// let mut v = Validator::new();
813 /// v.required("title", "");
814 /// let Err(Error::Invalid(errors)) = v.finish() else { panic!("expected errors") };
815 /// assert_eq!(errors, [FieldError::new("title", "can't be blank")]);
816 /// assert!(v.finish().is_ok());
817 /// ```
818 pub fn finish(&mut self) -> Result<(), Error> {
819 if self.errors.is_empty() { Ok(()) } else { Err(Error::Invalid(std::mem::take(&mut self.errors))) }
820 }
821}
822
823/// The address rule behind [`Validator::email`] and outgoing mail.
824pub(crate) fn is_email(value: &str) -> bool {
825 let forbidden = |c: char| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | ',');
826 !value.contains(forbidden)
827 && value.split_once('@').is_some_and(|(local, domain)| {
828 !local.is_empty()
829 && !domain.contains('@')
830 && domain.contains('.')
831 && !domain.starts_with('.')
832 && !domain.ends_with('.')
833 })
834}
835
836fn digits(text: &str) -> Option<u32> {
837 if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
838 return None;
839 }
840 text.parse().ok()
841}
842
843/// `YYYY-MM-DD`, checking month lengths and leap years.
844fn is_date(value: &str) -> bool {
845 let parts: Vec<&str> = value.split('-').collect();
846 let [year, month, day] = parts[..] else { return false };
847 let (Some(year), Some(month), Some(day)) = (digits(year), digits(month), digits(day)) else { return false };
848 if parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 {
849 return false;
850 }
851 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
852 let days = match month {
853 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
854 4 | 6 | 9 | 11 => 30,
855 2 if leap => 29,
856 2 => 28,
857 _ => return false,
858 };
859 (1..=days).contains(&day)
860}
861
862/// `HH:MM` or `HH:MM:SS`.
863fn is_time(value: &str) -> bool {
864 let parts: Vec<&str> = value.split(':').collect();
865 let limits = [23, 59, 59];
866 (2..=3).contains(&parts.len())
867 && parts.iter().zip(limits).all(|(part, max)| part.len() == 2 && digits(part).is_some_and(|n| n <= max))
868}
869
870#[cfg(test)]
871#[path = "../tests/validate.rs"]
872mod tests;