Skip to main content

renox_core/validation/
mod.rs

1//! Form validation with Laravel-style rules, database-backed `unique` and
2//! `exists`, and English messages (an app translates them in its lang files).
3//!
4//! ```
5//! # use renox::prelude::*;
6//! use serde::{Deserialize, Serialize};
7//!
8//! #[derive(Deserialize, Serialize)]
9//! struct ProductForm {
10//!     name: String,
11//!     price: i64,
12//!     email: Option<String>,
13//! }
14//!
15//! impl Validate for ProductForm {
16//!     fn rules(&self, v: &mut Validator) {
17//!         v.field("name", &self.name).required().max(100).unique("products", "name");
18//!         v.field("price", &self.price).label("sale price").min(1_000);
19//!         v.field("email", &self.email).email();
20//!     }
21//! }
22//!
23//! async fn store(State(db): State<Db>, back: Back, Valid(form): Valid<ProductForm>) -> Result<Back> {
24//!     // `form` passed every rule; invalid input never gets here.
25//! #   let _ = (db, form);
26//!     Ok(back)
27//! }
28//! ```
29
30pub(crate) mod extract;
31mod key_values;
32mod messages;
33pub(crate) mod nested;
34mod value;
35
36use std::collections::BTreeMap;
37
38use axum::http::StatusCode;
39use axum::response::{IntoResponse, Response};
40use serde::{Deserialize, Serialize};
41use serde_json::{Map, Value, json};
42
43pub use extract::Valid;
44pub use key_values::KeyValues;
45pub(crate) use messages::{render, template_for};
46pub use value::{FieldValue, Inspected};
47
48use crate::Result;
49use crate::db::{Db, DbValue, ToDbValue, quote};
50use chrono::NaiveDateTime;
51
52/// Validation errors: messages keyed by field name.
53#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct Errors(BTreeMap<String, Vec<String>>);
56
57impl Errors {
58    /// No errors.
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Adds `message` to the messages of `field`.
64    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
65        self.0.entry(field.into()).or_default().push(message.into());
66    }
67
68    /// Whether there are no errors.
69    pub fn is_empty(&self) -> bool {
70        self.0.is_empty()
71    }
72
73    /// Whether `field` has at least one error.
74    pub fn has(&self, field: &str) -> bool {
75        self.0.contains_key(field)
76    }
77
78    /// The first message for a field.
79    pub fn first(&self, field: &str) -> Option<&str> {
80        self.0
81            .get(field)
82            .and_then(|m| m.first())
83            .map(String::as_str)
84    }
85
86    /// Every field with its messages, fields in alphabetical order.
87    pub fn iter(&self) -> impl Iterator<Item = (&str, &[String])> {
88        self.0.iter().map(|(f, m)| (f.as_str(), m.as_slice()))
89    }
90}
91
92/// Declares the rules for a form. Used by the `Valid<T>` extractor, which
93/// calls, in order: [`prepare`](Validate::prepare),
94/// [`authorize`](Validate::authorize), [`rules`](Validate::rules) and, when
95/// they pass, [`after`](Validate::after). Only `rules` is required.
96///
97/// ```
98/// # use renox::prelude::*;
99/// use renox::validation::FormContext;
100///
101/// #[derive(serde::Deserialize)]
102/// struct Invite { email: String, team_id: i64 }
103///
104/// impl Validate for Invite {
105///     // Laravel's prepareForValidation: tidy the input first.
106///     fn prepare(&mut self) {
107///         self.email = self.email.trim().to_lowercase();
108///     }
109///
110///     // Laravel's authorize: `false` answers 403 before any rule runs.
111///     async fn authorize(&self, form: &FormContext<'_>) -> Result<bool> {
112///         Ok(form.user.is_some_and(|user| user.has_role("owner")))
113///     }
114///
115///     fn rules(&self, v: &mut Validator) {
116///         v.field("email", &self.email).required().email();
117///     }
118///
119///     // Laravel's `after`: checks that need the database or several fields,
120///     // once the rules pass. Errors added here are shown like any other.
121///     async fn after(&self, form: &FormContext<'_>, errors: &mut Errors) -> Result {
122///         let members: i64 = renox::db::sql("SELECT COUNT(*) FROM team_user WHERE team_id = ?")
123///             .bind(self.team_id)
124///             .scalar(&form.state.db)
125///             .await?;
126///         if members >= 10 {
127///             errors.add("email", "This team is full (10 members).");
128///         }
129///         Ok(())
130///     }
131/// }
132/// ```
133pub trait Validate {
134    /// The named error bag this form's errors are flashed in (Laravel's
135    /// error bags), for a page with two forms that share field names, e.g.
136    /// `Some("login")` next to a sign-up form. Templates read them with
137    /// `error('email', bag='login')`. `#[validate(bag = "login")]` on a
138    /// derived struct sets it.
139    const ERROR_BAG: Option<&'static str> = None;
140
141    /// Declares the fields' rules on `v`; they run after `prepare` and `authorize`.
142    fn rules(&self, v: &mut Validator);
143
144    /// Tidies the input before anything checks it (Laravel's
145    /// `prepareForValidation`): trim, lowercase an email, fill a slug. The
146    /// form refilled after an error still shows what was typed.
147    fn prepare(&mut self) {}
148
149    /// Whether this request may send the form (Laravel's `authorize`), e.g.
150    /// only the team's owner invites. `false` answers 403 before the rules
151    /// run. It sees the input, so it runs once the input is read.
152    fn authorize(
153        &self,
154        form: &FormContext<'_>,
155    ) -> impl std::future::Future<Output = Result<bool>> + Send {
156        let _ = form;
157        std::future::ready(Ok(true))
158    }
159
160    /// Checks that need the database, another service or several fields,
161    /// once the rules pass (Laravel's `after`, and async rules). Add
162    /// errors with `errors.add(field, message)`.
163    fn after(
164        &self,
165        form: &FormContext<'_>,
166        errors: &mut Errors,
167    ) -> impl std::future::Future<Output = Result> + Send {
168        let _ = (form, errors);
169        std::future::ready(Ok(()))
170    }
171}
172
173/// `prepare`, `authorize` and `after` for a struct with
174/// `#[derive(Validate)]`, which writes `rules` from its `#[validate(…)]`
175/// attributes: add `#[validate(hooks)]` on the struct and implement the
176/// ones you need.
177///
178/// ```
179/// # use renox::prelude::*;
180/// use renox::validation::{FormContext, ValidateHooks};
181///
182/// #[derive(serde::Deserialize, Validate)]
183/// #[validate(hooks)]
184/// struct Invite {
185///     #[validate(required, email)]
186///     email: String,
187/// }
188///
189/// impl ValidateHooks for Invite {
190///     fn prepare(&mut self) {
191///         self.email = self.email.trim().to_lowercase();
192///     }
193///
194///     async fn authorize(&self, form: &FormContext<'_>) -> Result<bool> {
195///         Ok(form.user.is_some())
196///     }
197/// }
198/// ```
199pub trait ValidateHooks {
200    /// Tidies the input before anything checks it; see [`Validate::prepare`].
201    fn prepare(&mut self) {}
202
203    /// Whether this request may send the form; `false` answers 403. See
204    /// [`Validate::authorize`].
205    fn authorize(
206        &self,
207        form: &FormContext<'_>,
208    ) -> impl std::future::Future<Output = Result<bool>> + Send {
209        let _ = form;
210        std::future::ready(Ok(true))
211    }
212
213    /// Checks run once the rules pass; see [`Validate::after`].
214    fn after(
215        &self,
216        form: &FormContext<'_>,
217        errors: &mut Errors,
218    ) -> impl std::future::Future<Output = Result> + Send {
219        let _ = (form, errors);
220        std::future::ready(Ok(()))
221    }
222}
223
224/// What [`Validate::authorize`] and [`Validate::after`] see of the request.
225#[non_exhaustive]
226pub struct FormContext<'a> {
227    /// The app's state, e.g. for its database.
228    pub state: &'a crate::AppState,
229    /// The logged-in user, if any.
230    pub user: Option<&'a crate::auth::User>,
231    /// The request's HTTP method.
232    pub method: &'a axum::http::Method,
233    /// The request's path, without the query string.
234    pub path: &'a str,
235}
236
237struct Pending {
238    field: String,
239    label: String,
240    table: String,
241    column: String,
242    value: DbValue,
243    ignore_id: Option<crate::db::DbValue>,
244    /// Extra conditions (`where_eq`, `where_null`, `where_not_null`).
245    scope: Vec<ScopeCondition>,
246    unique: bool,
247    message: Option<String>,
248}
249
250/// A check that needs the request's user or a service, run by `finish`.
251struct AsyncCheck {
252    field: String,
253    label: String,
254    kind: AsyncKind,
255    message: Option<String>,
256}
257
258enum AsyncKind {
259    /// The logged-in user's password (Laravel's `current_password`).
260    CurrentPassword(String),
261    /// Not in a known data breach (Have I Been Pwned's range API).
262    Uncompromised(String),
263}
264
265/// A condition added to a `unique`/`exists` check.
266enum ScopeCondition {
267    Eq(String, DbValue),
268    Null(String),
269    NotNull(String),
270}
271
272impl ScopeCondition {
273    fn column(&self) -> &str {
274        match self {
275            Self::Eq(column, _) | Self::Null(column) | Self::NotNull(column) => column,
276        }
277    }
278}
279
280/// SQLite reads an unknown double-quoted column as a string literal, which
281/// would make `unique("t", "typo")` always pass; refuse unknown columns.
282async fn ensure_sqlite_column(db: &Db, table: &str, column: &str) -> Result {
283    let found: i64 = crate::db::sql("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?")
284        .bind(table)
285        .bind(column)
286        .scalar(db)
287        .await?;
288    if found == 0 {
289        return Err(anyhow::anyhow!(
290            "unique/exists rule: table `{table}` has no column `{column}`"
291        )
292        .into());
293    }
294    Ok(())
295}
296
297/// Collects rule failures. Rules run in order and stop at a field's first
298/// failure; rules other than `required` and `accepted` skip empty values.
299pub struct Validator {
300    /// The request language's lang file, for overridden messages and labels.
301    texts: Option<crate::i18n::Texts>,
302    errors: Errors,
303    pending: Vec<Pending>,
304    checks: Vec<AsyncCheck>,
305}
306
307impl Default for Validator {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313impl Validator {
314    /// A validator with Renox's English messages.
315    pub fn new() -> Self {
316        Self {
317            texts: None,
318            errors: Errors::new(),
319            pending: Vec::new(),
320            checks: Vec::new(),
321        }
322    }
323
324    /// Messages and field names in `lang`, from the app's lang file
325    /// (`renox.validation.*`, `renox.validation.attributes.*`), e.g. for
326    /// rules run in a handler or a command: `Validator::new().in_lang(&lang)`.
327    pub fn in_lang(self, lang: &crate::Lang) -> Self {
328        self.with_texts(lang.texts())
329    }
330
331    /// Uses the app's translations of messages (`renox.validation.*`) and
332    /// field names (`renox.validation.attributes.*`).
333    pub(crate) fn with_texts(mut self, texts: crate::i18n::Texts) -> Self {
334        self.texts = Some(texts);
335        self
336    }
337
338    fn template(&self, key: &str) -> std::borrow::Cow<'static, str> {
339        messages::template_for(self.texts.as_ref(), key)
340    }
341
342    /// A field's name for messages: the app's translation
343    /// (`renox.validation.attributes.{name}`) or the name with spaces.
344    fn translated_label(&self, name: &str) -> Option<String> {
345        self.texts.as_ref().and_then(|t| {
346            t.get(&format!("renox.validation.attributes.{name}"))
347                .cloned()
348        })
349    }
350
351    fn label_for(&self, name: &str) -> String {
352        nested::label(name, |key| self.translated_label(key))
353    }
354
355    /// Rules for each item of a list, e.g. every tag or every uploaded
356    /// photo; errors are keyed `name.0`, `name.1`, … and labelled
357    /// "`name` #1", "#2", …
358    ///
359    /// ```
360    /// # use renox::prelude::*;
361    /// # struct Form { tags: Vec<String>, photos: Vec<Upload> }
362    /// # impl Validate for Form {
363    /// fn rules(&self, v: &mut Validator) {
364    ///     v.field("tags", &self.tags).max(5);
365    ///     v.each("tags", &self.tags, |tag| tag.required().max(20));
366    ///     v.each("photos", &self.photos, |photo| photo.image().max(2048));
367    /// }
368    /// # }
369    /// ```
370    pub fn each<T: FieldValue>(
371        &mut self,
372        name: &str,
373        items: &[T],
374        rules: impl for<'a> Fn(Field<'a>) -> Field<'a>,
375    ) {
376        let base = self.label_for(name);
377        for (i, item) in items.iter().enumerate() {
378            let key = format!("{name}.{i}");
379            let label = format!("{base} #{}", i + 1);
380            rules(self.field(&key, item).label(&label));
381        }
382    }
383
384    /// Each item's own `Validate` rules, for a list of structs (e.g. the
385    /// lines of an order sent as JSON); errors are keyed `name.0.field`.
386    pub fn nested<T: Validate>(&mut self, name: &str, items: &[T]) {
387        for (i, item) in items.iter().enumerate() {
388            let mut inner = Validator {
389                texts: self.texts.clone(),
390                errors: Errors::new(),
391                pending: Vec::new(),
392                checks: Vec::new(),
393            };
394            item.rules(&mut inner);
395            for (field, messages) in inner.errors.iter() {
396                for message in messages {
397                    self.errors
398                        .add(format!("{name}.{i}.{field}"), message.clone());
399                }
400            }
401            for mut pending in inner.pending {
402                pending.field = format!("{name}.{i}.{}", pending.field);
403                self.pending.push(pending);
404            }
405            for mut check in inner.checks {
406                check.field = format!("{name}.{i}.{}", check.field);
407                self.checks.push(check);
408            }
409        }
410    }
411
412    /// No two items of a list are the same (Laravel's `distinct`), e.g. the
413    /// emails invited at once; each repeat gets the error, keyed `name.i`.
414    pub fn distinct<T: FieldValue>(&mut self, name: &str, items: &[T]) {
415        let base = self.label_for(name);
416        let template = self.template("distinct");
417        let mut seen: Vec<Inspected> = Vec::new();
418        for (i, item) in items.iter().enumerate() {
419            let value = match item.inspect() {
420                Inspected::Text(text) => Inspected::Text(text.trim().to_lowercase()),
421                other => other,
422            };
423            if value == Inspected::Missing {
424                continue;
425            }
426            if seen.contains(&value) {
427                let label = format!("{base} #{}", i + 1);
428                self.errors
429                    .add(format!("{name}.{i}"), render(&template, &label, &[]));
430            } else {
431                seen.push(value);
432            }
433        }
434    }
435
436    /// Starts the rules for one field. The label in messages defaults to the
437    /// name with `_` replaced by spaces.
438    pub fn field<'v>(&'v mut self, name: &str, value: &impl FieldValue) -> Field<'v> {
439        let translated = self.translated_label(name).is_some();
440        let mut field = Field {
441            translated,
442            // `items.0.name` reads "name" (or the app's `items.*.name`).
443            label: self.label_for(name),
444            name: name.to_owned(),
445            value: value.inspect(),
446            db_value: value.db_value(),
447            failed: false,
448            last_pending: None,
449            last_check: None,
450            v: self,
451        };
452        // `NaN`, `inf` and `1e999` parse as floats, but aren't numbers anyone entered.
453        if let Inspected::Number(n) = field.value
454            && !n.is_finite()
455        {
456            field.fail("numeric", &[]);
457        }
458        field
459    }
460
461    /// Adds an error that no rule covers.
462    pub fn error(&mut self, field: &str, message: impl Into<String>) {
463        self.errors.add(field, message);
464    }
465
466    /// Runs the database checks and returns every error (empty when valid).
467    /// `current_password` fails here, as there's no user to check against,
468    /// and the breach check of `Password::uncompromised` is skipped; use
469    /// [`finish_for`](Self::finish_for) for those (`Valid<T>` does).
470    pub async fn finish(self, db: &Db) -> Result<Errors> {
471        self.finish_with(db, None, None).await
472    }
473
474    /// Like [`finish`](Self::finish), with the logged-in user for
475    /// `current_password` and the app's HTTP client for
476    /// `Password::uncompromised`.
477    pub async fn finish_for(
478        self,
479        state: &crate::AppState,
480        user: Option<&crate::auth::User>,
481    ) -> Result<Errors> {
482        self.finish_with(&state.db, Some(state), user).await
483    }
484
485    async fn finish_with(
486        self,
487        db: &Db,
488        state: Option<&crate::AppState>,
489        user: Option<&crate::auth::User>,
490    ) -> Result<Errors> {
491        let mut errors = self.errors;
492        for check in self.pending {
493            if errors.has(&check.field) {
494                continue;
495            }
496            let dialect = db.dialect();
497            if dialect == crate::db::Dialect::Sqlite {
498                ensure_sqlite_column(db, &check.table, &check.column).await?;
499                for condition in &check.scope {
500                    ensure_sqlite_column(db, &check.table, condition.column()).await?;
501                }
502            }
503            // Form input is text; PostgreSQL won't compare text with a number
504            // column, so compare as text (a no-op for text columns).
505            let column = match (&check.value, dialect) {
506                (DbValue::Text(_), crate::db::Dialect::Postgres) => {
507                    format!("CAST({} AS TEXT)", quote(&check.column))
508                }
509                _ => quote(&check.column),
510            };
511            let mut sql = format!(
512                "SELECT EXISTS(SELECT 1 FROM {} WHERE {column} = ?",
513                quote(&check.table),
514            );
515            if check.ignore_id.is_some() {
516                sql.push_str(" AND \"id\" != ?");
517            }
518            let mut scope_values = Vec::new();
519            for condition in check.scope {
520                match condition {
521                    ScopeCondition::Eq(column, value) => {
522                        sql.push_str(&format!(" AND {} = ?", quote(&column)));
523                        scope_values.push(value);
524                    }
525                    ScopeCondition::Null(column) => {
526                        sql.push_str(&format!(" AND {} IS NULL", quote(&column)));
527                    }
528                    ScopeCondition::NotNull(column) => {
529                        sql.push_str(&format!(" AND {} IS NOT NULL", quote(&column)));
530                    }
531                }
532            }
533            sql.push(')');
534            let mut query = crate::db::sql(sql).bind(check.value);
535            if let Some(id) = check.ignore_id {
536                query = query.bind(id);
537            }
538            let query = query.bind_all(scope_values);
539            let found: bool = query.scalar(db).await?;
540            if found == check.unique {
541                let key = if check.unique { "unique" } else { "exists" };
542                let message = check.message.unwrap_or_else(|| {
543                    render(
544                        &messages::template_for(self.texts.as_ref(), key),
545                        &check.label,
546                        &[],
547                    )
548                });
549                errors.add(check.field, message);
550            }
551        }
552        for check in self.checks {
553            if errors.has(&check.field) {
554                continue;
555            }
556            let key = match &check.kind {
557                AsyncKind::CurrentPassword(password) => match user {
558                    Some(user) if user.check_password(password).await => continue,
559                    _ => "current_password",
560                },
561                AsyncKind::Uncompromised(password) => match state {
562                    Some(state) if breached(state, password).await => "password.uncompromised",
563                    _ => continue,
564                },
565            };
566            let message = check.message.unwrap_or_else(|| {
567                render(
568                    &messages::template_for(self.texts.as_ref(), key),
569                    &check.label,
570                    &[],
571                )
572            });
573            errors.add(check.field, message);
574        }
575        Ok(errors)
576    }
577
578    /// Like `rules_of`, with the app's translations of messages and labels.
579    pub(crate) fn rules_with_texts(data: &impl Validate, texts: crate::i18n::Texts) -> Self {
580        let mut validator = Self::new().with_texts(texts);
581        data.rules(&mut validator);
582        validator
583    }
584
585    /// Applies `data`'s rules; call `finish` to run the database checks.
586    ///
587    /// ```
588    /// # use renox::prelude::*;
589    /// # #[derive(serde::Deserialize)] struct ProductForm { name: String }
590    /// # impl Validate for ProductForm { fn rules(&self, v: &mut Validator) { v.field("name", &self.name).required(); } }
591    /// # async fn demo(form: ProductForm, db: Db) -> Result {
592    /// let errors = Validator::rules_of(&form).finish(&db).await?;
593    /// # let _ = errors; Ok(()) }
594    /// ```
595    pub fn rules_of(data: &impl Validate) -> Self {
596        let mut validator = Self::new();
597        data.rules(&mut validator);
598        validator
599    }
600}
601
602/// The rules for one field, chained: `v.field("name", &self.name).required().max(100)`.
603pub struct Field<'v> {
604    v: &'v mut Validator,
605    name: String,
606    label: String,
607    /// The label came from the app's lang file.
608    translated: bool,
609    value: Inspected,
610    db_value: DbValue,
611    failed: bool,
612    last_pending: Option<usize>,
613    /// The `current_password`/`uncompromised` check just added, for `message`.
614    last_check: Option<usize>,
615}
616
617fn number(n: f64) -> String {
618    if n.fract() == 0.0 && n.abs() < 1e15 {
619        format!("{}", n as i64)
620    } else {
621        n.to_string()
622    }
623}
624
625impl Field<'_> {
626    /// The name used in messages, e.g. `.label("sale price")`.
627    pub fn label(mut self, label: &str) -> Self {
628        self.label = label.to_owned();
629        self
630    }
631
632    /// Uses `label` unless the app's lang file names this field.
633    pub(crate) fn fallback_label(mut self, label: &str) -> Self {
634        if !self.translated {
635            self.label = label.to_owned();
636        }
637        self
638    }
639
640    fn fail(&mut self, key: &str, params: &[(&str, String)]) {
641        if !self.failed {
642            let message = render(&self.v.template(key), &self.label, params);
643            self.v.errors.add(&self.name, message);
644            self.failed = true;
645            self.last_pending = None;
646            self.last_check = None;
647        }
648    }
649
650    fn check(&mut self, kind: AsyncKind) {
651        self.v.checks.push(AsyncCheck {
652            field: self.name.clone(),
653            label: self.label.clone(),
654            kind,
655            message: None,
656        });
657        self.last_pending = None;
658        self.last_check = Some(self.v.checks.len() - 1);
659    }
660
661    fn present(&self) -> bool {
662        !self.failed && self.value != Inspected::Missing
663    }
664
665    /// Replaces this field's error so far with `message`: the error of the
666    /// rule just before it, or of any earlier rule that failed (only one error
667    /// is kept per field). A database or async check just before it gets the
668    /// message when it fails.
669    pub fn message(self, message: impl Into<String>) -> Self {
670        let message = message.into();
671        if let Some(i) = self.last_pending {
672            self.v.pending[i].message = Some(message);
673        } else if let Some(i) = self.last_check {
674            self.v.checks[i].message = Some(message);
675        } else if self.failed
676            && let Some(last) = self
677                .v
678                .errors
679                .0
680                .get_mut(&self.name)
681                .and_then(|messages| messages.last_mut())
682        {
683            *last = message;
684        }
685        self
686    }
687
688    /// Fails when the value is missing: `None`, or text that is blank after trimming.
689    pub fn required(mut self) -> Self {
690        if !self.failed && self.value == Inspected::Missing {
691            self.fail("required", &[]);
692        }
693        self
694    }
695
696    fn size_rule(
697        mut self,
698        kind: &str,
699        ok: impl Fn(f64) -> bool,
700        params: &[(&str, String)],
701    ) -> Self {
702        if !self.present() {
703            return self;
704        }
705        let (size, suffix) = match &self.value {
706            Inspected::Text(text) => (text.chars().count() as f64, "string"),
707            Inspected::Number(n) => (*n, "numeric"),
708            Inspected::Items(n) => (*n as f64, "array"),
709            Inspected::File { kilobytes, .. } => (*kilobytes, "file"),
710            _ => return self,
711        };
712        if !ok(size) {
713            self.fail(&format!("{kind}.{suffix}"), params);
714        }
715        self
716    }
717
718    /// At least `min` characters, items, kilobytes (files), or as a number.
719    pub fn min(self, min: impl Into<f64>) -> Self {
720        let min = min.into();
721        self.size_rule("min", |s| s >= min, &[("min", number(min))])
722    }
723
724    /// At most `max` characters, items, kilobytes (files), or as a number.
725    pub fn max(self, max: impl Into<f64>) -> Self {
726        let max = max.into();
727        self.size_rule("max", |s| s <= max, &[("max", number(max))])
728    }
729
730    /// Between `min` and `max` inclusive, measured as `min`/`max` do.
731    pub fn between(self, min: impl Into<f64>, max: impl Into<f64>) -> Self {
732        let (min, max) = (min.into(), max.into());
733        self.size_rule(
734            "between",
735            |s| s >= min && s <= max,
736            &[("min", number(min)), ("max", number(max))],
737        )
738    }
739
740    /// An email address: something before a single `@`, a dotted domain, no spaces.
741    pub fn email(mut self) -> Self {
742        if let (true, Inspected::Text(text)) = (self.present(), &self.value)
743            && !is_email(text)
744        {
745            self.fail("email", &[]);
746        }
747        self
748    }
749
750    /// An `http://` or `https://` URL with a host and no spaces.
751    pub fn url(mut self) -> Self {
752        if let (true, Inspected::Text(text)) = (self.present(), &self.value)
753            && !is_url(text)
754        {
755            self.fail("url", &[]);
756        }
757        self
758    }
759
760    /// An uploaded image: PNG, JPEG, GIF or WebP, checked from the file's
761    /// content rather than its name.
762    pub fn image(mut self) -> Self {
763        if let (true, Inspected::File { image, .. }) = (self.present(), &self.value)
764            && !*image
765        {
766            self.fail("image", &[]);
767        }
768        self
769    }
770
771    /// An uploaded file of one of these types, e.g. `&["jpg", "png", "pdf"]`
772    /// (`jpeg` counts as `jpg`). The content decides for the formats Renox can
773    /// recognise, the file name for the rest.
774    pub fn mimes(mut self, extensions: &[&str]) -> Self {
775        if let (true, Inspected::File { extension, .. }) = (self.present(), &self.value) {
776            let normalise = |e: &str| match e.to_ascii_lowercase().as_str() {
777                "jpeg" => "jpg".to_owned(),
778                other => other.to_owned(),
779            };
780            let ok = extensions
781                .iter()
782                .any(|e| normalise(e) == normalise(extension));
783            if !ok {
784                self.fail("mimes", &[("values", extensions.join(", "))]);
785            }
786        }
787        self
788    }
789
790    /// One of the given values (Laravel's `in`).
791    pub fn one_of<V: FieldValue>(mut self, allowed: &[V]) -> Self {
792        if self.present() && !allowed.iter().any(|a| a.inspect() == self.value) {
793            self.fail("in", &[]);
794        }
795        self
796    }
797
798    /// Equal to its confirmation field, e.g. `password` and `password_confirmation`.
799    pub fn confirmed(mut self, confirmation: &impl FieldValue) -> Self {
800        if self.present() && confirmation.inspect() != self.value {
801            self.fail("confirmed", &[]);
802        }
803        self
804    }
805
806    /// A checkbox that must be ticked.
807    pub fn accepted(mut self) -> Self {
808        if !self.failed && self.value != Inspected::Bool(true) {
809            self.fail("accepted", &[]);
810        }
811        self
812    }
813
814    /// A custom check: fails with `message` when `valid` is false.
815    pub fn rule(mut self, valid: bool, message: impl Into<String>) -> Self {
816        if !self.failed && !valid {
817            self.v.errors.add(&self.name, message);
818            self.failed = true;
819            self.last_pending = None;
820            self.last_check = None;
821        }
822        self
823    }
824
825    /// The whole text matches `pattern` (a regular expression; anchor it
826    /// with `^…$` to match all of it), e.g. `.matches(r"^[A-Z]{2}\d{4}$")`.
827    pub fn matches(mut self, pattern: &str) -> Self {
828        if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
829            let ok = match cached_regex(pattern) {
830                Ok(regex) => regex.is_match(text),
831                Err(err) => {
832                    tracing::error!(pattern, error = %err, "invalid pattern in a `matches` rule");
833                    false
834                }
835            };
836            if !ok {
837                self.fail("regex", &[]);
838            }
839        }
840        self
841    }
842
843    /// Exactly `n` digits (and nothing else), e.g. a PIN.
844    pub fn digits(mut self, n: usize) -> Self {
845        if self.present() && digit_count(&self.value) != Some(n) {
846            self.fail("digits", &[("digits", n.to_string())]);
847        }
848        self
849    }
850
851    /// Between `min` and `max` digits (and nothing else), e.g. a phone number.
852    pub fn digits_between(mut self, min: usize, max: usize) -> Self {
853        if self.present() && !digit_count(&self.value).is_some_and(|n| n >= min && n <= max) {
854            self.fail(
855                "digits_between",
856                &[("min", min.to_string()), ("max", max.to_string())],
857            );
858        }
859        self
860    }
861
862    /// A date (`2026-10-01`) or a date and time (`2026-10-01T10:30`).
863    pub fn date(mut self) -> Self {
864        if self.present() && self.as_date().is_none() {
865            self.fail("date", &[]);
866        }
867        self
868    }
869
870    fn date_rule(
871        mut self,
872        key: &str,
873        limit: NaiveDateTime,
874        ok: impl Fn(NaiveDateTime, NaiveDateTime) -> bool,
875    ) -> Self {
876        if !self.present() {
877            return self;
878        }
879        match self.as_date() {
880            None => self.fail("date", &[]),
881            Some(date) if !ok(date, limit) => {
882                let shown = if limit.time() == chrono::NaiveTime::MIN {
883                    limit.date().to_string()
884                } else {
885                    limit.format("%Y-%m-%d %H:%M").to_string()
886                };
887                self.fail(key, &[("date", shown)]);
888            }
889            Some(_) => {}
890        }
891        self
892    }
893
894    /// A date before `limit` (a `NaiveDate`, `NaiveDateTime` or `DateTime`,
895    /// or text such as `"2026-01-01"`), e.g. `.before(today)` for a birth date.
896    pub fn before(self, limit: impl FieldValue) -> Self {
897        match limit_date(&limit) {
898            Some(limit) => self.date_rule("before", limit, |d, l| d < l),
899            None => self,
900        }
901    }
902
903    /// A date on or before `limit`.
904    pub fn before_or_equal(self, limit: impl FieldValue) -> Self {
905        match limit_date(&limit) {
906            Some(limit) => self.date_rule("before_or_equal", limit, |d, l| d <= l),
907            None => self,
908        }
909    }
910
911    /// A date after `limit`, e.g. `.after(self.start)` for an end date.
912    pub fn after(self, limit: impl FieldValue) -> Self {
913        match limit_date(&limit) {
914            Some(limit) => self.date_rule("after", limit, |d, l| d > l),
915            None => self,
916        }
917    }
918
919    /// A date on or after `limit`.
920    pub fn after_or_equal(self, limit: impl FieldValue) -> Self {
921        match limit_date(&limit) {
922            Some(limit) => self.date_rule("after_or_equal", limit, |d, l| d >= l),
923            None => self,
924        }
925    }
926
927    fn as_date(&self) -> Option<NaiveDateTime> {
928        match &self.value {
929            Inspected::Date(date) => Some(*date),
930            Inspected::Text(text) => parse_date(text.trim()),
931            _ => None,
932        }
933    }
934
935    /// None of the given values (Laravel's `not_in`).
936    pub fn none_of<V: FieldValue>(mut self, refused: &[V]) -> Self {
937        if self.present() && refused.iter().any(|r| r.inspect() == self.value) {
938            self.fail("not_in", &[]);
939        }
940        self
941    }
942
943    fn text_rule(
944        mut self,
945        key: &str,
946        ok: impl Fn(&str) -> bool,
947        params: &[(&str, String)],
948    ) -> Self {
949        if let (true, Inspected::Text(text)) = (self.present(), &self.value)
950            && !ok(text)
951        {
952            self.fail(key, params);
953        }
954        self
955    }
956
957    /// Letters only (any language's: `é`, `ü`, `ß` count).
958    pub fn alpha(self) -> Self {
959        self.text_rule("alpha", |t| t.chars().all(char::is_alphabetic), &[])
960    }
961
962    /// Letters and digits only.
963    pub fn alpha_num(self) -> Self {
964        self.text_rule("alpha_num", |t| t.chars().all(char::is_alphanumeric), &[])
965    }
966
967    /// Letters, digits, `-` and `_`, e.g. a username or a slug.
968    pub fn alpha_dash(self) -> Self {
969        self.text_rule(
970            "alpha_dash",
971            |t| {
972                t.chars()
973                    .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
974            },
975            &[],
976        )
977    }
978
979    /// No uppercase letters.
980    pub fn lowercase(self) -> Self {
981        self.text_rule("lowercase", |t| !t.chars().any(char::is_uppercase), &[])
982    }
983
984    /// No lowercase letters.
985    pub fn uppercase(self) -> Self {
986        self.text_rule("uppercase", |t| !t.chars().any(char::is_lowercase), &[])
987    }
988
989    /// Starts with one of `prefixes`, e.g. `.starts_with(&["08", "+62"])`.
990    pub fn starts_with(self, prefixes: &[&str]) -> Self {
991        let values = prefixes.join(", ");
992        self.text_rule(
993            "starts_with",
994            |t| prefixes.iter().any(|p| t.starts_with(p)),
995            &[("values", values)],
996        )
997    }
998
999    /// Ends with one of `suffixes`, e.g. `.ends_with(&["@company.com"])`.
1000    pub fn ends_with(self, suffixes: &[&str]) -> Self {
1001        let values = suffixes.join(", ");
1002        self.text_rule(
1003            "ends_with",
1004            |t| suffixes.iter().any(|s| t.ends_with(s)),
1005            &[("values", values)],
1006        )
1007    }
1008
1009    /// A UUID (`8-4-4-4-12` hexadecimal digits).
1010    pub fn uuid(self) -> Self {
1011        self.text_rule("uuid", is_uuid, &[])
1012    }
1013
1014    /// An IPv4 or IPv6 address.
1015    pub fn ip(self) -> Self {
1016        self.text_rule("ip", |t| t.trim().parse::<std::net::IpAddr>().is_ok(), &[])
1017    }
1018
1019    /// Exactly `size` characters, items, kilobytes (files), or equal to it as
1020    /// a number (Laravel's `size`).
1021    pub fn size(self, size: impl Into<f64>) -> Self {
1022        let size = size.into();
1023        self.size_rule("size", |s| s == size, &[("size", number(size))])
1024    }
1025
1026    /// Required when `other` is empty, e.g. an email when there's no phone.
1027    pub fn required_without(self, other: &impl FieldValue) -> Self {
1028        let missing = other.inspect() == Inspected::Missing;
1029        self.required_if(missing)
1030    }
1031
1032    /// Must be empty when `condition` holds (Laravel's `prohibited_if`), e.g.
1033    /// no discount code on a gift card order.
1034    pub fn prohibited_if(mut self, condition: bool) -> Self {
1035        if condition && !self.failed && self.value != Inspected::Missing {
1036            self.fail("prohibited", &[]);
1037        }
1038        self
1039    }
1040
1041    /// Required when `condition` holds, e.g.
1042    /// `.required_if(self.kind == "company")` for a company name.
1043    pub fn required_if(self, condition: bool) -> Self {
1044        if condition { self.required() } else { self }
1045    }
1046
1047    /// Required unless `condition` holds.
1048    pub fn required_unless(self, condition: bool) -> Self {
1049        self.required_if(!condition)
1050    }
1051
1052    /// Required when `other` has a value, e.g. a phone number's country
1053    /// code when a phone number is given.
1054    pub fn required_with(self, other: &impl FieldValue) -> Self {
1055        let given = other.inspect() != Inspected::Missing;
1056        self.required_if(given)
1057    }
1058
1059    /// Equal to another field, named `other` in the message.
1060    pub fn same(mut self, other: &str, value: &impl FieldValue) -> Self {
1061        if self.present() && value.inspect() != self.value {
1062            let other = self.v.label_for(other);
1063            self.fail("same", &[("other", other)]);
1064        }
1065        self
1066    }
1067
1068    /// Different from another field, named `other` in the message.
1069    pub fn different(mut self, other: &str, value: &impl FieldValue) -> Self {
1070        if self.present() && value.inspect() == self.value {
1071            let other = self.v.label_for(other);
1072            self.fail("different", &[("other", other)]);
1073        }
1074        self
1075    }
1076
1077    fn compare_rule(
1078        mut self,
1079        key: &str,
1080        other: &str,
1081        value: &impl FieldValue,
1082        ok: fn(std::cmp::Ordering) -> bool,
1083    ) -> Self {
1084        if !self.present() {
1085            return self;
1086        }
1087        let other_value = value.inspect();
1088        if other_value == Inspected::Missing {
1089            return self;
1090        }
1091        // Two texts that both read as numbers compare as numbers (a price
1092        // in a `String`); other text compares by length.
1093        let both_numeric = matches!(
1094            (&self.value, &other_value),
1095            (Inspected::Text(_), Inspected::Text(_))
1096        ) && numeric_value(&self.value).is_some()
1097            && numeric_value(&other_value).is_some();
1098        let measured = |value: &Inspected| -> Option<(f64, &'static str)> {
1099            match value {
1100                Inspected::Text(_) if both_numeric => Some((numeric_value(value)?, "numeric")),
1101                Inspected::Number(n) => Some((*n, "numeric")),
1102                Inspected::Text(text) => Some((text.chars().count() as f64, "string")),
1103                Inspected::Items(n) => Some((*n as f64, "array")),
1104                Inspected::File { kilobytes, .. } => Some((*kilobytes, "file")),
1105                _ => None,
1106            }
1107        };
1108        let label = self.v.label_for(other);
1109        // Dates compare as dates, whatever form they came in.
1110        let as_date = |value: &Inspected| match value {
1111            Inspected::Date(date) => Some(*date),
1112            Inspected::Text(text) => parse_date(text.trim()),
1113            _ => None,
1114        };
1115        let dates = as_date(&self.value).zip(as_date(&other_value));
1116        if let Some((a, b)) = dates {
1117            if !ok(a.cmp(&b)) {
1118                self.fail(&format!("{key}.date"), &[("other", label)]);
1119            }
1120            return self;
1121        }
1122        match (measured(&self.value), measured(&other_value)) {
1123            (Some((a, kind)), Some((b, other_kind))) if kind == other_kind => {
1124                if !a.partial_cmp(&b).is_some_and(ok) {
1125                    self.fail(&format!("{key}.{kind}"), &[("other", label)]);
1126                }
1127            }
1128            _ => self.fail(&format!("{key}.numeric"), &[("other", label)]),
1129        }
1130        self
1131    }
1132
1133    /// Greater than another field (Laravel's `gt`), named `other` in the
1134    /// message: numbers by value (also two texts that both read as
1135    /// numbers), dates by date, other text by length, lists by items,
1136    /// files by size, e.g. a maximum price above the minimum. Skipped when
1137    /// the other field is empty.
1138    pub fn gt(self, other: &str, value: &impl FieldValue) -> Self {
1139        self.compare_rule("gt", other, value, |o| o.is_gt())
1140    }
1141
1142    /// Greater than or equal to another field; see [`gt`](Self::gt).
1143    pub fn gte(self, other: &str, value: &impl FieldValue) -> Self {
1144        self.compare_rule("gte", other, value, |o| o.is_ge())
1145    }
1146
1147    /// Less than another field; see [`gt`](Self::gt).
1148    pub fn lt(self, other: &str, value: &impl FieldValue) -> Self {
1149        self.compare_rule("lt", other, value, |o| o.is_lt())
1150    }
1151
1152    /// Less than or equal to another field; see [`gt`](Self::gt).
1153    pub fn lte(self, other: &str, value: &impl FieldValue) -> Self {
1154        self.compare_rule("lte", other, value, |o| o.is_le())
1155    }
1156
1157    /// A number with `min` to `max` decimal places (Laravel's `decimal`),
1158    /// e.g. `.decimal(2, 2)` for a price typed as `12.50`. A text field
1159    /// keeps what was typed; an `f64` field has lost trailing zeros
1160    /// (`12.50` is `12.5`), so check prices as text.
1161    pub fn decimal(mut self, min: usize, max: usize) -> Self {
1162        if !self.present() {
1163            return self;
1164        }
1165        let text = match &self.value {
1166            Inspected::Text(text) => text.trim().to_owned(),
1167            Inspected::Number(n) => n.to_string(),
1168            _ => String::new(),
1169        };
1170        let places = decimal_places(&text);
1171        if !places.is_some_and(|p| p >= min && p <= max) {
1172            let places = if min == max {
1173                min.to_string()
1174            } else {
1175                format!("{min}-{max}")
1176            };
1177            self.fail("decimal", &[("decimal", places)]);
1178        }
1179        self
1180    }
1181
1182    /// An uploaded image within `limits`, e.g.
1183    /// `.dimensions(&Dimensions::new().min_width(400).ratio(16, 9))`; see
1184    /// [`Dimensions`]. A file that isn't an image fails.
1185    pub fn dimensions(mut self, limits: &Dimensions) -> Self {
1186        if let (true, Inspected::File { dimensions, .. }) = (self.present(), &self.value) {
1187            let ok = dimensions.is_some_and(|(w, h)| limits.allows(w, h));
1188            if !ok {
1189                self.fail("dimensions", &[]);
1190            }
1191        }
1192        self
1193    }
1194
1195    /// Must be empty (Laravel's `prohibited`), e.g. a field only an admin
1196    /// may send, checked for everyone else.
1197    pub fn prohibited(self) -> Self {
1198        self.prohibited_if(true)
1199    }
1200
1201    /// Must be empty unless `condition` holds.
1202    pub fn prohibited_unless(self, condition: bool) -> Self {
1203        self.prohibited_if(!condition)
1204    }
1205
1206    /// When this field has a value, `other` must be empty (Laravel's
1207    /// `prohibits`), e.g. a coupon code and a gift card can't both be used.
1208    pub fn prohibits(mut self, other: &str, value: &impl FieldValue) -> Self {
1209        if self.present() && value.inspect() != Inspected::Missing {
1210            let other = self.v.label_for(other);
1211            self.fail("prohibits", &[("other", other)]);
1212        }
1213        self
1214    }
1215
1216    /// Required when every one of `others` has a value.
1217    pub fn required_with_all(self, others: &[&dyn FieldValue]) -> Self {
1218        let all = others.iter().all(|o| o.inspect() != Inspected::Missing);
1219        self.required_if(all)
1220    }
1221
1222    /// Required when none of `others` has a value, e.g. one way to reach
1223    /// the customer at least.
1224    pub fn required_without_all(self, others: &[&dyn FieldValue]) -> Self {
1225        let none = others.iter().all(|o| o.inspect() == Inspected::Missing);
1226        self.required_if(none)
1227    }
1228
1229    /// An integer of at least `min` digits.
1230    pub fn min_digits(mut self, min: usize) -> Self {
1231        if self.present() && !integer_digits(&self.value).is_some_and(|n| n >= min) {
1232            self.fail("min_digits", &[("min", min.to_string())]);
1233        }
1234        self
1235    }
1236
1237    /// An integer of at most `max` digits.
1238    pub fn max_digits(mut self, max: usize) -> Self {
1239        if self.present() && !integer_digits(&self.value).is_some_and(|n| n <= max) {
1240            self.fail("max_digits", &[("max", max.to_string())]);
1241        }
1242        self
1243    }
1244
1245    /// A multiple of `step`, e.g. `.multiple_of(500)` for amounts in
1246    /// steps of 500, or `.multiple_of(0.25)`.
1247    pub fn multiple_of(mut self, step: impl Into<f64>) -> Self {
1248        let step = step.into();
1249        if !self.present() {
1250            return self;
1251        }
1252        let ok = numeric_value(&self.value).is_some_and(|n| {
1253            let ratio = n / step;
1254            step != 0.0 && (ratio - ratio.round()).abs() < 1e-9
1255        });
1256        if !ok {
1257            self.fail("multiple_of", &[("value", number(step))]);
1258        }
1259        self
1260    }
1261
1262    /// A number: a number field, or text that reads as one (`12`, `-3.5`).
1263    pub fn numeric(mut self) -> Self {
1264        if self.present() && numeric_value(&self.value).is_none() {
1265            self.fail("numeric", &[]);
1266        }
1267        self
1268    }
1269
1270    /// A whole number: a number field without a fraction, or text that
1271    /// reads as one.
1272    pub fn integer(mut self) -> Self {
1273        if self.present() && !numeric_value(&self.value).is_some_and(|n| n.fract() == 0.0) {
1274            self.fail("integer", &[]);
1275        }
1276        self
1277    }
1278
1279    /// Valid JSON text, e.g. a settings field edited by hand.
1280    pub fn json(self) -> Self {
1281        self.text_rule(
1282            "json",
1283            |t| serde_json::from_str::<serde_json::Value>(t).is_ok(),
1284            &[],
1285        )
1286    }
1287
1288    /// A ULID (26 characters of Crockford's base 32).
1289    pub fn ulid(self) -> Self {
1290        self.text_rule("ulid", |t| t.trim().parse::<crate::db::Ulid>().is_ok(), &[])
1291    }
1292
1293    /// An IANA time zone name, e.g. `Asia/Jakarta`, or `UTC`.
1294    pub fn timezone(self) -> Self {
1295        self.text_rule(
1296            "timezone",
1297            |t| t.trim().parse::<chrono_tz::Tz>().is_ok(),
1298            &[],
1299        )
1300    }
1301
1302    /// A MAC address: `00:1A:2B:3C:4D:5E`, with `-` as well, or
1303    /// `001A.2B3C.4D5E`.
1304    pub fn mac_address(self) -> Self {
1305        self.text_rule("mac_address", is_mac_address, &[])
1306    }
1307
1308    /// ASCII characters only.
1309    pub fn ascii(self) -> Self {
1310        self.text_rule("ascii", |t| t.is_ascii(), &[])
1311    }
1312
1313    /// A hex colour: `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA`.
1314    pub fn hex_color(self) -> Self {
1315        self.text_rule(
1316            "hex_color",
1317            |t| {
1318                t.strip_prefix('#').is_some_and(|hex| {
1319                    matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
1320                })
1321            },
1322            &[],
1323        )
1324    }
1325
1326    /// Doesn't start with any of `prefixes`.
1327    pub fn doesnt_start_with(self, prefixes: &[&str]) -> Self {
1328        let values = prefixes.join(", ");
1329        self.text_rule(
1330            "doesnt_start_with",
1331            |t| !prefixes.iter().any(|p| t.starts_with(p)),
1332            &[("values", values)],
1333        )
1334    }
1335
1336    /// Doesn't end with any of `suffixes`.
1337    pub fn doesnt_end_with(self, suffixes: &[&str]) -> Self {
1338        let values = suffixes.join(", ");
1339        self.text_rule(
1340            "doesnt_end_with",
1341            |t| !suffixes.iter().any(|s| t.ends_with(s)),
1342            &[("values", values)],
1343        )
1344    }
1345
1346    /// The text does not match `pattern` (Laravel's `not_regex`).
1347    pub fn not_matches(mut self, pattern: &str) -> Self {
1348        if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
1349            let matched = match cached_regex(pattern) {
1350                Ok(regex) => regex.is_match(text),
1351                Err(err) => {
1352                    tracing::error!(pattern, error = %err, "invalid pattern in a `not_matches` rule");
1353                    true
1354                }
1355            };
1356            if matched {
1357                self.fail("not_regex", &[]);
1358            }
1359        }
1360        self
1361    }
1362
1363    /// A checkbox that must be ticked when `condition` holds.
1364    pub fn accepted_if(self, condition: bool) -> Self {
1365        if condition { self.accepted() } else { self }
1366    }
1367
1368    /// Must be declined: an unticked checkbox (`false`), or `no`, `off`,
1369    /// `0` or `false`, e.g. "Don't share my data" answered no.
1370    pub fn declined(mut self) -> Self {
1371        let declined = match &self.value {
1372            Inspected::Bool(b) => !b,
1373            Inspected::Number(n) => *n == 0.0,
1374            Inspected::Text(text) => {
1375                matches!(
1376                    text.trim().to_ascii_lowercase().as_str(),
1377                    "no" | "off" | "0" | "false"
1378                )
1379            }
1380            _ => false,
1381        };
1382        if !self.failed && !declined {
1383            self.fail("declined", &[]);
1384        }
1385        self
1386    }
1387
1388    /// Must be declined when `condition` holds; see [`declined`](Self::declined).
1389    pub fn declined_if(self, condition: bool) -> Self {
1390        if condition { self.declined() } else { self }
1391    }
1392
1393    /// The value meets `policy` (length, letters, mixed case, numbers,
1394    /// symbols); see [`Password`].
1395    pub fn password(mut self, policy: &Password) -> Self {
1396        if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
1397            match policy.broken(text) {
1398                Some((key, params)) => self.fail(key, &params),
1399                None if policy.uncompromised => {
1400                    let text = text.clone();
1401                    self.check(AsyncKind::Uncompromised(text));
1402                }
1403                None => {}
1404            }
1405        }
1406        self
1407    }
1408
1409    /// The logged-in user's password (Laravel's `current_password`), e.g.
1410    /// before changing an email address. Checked last, once the other
1411    /// rules pass; it fails when no one is logged in. Needs
1412    /// [`Validator::finish_for`], which `Valid<T>` uses.
1413    pub fn current_password(mut self) -> Self {
1414        if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
1415            let text = text.clone();
1416            self.check(AsyncKind::CurrentPassword(text));
1417        }
1418        self
1419    }
1420
1421    /// A reusable rule; see [`Rule`].
1422    pub fn apply(mut self, rule: &impl Rule) -> Self {
1423        if !self.present() {
1424            return self;
1425        }
1426        if let Err(message) = rule.check(&self.value) {
1427            let message = render(&message, &self.label, &[]);
1428            self.v.errors.add(&self.name, message);
1429            self.failed = true;
1430            self.last_pending = None;
1431            self.last_check = None;
1432        }
1433        self
1434    }
1435
1436    fn database(mut self, table: &str, column: &str, unique: bool) -> Self {
1437        if self.present() {
1438            self.v.pending.push(Pending {
1439                field: self.name.clone(),
1440                label: self.label.clone(),
1441                table: table.to_owned(),
1442                column: column.to_owned(),
1443                value: self.db_value.clone(),
1444                ignore_id: None,
1445                scope: Vec::new(),
1446                unique,
1447                message: None,
1448            });
1449            self.last_pending = Some(self.v.pending.len() - 1);
1450            self.last_check = None;
1451        }
1452        self
1453    }
1454
1455    /// No row in `table` has this value in `column`.
1456    pub fn unique(self, table: &str, column: &str) -> Self {
1457        self.database(table, column, true)
1458    }
1459
1460    /// Skips the row with this id in the preceding `unique`, for updates:
1461    /// `.unique("products", "sku").ignore(product.id)`, whatever the key's
1462    /// type (`i64`, `Ulid`, `Uuid`, `String`).
1463    pub fn ignore(self, id: impl crate::db::ToDbValue) -> Self {
1464        if let Some(i) = self.last_pending {
1465            self.v.pending[i].ignore_id = Some(id.to_db_value());
1466        }
1467        self
1468    }
1469
1470    /// Some row in `table` has this value in `column`.
1471    pub fn exists(self, table: &str, column: &str) -> Self {
1472        self.database(table, column, false)
1473    }
1474
1475    /// Only rows where `column = value` count for the preceding `unique` or
1476    /// `exists`, e.g. the current team's: SKUs are unique per team.
1477    ///
1478    /// ```
1479    /// # use renox::prelude::*;
1480    /// # struct ProductForm { id: i64, sku: String, category_id: i64, team_id: i64 }
1481    /// # impl Validate for ProductForm {
1482    /// fn rules(&self, v: &mut Validator) {
1483    ///     v.field("sku", &self.sku)
1484    ///         .unique("products", "sku")
1485    ///         .ignore(self.id)
1486    ///         .where_eq("team_id", self.team_id)
1487    ///         .where_null("deleted_at"); // soft-deleted rows don't count
1488    ///     v.field("category_id", &self.category_id)
1489    ///         .exists("categories", "id")
1490    ///         .where_eq("team_id", self.team_id); // not another team's category
1491    /// }
1492    /// # }
1493    /// ```
1494    pub fn where_eq(self, column: &str, value: impl ToDbValue) -> Self {
1495        self.scope(ScopeCondition::Eq(column.to_owned(), value.to_db_value()))
1496    }
1497
1498    /// Only rows where `column` is null count (e.g. `deleted_at`).
1499    pub fn where_null(self, column: &str) -> Self {
1500        self.scope(ScopeCondition::Null(column.to_owned()))
1501    }
1502
1503    /// Only rows where `column` is not null count.
1504    pub fn where_not_null(self, column: &str) -> Self {
1505        self.scope(ScopeCondition::NotNull(column.to_owned()))
1506    }
1507
1508    fn scope(self, condition: ScopeCondition) -> Self {
1509        if let Some(i) = self.last_pending {
1510            self.v.pending[i].scope.push(condition);
1511        }
1512        self
1513    }
1514}
1515
1516/// What a password must contain; `Field::password(&policy)` checks it.
1517/// The built-in register, reset and account forms use the app's policy
1518/// (`Auth::password_rules`), `Password::min(8)` by default.
1519///
1520/// ```
1521/// # use renox::prelude::*;
1522/// use renox::validation::Password;
1523/// # struct Form { password: String }
1524/// # impl Validate for Form {
1525/// fn rules(&self, v: &mut Validator) {
1526///     let policy = Password::min(12).mixed_case().numbers().symbols();
1527///     v.field("password", &self.password).required().password(&policy);
1528/// }
1529/// # }
1530/// ```
1531#[derive(Debug, Clone, PartialEq, Eq)]
1532pub struct Password {
1533    min: usize,
1534    letters: bool,
1535    mixed_case: bool,
1536    numbers: bool,
1537    symbols: bool,
1538    uncompromised: bool,
1539}
1540
1541impl Password {
1542    /// At least `min` characters.
1543    pub fn min(min: usize) -> Self {
1544        Self {
1545            min,
1546            letters: false,
1547            mixed_case: false,
1548            numbers: false,
1549            symbols: false,
1550            uncompromised: false,
1551        }
1552    }
1553
1554    /// Not a password found in a known data breach (Laravel's
1555    /// `uncompromised`), checked with Have I Been Pwned: only the first
1556    /// five characters of the password's SHA-1 leave the server
1557    /// (k-anonymity), through `state.http` (faked in tests). When the
1558    /// service can't be reached the password is allowed and a warning
1559    /// logged. Runs once the other rules pass.
1560    pub fn uncompromised(mut self) -> Self {
1561        self.uncompromised = true;
1562        self
1563    }
1564
1565    /// At least one letter.
1566    pub fn letters(mut self) -> Self {
1567        self.letters = true;
1568        self
1569    }
1570
1571    /// At least one uppercase and one lowercase letter.
1572    pub fn mixed_case(mut self) -> Self {
1573        self.mixed_case = true;
1574        self
1575    }
1576
1577    /// At least one digit.
1578    pub fn numbers(mut self) -> Self {
1579        self.numbers = true;
1580        self
1581    }
1582
1583    /// At least one character that isn't a letter, digit or space.
1584    pub fn symbols(mut self) -> Self {
1585        self.symbols = true;
1586        self
1587    }
1588
1589    /// The first rule `password` breaks: a message key and its parameters.
1590    fn broken(&self, password: &str) -> Option<(&'static str, Vec<(&'static str, String)>)> {
1591        if password.chars().count() < self.min {
1592            return Some(("min.string", vec![("min", self.min.to_string())]));
1593        }
1594        let has = |test: fn(&char) -> bool| password.chars().any(|c| test(&c));
1595        if self.letters && !has(|c| c.is_alphabetic()) {
1596            return Some(("password.letters", Vec::new()));
1597        }
1598        if self.mixed_case && !(has(|c| c.is_uppercase()) && has(|c| c.is_lowercase())) {
1599            return Some(("password.mixed", Vec::new()));
1600        }
1601        if self.numbers && !has(|c| c.is_numeric()) {
1602            return Some(("password.numbers", Vec::new()));
1603        }
1604        if self.symbols && !has(|c| !c.is_alphanumeric() && !c.is_whitespace()) {
1605            return Some(("password.symbols", Vec::new()));
1606        }
1607        None
1608    }
1609}
1610
1611impl Default for Password {
1612    fn default() -> Self {
1613        Self::min(8)
1614    }
1615}
1616
1617/// A rule to reuse across forms, e.g. a tax number:
1618///
1619/// ```
1620/// # use renox::prelude::*;
1621/// use renox::validation::{Inspected, Rule};
1622///
1623/// struct TaxId;
1624///
1625/// impl Rule for TaxId {
1626///     fn check(&self, value: &Inspected) -> std::result::Result<(), String> {
1627///         let Inspected::Text(text) = value else { return Ok(()) };
1628///         let digits = text.chars().filter(char::is_ascii_digit).count();
1629///         if digits == 15 || digits == 16 {
1630///             Ok(())
1631///         } else {
1632///             Err("The :attribute must be a valid tax ID.".into()) // :attribute is the field's label
1633///         }
1634///     }
1635/// }
1636///
1637/// # struct Form { tax_id: String }
1638/// # impl Validate for Form {
1639/// fn rules(&self, v: &mut Validator) {
1640///     v.field("tax_id", &self.tax_id).required().apply(&TaxId);
1641/// }
1642/// # }
1643/// ```
1644///
1645/// Missing values skip the rule (combine it with `required`).
1646pub trait Rule {
1647    /// `Err(message)` when `value` breaks the rule.
1648    fn check(&self, value: &Inspected) -> std::result::Result<(), String>;
1649}
1650
1651/// Limits for an uploaded image's size in pixels, for
1652/// [`Field::dimensions`] (Laravel's `dimensions`).
1653///
1654/// ```
1655/// # use renox::prelude::*;
1656/// use renox::validation::Dimensions;
1657/// # struct Form { banner: Option<Upload> }
1658/// # impl Validate for Form {
1659/// fn rules(&self, v: &mut Validator) {
1660///     let banner = Dimensions::new().min_width(1200).ratio(3, 1);
1661///     v.field("banner", &self.banner).image().dimensions(&banner);
1662/// }
1663/// # }
1664/// ```
1665#[derive(Debug, Clone, Default, PartialEq)]
1666pub struct Dimensions {
1667    min_width: Option<u32>,
1668    max_width: Option<u32>,
1669    min_height: Option<u32>,
1670    max_height: Option<u32>,
1671    width: Option<u32>,
1672    height: Option<u32>,
1673    ratio: Option<(u32, u32)>,
1674}
1675
1676impl Dimensions {
1677    /// No limits yet.
1678    pub fn new() -> Self {
1679        Self::default()
1680    }
1681
1682    /// At least `px` wide.
1683    pub fn min_width(mut self, px: u32) -> Self {
1684        self.min_width = Some(px);
1685        self
1686    }
1687
1688    /// At most `px` wide.
1689    pub fn max_width(mut self, px: u32) -> Self {
1690        self.max_width = Some(px);
1691        self
1692    }
1693
1694    /// At least `px` high.
1695    pub fn min_height(mut self, px: u32) -> Self {
1696        self.min_height = Some(px);
1697        self
1698    }
1699
1700    /// At most `px` high.
1701    pub fn max_height(mut self, px: u32) -> Self {
1702        self.max_height = Some(px);
1703        self
1704    }
1705
1706    /// Exactly `px` wide.
1707    pub fn width(mut self, px: u32) -> Self {
1708        self.width = Some(px);
1709        self
1710    }
1711
1712    /// Exactly `px` high.
1713    pub fn height(mut self, px: u32) -> Self {
1714        self.height = Some(px);
1715        self
1716    }
1717
1718    /// Width to height in this ratio, e.g. `ratio(16, 9)` or `ratio(1, 1)`
1719    /// for a square (to within a pixel of rounding).
1720    pub fn ratio(mut self, width: u32, height: u32) -> Self {
1721        self.ratio = Some((width, height));
1722        self
1723    }
1724
1725    fn allows(&self, w: u32, h: u32) -> bool {
1726        let at_least = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v >= l);
1727        let at_most = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v <= l);
1728        let exactly = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v == l);
1729        let ratio = self.ratio.is_none_or(|(rw, rh)| {
1730            // Within one pixel of the exact height for this width.
1731            rw > 0 && (h as f64 - w as f64 * rh as f64 / rw as f64).abs() <= 1.0
1732        });
1733        at_least(self.min_width, w)
1734            && at_most(self.max_width, w)
1735            && at_least(self.min_height, h)
1736            && at_most(self.max_height, h)
1737            && exactly(self.width, w)
1738            && exactly(self.height, h)
1739            && ratio
1740    }
1741}
1742
1743/// The Have I Been Pwned range API (`app.fake_http()` answers it in tests).
1744const PWNED_RANGE: &str = "https://api.pwnedpasswords.com/range/";
1745
1746/// Whether `password` appears in a known breach. Errors (no network, a
1747/// slow service) count as "no", with a warning, so sign-ups keep working.
1748async fn breached(state: &crate::AppState, password: &str) -> bool {
1749    use sha1::{Digest, Sha1};
1750    let hash: String = Sha1::digest(password.as_bytes())
1751        .iter()
1752        .map(|b| format!("{b:02X}"))
1753        .collect();
1754    let (prefix, suffix) = hash.split_at(5);
1755    let response = state
1756        .http
1757        .get(format!("{PWNED_RANGE}{prefix}"))
1758        .header("Add-Padding", "true")
1759        .timeout(std::time::Duration::from_secs(5))
1760        .send()
1761        .await;
1762    match response {
1763        Ok(response) if response.status().is_success() => response.text().lines().any(|line| {
1764            line.split_once(':').is_some_and(|(candidate, count)| {
1765                candidate.trim().eq_ignore_ascii_case(suffix)
1766                    && count.trim().parse::<u64>().is_ok_and(|n| n > 0)
1767            })
1768        }),
1769        Ok(response) => {
1770            tracing::warn!(status = %response.status(), "the password breach check answered with an error; allowing the password");
1771            false
1772        }
1773        Err(err) => {
1774            tracing::warn!(error = ?err, "the password breach check failed; allowing the password");
1775            false
1776        }
1777    }
1778}
1779
1780/// Decimal places of a plain decimal number (`12`, `-3.50`), else `None`.
1781fn decimal_places(text: &str) -> Option<usize> {
1782    let digits = text.strip_prefix(['-', '+']).unwrap_or(text);
1783    let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
1784    let all_digits = |s: &str| s.chars().all(|c| c.is_ascii_digit());
1785    (!whole.is_empty()
1786        && all_digits(whole)
1787        && all_digits(fraction)
1788        && !(digits.contains('.') && fraction.is_empty()))
1789    .then_some(fraction.len())
1790}
1791
1792/// The date a date rule compares with: a date value, or text that reads as
1793/// one (`"2026-01-01"`). Anything else leaves the rule out.
1794fn limit_date(limit: &impl FieldValue) -> Option<NaiveDateTime> {
1795    match limit.inspect() {
1796        Inspected::Date(date) => Some(date),
1797        Inspected::Text(text) => parse_date(text.trim()),
1798        _ => None,
1799    }
1800}
1801
1802/// The value as a number: a number, or text that reads as a finite one.
1803fn numeric_value(value: &Inspected) -> Option<f64> {
1804    match value {
1805        Inspected::Number(n) => Some(*n),
1806        Inspected::Text(text) => {
1807            let text = text.trim();
1808            decimal_places(text)?;
1809            text.parse::<f64>().ok().filter(|n| n.is_finite())
1810        }
1811        _ => None,
1812    }
1813}
1814
1815/// How many digits an integer value has (the sign doesn't count).
1816fn integer_digits(value: &Inspected) -> Option<usize> {
1817    let n = numeric_value(value)?;
1818    (n.fract() == 0.0).then(|| format!("{}", n.abs() as u64).len())
1819}
1820
1821fn is_mac_address(text: &str) -> bool {
1822    let text = text.trim();
1823    let hex = |s: &str, n: usize| s.len() == n && s.chars().all(|c| c.is_ascii_hexdigit());
1824    for separator in [':', '-'] {
1825        let parts: Vec<&str> = text.split(separator).collect();
1826        if parts.len() == 6 && parts.iter().all(|p| hex(p, 2)) {
1827            return true;
1828        }
1829    }
1830    let parts: Vec<&str> = text.split('.').collect();
1831    parts.len() == 3 && parts.iter().all(|p| hex(p, 4))
1832}
1833
1834fn is_uuid(text: &str) -> bool {
1835    let groups: Vec<&str> = text.trim().split('-').collect();
1836    groups.len() == 5
1837        && groups
1838            .iter()
1839            .zip([8, 4, 4, 4, 12])
1840            .all(|(g, n)| g.len() == n && g.chars().all(|c| c.is_ascii_hexdigit()))
1841}
1842
1843/// Compiled patterns, so a rule in a hot form doesn't recompile each time.
1844fn cached_regex(pattern: &str) -> std::result::Result<regex::Regex, regex::Error> {
1845    static CACHE: std::sync::LazyLock<
1846        std::sync::Mutex<std::collections::HashMap<String, regex::Regex>>,
1847    > = std::sync::LazyLock::new(Default::default);
1848    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
1849    if let Some(regex) = cache.get(pattern) {
1850        return Ok(regex.clone());
1851    }
1852    let regex = regex::Regex::new(pattern)?;
1853    if cache.len() < 1000 {
1854        cache.insert(pattern.to_owned(), regex.clone());
1855    }
1856    Ok(regex)
1857}
1858
1859/// How many digits the value is made of, if it's only digits.
1860fn digit_count(value: &Inspected) -> Option<usize> {
1861    let text = match value {
1862        Inspected::Text(text) => text.trim().to_owned(),
1863        Inspected::Number(n) if n.fract() == 0.0 && *n >= 0.0 => format!("{}", *n as u64),
1864        _ => return None,
1865    };
1866    (!text.is_empty() && text.chars().all(|c| c.is_ascii_digit())).then_some(text.len())
1867}
1868
1869/// `2026-10-01`, `2026-10-01T10:30`, `2026-10-01 10:30:00` or RFC 3339.
1870fn parse_date(text: &str) -> Option<NaiveDateTime> {
1871    if let Ok(date) = chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d") {
1872        return Some(date.and_time(chrono::NaiveTime::MIN));
1873    }
1874    for format in [
1875        "%Y-%m-%dT%H:%M",
1876        "%Y-%m-%dT%H:%M:%S",
1877        "%Y-%m-%d %H:%M",
1878        "%Y-%m-%d %H:%M:%S",
1879    ] {
1880        if let Ok(date) = NaiveDateTime::parse_from_str(text, format) {
1881            return Some(date);
1882        }
1883    }
1884    chrono::DateTime::parse_from_rfc3339(text)
1885        .ok()
1886        .map(|d| d.naive_utc())
1887}
1888
1889fn is_email(text: &str) -> bool {
1890    let Some((local, domain)) = text.split_once('@') else {
1891        return false;
1892    };
1893    !local.is_empty()
1894        && !text.chars().any(char::is_whitespace)
1895        && !domain.contains('@')
1896        && domain.contains('.')
1897        && domain.split('.').all(|part| !part.is_empty())
1898}
1899
1900fn is_url(text: &str) -> bool {
1901    let rest = text
1902        .strip_prefix("https://")
1903        .or_else(|| text.strip_prefix("http://"));
1904    matches!(rest, Some(rest) if !rest.is_empty()
1905        && !rest.starts_with('/')
1906        && !text.chars().any(char::is_whitespace))
1907}
1908
1909/// Keys never flashed back into forms.
1910const DONT_FLASH: &[&str] = &[
1911    "password",
1912    "password_confirmation",
1913    "current_password",
1914    "_token",
1915];
1916
1917/// A failed validation. As a response it is `422` with
1918/// `{"message": ..., "errors": {...}}`; for regular (non-HTMX, non-JSON)
1919/// requests Renox turns it into a redirect back with the errors and old
1920/// input flashed to the session.
1921///
1922/// Return it from a handler for errors found after validation:
1923///
1924/// ```
1925/// # use renox::prelude::*;
1926/// # #[derive(serde::Serialize)] struct StockForm { quantity: i64 }
1927/// # fn demo(form: StockForm) -> Result {
1928/// let mut errors = Errors::new();
1929/// errors.add("quantity", "Not enough stock.");
1930/// return Err(ValidationError::new(errors).with_input(&form).into());
1931/// # }
1932/// ```
1933#[derive(Debug, Clone)]
1934pub struct ValidationError {
1935    /// The messages, keyed by field name.
1936    pub errors: Errors,
1937    /// The submitted values flashed back into the form (never passwords).
1938    pub input: Map<String, Value>,
1939    /// The named error bag (`in_bag`), for a page with several forms.
1940    bag: Option<String>,
1941}
1942
1943impl ValidationError {
1944    /// An error with these messages and no input to refill.
1945    pub fn new(errors: Errors) -> Self {
1946        Self {
1947            errors,
1948            input: Map::new(),
1949            bag: None,
1950        }
1951    }
1952
1953    /// Flashes the errors in the named bag `bag` (Laravel's error bags) when
1954    /// the form is sent back: the page shows them with
1955    /// `error('email', bag='login')`, and the default `error('email')` of
1956    /// another form on the page stays empty. `Valid<T>` does it for
1957    /// [`Validate::ERROR_BAG`].
1958    pub fn in_bag(mut self, bag: impl Into<String>) -> Self {
1959        self.bag = Some(bag.into());
1960        self
1961    }
1962
1963    /// The named error bag, if any.
1964    pub fn bag(&self) -> Option<&str> {
1965        self.bag.as_deref()
1966    }
1967
1968    /// The submitted values to refill the form with (passwords are dropped).
1969    pub fn with_input(mut self, input: &impl Serialize) -> Self {
1970        if let Ok(Value::Object(map)) = serde_json::to_value(input) {
1971            self.input = map;
1972        }
1973        self.input
1974            .retain(|key, _| !DONT_FLASH.contains(&key.as_str()));
1975        self
1976    }
1977
1978    pub(crate) fn with_input_map(mut self, input: Map<String, Value>) -> Self {
1979        self.input = input;
1980        self.input
1981            .retain(|key, _| !DONT_FLASH.contains(&key.as_str()));
1982        self
1983    }
1984}
1985
1986impl From<Errors> for ValidationError {
1987    fn from(errors: Errors) -> Self {
1988        Self::new(errors)
1989    }
1990}
1991
1992impl IntoResponse for ValidationError {
1993    fn into_response(self) -> Response {
1994        let message = self
1995            .errors
1996            .iter()
1997            .next()
1998            .and_then(|(_, m)| m.first().cloned())
1999            .unwrap_or_default();
2000        let body = json!({ "message": message, "errors": self.errors });
2001        let mut res = (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(body)).into_response();
2002        res.extensions_mut().insert(self);
2003        res
2004    }
2005}
2006
2007#[cfg(test)]
2008mod tests {
2009    use super::*;
2010
2011    #[test]
2012    fn checks_emails_and_urls() {
2013        assert!(is_email("alex@example.com"));
2014        assert!(!is_email("alex@localhost"));
2015        assert!(!is_email("alex example@x.com"));
2016        assert!(!is_email("@x.com"));
2017        assert!(is_url("https://renox.dev/docs"));
2018        assert!(!is_url("ftp://renox.dev"));
2019        assert!(!is_url("https://"));
2020    }
2021
2022    #[test]
2023    fn formats_numbers_without_trailing_zeros() {
2024        assert_eq!(number(3.0), "3");
2025        assert_eq!(number(2.5), "2.5");
2026    }
2027    /// A value that inspects as whatever a test needs (a file, a bool).
2028    struct As(Inspected);
2029
2030    impl FieldValue for As {
2031        fn inspect(&self) -> Inspected {
2032            self.0.clone()
2033        }
2034        fn db_value(&self) -> DbValue {
2035            DbValue::Null
2036        }
2037    }
2038
2039    /// A rule that fails every value it sees.
2040    struct Never;
2041
2042    impl Rule for Never {
2043        fn check(&self, _: &Inspected) -> std::result::Result<(), String> {
2044            Err("never".into())
2045        }
2046    }
2047
2048    fn file(kilobytes: f64, dimensions: Option<(u32, u32)>) -> As {
2049        As(Inspected::File {
2050            kilobytes,
2051            extension: "png".into(),
2052            image: true,
2053            dimensions,
2054        })
2055    }
2056
2057    // #250: the branches rules take for values they don't measure, and the
2058    // passing side of rules only failures had reached.
2059    #[test]
2060    fn rules_pass_or_step_aside_for_values_they_dont_judge() {
2061        let mut v = Validator::default();
2062        let photo = file(120.0, Some((800, 600)));
2063        v.field("photo", &photo)
2064            .mimes(&["PNG", "jpeg"])
2065            .dimensions(&Dimensions::new().min_width(400).max_height(600));
2066        v.field("code", &"AB1234")
2067            .matches(r"^[A-Z]{2}\d{4}$")
2068            .not_matches(r"^\d+$");
2069        v.field("secret", &"long enough password")
2070            .password(&Password::min(8));
2071        // Rules that can't read the value leave it alone.
2072        let missing: Option<String> = None;
2073        v.field("note", &missing)
2074            .decimal(2, 2)
2075            .apply(&Never)
2076            .gt("other", &"5");
2077        v.field("price", &"12").gt("budget", &missing);
2078        // A limit that isn't a date leaves the date rules out.
2079        v.field("day", &"2026-01-01")
2080            .before_or_equal(5)
2081            .after(true)
2082            .after_or_equal(2.5);
2083        assert!(v.errors.is_empty(), "{:?}", v.errors);
2084    }
2085
2086    #[test]
2087    fn rules_fail_values_of_the_wrong_kind() {
2088        let mut v = Validator::default();
2089        // A number isn't a date; a bool has no digits, no decimals, no number.
2090        v.field("day", &7).before("2026-01-01");
2091        v.field("agree", &true).digits(3);
2092        v.field("flag", &true).decimal(2, 2);
2093        v.field("on", &true).numeric();
2094        // Two files compare by size; two booleans can't be compared.
2095        v.field("photo", &file(10.0, None))
2096            .gt("thumb", &file(20.0, None));
2097        v.field("yes", &true).lt("no", &false);
2098        assert_eq!(v.errors.first("day"), Some("The day is not a valid date."));
2099        assert_eq!(v.errors.first("agree"), Some("The agree must be 3 digits."));
2100        assert_eq!(
2101            v.errors.first("flag"),
2102            Some("The flag must have 2 decimal places.")
2103        );
2104        assert_eq!(v.errors.first("on"), Some("The on must be a number."));
2105        assert_eq!(
2106            v.errors.first("photo"),
2107            Some("The photo must be larger than thumb.")
2108        );
2109        assert!(v.errors.has("yes"), "{:?}", v.errors);
2110        // An image too wide fails `dimensions`.
2111        let mut v = Validator::new();
2112        v.field("photo", &file(1.0, Some((2000, 10))))
2113            .dimensions(&Dimensions::new().max_width(100));
2114        assert!(v.errors.has("photo"));
2115    }
2116
2117    #[test]
2118    fn nested_rows_carry_their_async_checks_under_the_row_name() {
2119        struct Row {
2120            password: String,
2121        }
2122        impl Validate for Row {
2123            fn rules(&self, v: &mut Validator) {
2124                v.field("password", &self.password).current_password();
2125            }
2126        }
2127        let mut v = Validator::new();
2128        v.nested(
2129            "rows",
2130            &[
2131                Row {
2132                    password: "a".into(),
2133                },
2134                Row {
2135                    password: "b".into(),
2136                },
2137            ],
2138        );
2139        let fields: Vec<_> = v.checks.iter().map(|c| c.field.as_str()).collect();
2140        assert_eq!(fields, ["rows.0.password", "rows.1.password"]);
2141    }
2142
2143    #[tokio::test]
2144    async fn checks_on_a_field_that_already_failed_are_skipped() {
2145        let db = crate::db::connect(&crate::Config::default()).await.unwrap();
2146        let mut v = Validator::new();
2147        // The field failed a rule before its database and async checks were
2148        // added: neither runs (no `users` table is asked), one error stays.
2149        v.field("email", &"not an email").email();
2150        v.field("email", &"not an email")
2151            .unique("no_such_table", "email")
2152            .current_password();
2153        // `.message` after an async check is that check's message.
2154        v.field("password", &"guess")
2155            .current_password()
2156            .message("That isn't your password.");
2157        let errors = v.finish(&db).await.unwrap();
2158        assert_eq!(
2159            errors.first("email"),
2160            Some("The email must be a valid email address.")
2161        );
2162        assert_eq!(
2163            errors.iter().find(|(f, _)| *f == "email").unwrap().1.len(),
2164            1
2165        );
2166        assert_eq!(errors.first("password"), Some("That isn't your password."));
2167    }
2168}