Skip to main content

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;