Skip to main content

umbral_core/
forms.rs

1//! Form parsing, validation, and HTML rendering.
2//!
3//! ## Two names, two layers (gaps2 #19)
4//!
5//! - **`FormValidate` trait** (the primitive, was `Form`): a struct
6//!   implements this to provide a `validate(&HashMap)` method. The
7//!   `#[derive(Form)]` macro emits it.
8//! - **`Form<T>` extractor** (the axum entry point): wraps the
9//!   parsed-and-validated `T` in a `Result<T, FormErrors>`. Use in
10//!   handler signatures: `Form<ContactForm>`.
11//!
12//! The trait used to be called `Form` too, but that collided with
13//! the extractor type in the same module. The name with generics
14//! went to the extractor (matches `axum::extract::Form<T>` /
15//! `axum::Json<T>` shape) and the trait got the more descriptive
16//! `FormValidate`.
17//!
18//! The piece that fills out the request-handling story between axum's
19//! `Form<T>` extractor (raw key/value access) and a typed Rust struct
20//! (the application's view of validated input). umbral's first cut is
21//! the primitive layer richer form abstractions sit on top of.
22//!
23//! ## v1 shape
24//!
25//! - [`Field`] types per HTML input shape (`TextField`,
26//!   `IntegerField`, `EmailField`, `PasswordField`, `BooleanField`,
27//!   `DateField`, `TimeField`).
28//! - Field-level validators ([`Required`], [`MinLength`],
29//!   [`MaxLength`], [`Pattern`]) plus the convenience built-in checks
30//!   each field type does for its own shape (e.g. `EmailField` runs
31//!   `Pattern` against an email regex by default).
32//! - [`ValidationErrors`] is a map of field-name -> error messages.
33//!   Forms accumulate every per-field error before returning, so the
34//!   user sees the whole form's problems at once.
35//! - HTML rendering: every field type has [`Field::render_html`]
36//!   that emits a single `<input>` (or `<textarea>`) with the right
37//!   `type`, `name`, `value`, and a `required` attribute when the
38//!   field is required.
39//!
40//! ## v1 caps
41//!
42//! - No `#[derive(Form)]` macro. Users compose forms by hand:
43//!   `LoginForm::validate(&form_data)` is a function that reads each
44//!   field, accumulates errors, returns either the typed struct or
45//!   `Err(ValidationErrors)`. The derive lands as a future round.
46//! - No file uploads (multipart); HTML-only.
47//! - No localized error messages.
48
49use std::collections::HashMap;
50
51use async_trait::async_trait;
52
53/// Re-exported so the `#[derive(Form)]` macro can name
54/// `::umbral::forms::async_trait` on the impl it emits without the
55/// consumer crate having to depend on `async-trait` directly.
56#[doc(hidden)]
57pub use async_trait::async_trait as async_trait_reexport;
58
59// =========================================================================
60// Form trait. The `#[derive(Form)]` macro emits an impl of this. User
61// code can also impl it by hand for the rare "I want different
62// semantics than the macro" case.
63// =========================================================================
64
65/// The contract a typed form satisfies. `validate` reads form data
66/// (a `HashMap<String, String>`, the natural shape after
67/// `serde_urlencoded` or axum's `Form` extractor) and produces either
68/// the typed struct or a `ValidationErrors` map describing every
69/// problem at once.
70///
71/// `render_html` writes the form's HTML inputs, prefilled from a
72/// HashMap on the re-render path (after a validation failure or on
73/// edit views). The default impl walks `fields()` and concatenates
74/// each field's `render_html` — most macro-derived forms inherit
75/// this and only override when they need custom layout.
76#[async_trait]
77pub trait FormValidate: Sized {
78    /// Parse and validate the form's input. Async because FK / M2M
79    /// fields verify existence through the ORM before insert. Returns
80    /// the typed struct on success; returns `ValidationErrors` with
81    /// every field's problems accumulated on failure.
82    async fn validate(data: &HashMap<String, String>) -> Result<Self, ValidationErrors>;
83
84    /// The field declarations this form carries. Sync — kinds /
85    /// validators only, no live options. Used by the default
86    /// `render_html` to walk them in declaration order. The macro
87    /// emits one entry per struct field.
88    fn fields() -> Vec<Field>;
89
90    /// Render every field as an HTML `<label>` + `<input>` pair,
91    /// prefilled from `data`. Wraps each in a `<div class="field">`
92    /// for styling. Async because `ModelChoice` / `ModelMultiChoice`
93    /// fetch their `<select>` options from the DB. Override if you
94    /// want a non-default layout.
95    async fn render_html(data: &HashMap<String, String>) -> String {
96        let mut out = String::new();
97        for field in Self::fields() {
98            let value = data.get(&field.name).map(String::as_str).unwrap_or("");
99            out.push_str("<div class=\"field\">");
100            out.push_str(&format!(
101                "<label for=\"{name}\">{name}</label>",
102                name = html_escape(&field.name)
103            ));
104            out.push_str(&field.render_html_async(value).await);
105            out.push_str("</div>");
106        }
107        out
108    }
109}
110
111// =========================================================================
112// Errors. One per-field message list, plus a "non-field" bucket for
113// cross-field issues (passwords don't match, etc.).
114// =========================================================================
115
116/// A collection of per-field validation errors. Forms accumulate
117/// these and return the whole map at once.
118#[derive(Debug, Default, Clone, PartialEq, Eq)]
119pub struct ValidationErrors {
120    /// Per-field error messages. Each field's vec may carry multiple
121    /// messages (e.g. both Required and Pattern fire).
122    pub fields: HashMap<String, Vec<String>>,
123    /// Cross-field errors that don't belong to one field. Use for
124    /// "password and confirm don't match", etc.
125    pub non_field: Vec<String>,
126}
127
128impl ValidationErrors {
129    /// Construct an empty error set.
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Add an error to one field. Multiple calls accumulate.
135    pub fn add(&mut self, field: &str, message: impl Into<String>) {
136        self.fields
137            .entry(field.to_string())
138            .or_default()
139            .push(message.into());
140    }
141
142    /// Add a cross-field error.
143    pub fn add_non_field(&mut self, message: impl Into<String>) {
144        self.non_field.push(message.into());
145    }
146
147    /// Has any error been recorded?
148    pub fn is_empty(&self) -> bool {
149        self.fields.is_empty() && self.non_field.is_empty()
150    }
151
152    /// Convert to `Result<(), ValidationErrors>`, returning `Ok(())`
153    /// when no errors have accumulated. Use as the last step in a
154    /// form's `validate` method.
155    pub fn into_result(self) -> Result<(), Self> {
156        if self.is_empty() { Ok(()) } else { Err(self) }
157    }
158}
159
160impl std::fmt::Display for ValidationErrors {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        for msg in &self.non_field {
163            writeln!(f, "form: {msg}")?;
164        }
165        for (field, msgs) in &self.fields {
166            for msg in msgs {
167                writeln!(f, "{field}: {msg}")?;
168            }
169        }
170        Ok(())
171    }
172}
173
174impl std::error::Error for ValidationErrors {}
175
176// =========================================================================
177// Validators. Reusable functions that take a value and either succeed
178// or push an error onto the per-field list. Each is a small struct
179// implementing `Validator` so users can build a Vec<Box<dyn ...>>.
180// =========================================================================
181
182/// One validator's verdict.
183pub trait Validator: Send + Sync {
184    /// Check the value. `field_name` is included for the error
185    /// message. Return `Ok(())` to accept, `Err(message)` to reject.
186    fn check(&self, field_name: &str, value: &str) -> Result<(), String>;
187}
188
189/// The field must not be empty.
190pub struct Required;
191impl Validator for Required {
192    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
193        if value.trim().is_empty() {
194            Err(format!("{field_name} is required"))
195        } else {
196            Ok(())
197        }
198    }
199}
200
201/// The field's length (in characters) must be at least `n`.
202pub struct MinLength(pub usize);
203impl Validator for MinLength {
204    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
205        if value.chars().count() < self.0 {
206            Err(format!(
207                "{field_name} must be at least {} characters",
208                self.0
209            ))
210        } else {
211            Ok(())
212        }
213    }
214}
215
216/// The field's length (in characters) must be at most `n`.
217pub struct MaxLength(pub usize);
218impl Validator for MaxLength {
219    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
220        if value.chars().count() > self.0 {
221            Err(format!(
222                "{field_name} must be at most {} characters",
223                self.0
224            ))
225        } else {
226            Ok(())
227        }
228    }
229}
230
231/// A simple "must look like an email" check. Not RFC 5322 strict —
232/// covers the 99% case (one `@`, non-empty local part, dot in the
233/// domain). Users with stricter needs swap in a real regex.
234pub struct EmailFormat;
235impl Validator for EmailFormat {
236    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
237        let Some((local, domain)) = value.split_once('@') else {
238            return Err(format!("{field_name} must contain `@`"));
239        };
240        if local.is_empty() {
241            return Err(format!("{field_name} is missing a local part before `@`"));
242        }
243        if !domain.contains('.') {
244            return Err(format!(
245                "{field_name}'s domain must contain at least one `.`"
246            ));
247        }
248        if domain.starts_with('.') || domain.ends_with('.') {
249            return Err(format!("{field_name}'s domain is malformed"));
250        }
251        Ok(())
252    }
253}
254
255/// Regex-pattern validator — the catch-all shape for "value must
256/// match this format". Reject the field with a user-supplied message
257/// when the pattern doesn't match.
258///
259/// Used by `#[form(regex = "...")]` on derived form structs AND by
260/// the `Field::regex` / `Field::phone` / `Field::url` convenience
261/// constructors. The pattern is parsed once at construction time
262/// (panics if invalid — a hardcoded pattern can't go wrong in
263/// production; user-supplied patterns are validated at `cargo build`
264/// time through the macro's `Regex::new(...)` compile-time call).
265pub struct RegexFormat {
266    pattern: regex::Regex,
267    message: String,
268}
269
270impl RegexFormat {
271    /// Build a regex validator from a pattern + a human message. The
272    /// pattern is compiled eagerly — use `regex::Regex::new` shape
273    /// (no leading slash, no flags suffix). Panics on an invalid
274    /// pattern; the derive macro catches this at build time by
275    /// emitting the literal into the generated code.
276    pub fn new(pattern: &str, message: impl Into<String>) -> Self {
277        Self {
278            pattern: regex::Regex::new(pattern)
279                .unwrap_or_else(|e| panic!("RegexFormat: invalid pattern `{pattern}`: {e}")),
280            message: message.into(),
281        }
282    }
283}
284
285impl Validator for RegexFormat {
286    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
287        if self.pattern.is_match(value) {
288            Ok(())
289        } else {
290            // `{field}` placeholder in the message gets substituted
291            // with the actual field name — lets one message template
292            // be reused across forms ("{field} must start with `+`").
293            // Most callers won't use the placeholder; substitution is
294            // a no-op when it's absent.
295            Err(self.message.replace("{field}", field_name))
296        }
297    }
298}
299
300/// E.164 international phone-number format — the standard the
301/// telecoms industry uses. `+<country code><subscriber number>`
302/// where the country code is 1-3 digits and the subscriber number
303/// is up to 14 digits, no spaces or punctuation.
304///
305/// Catches the most common typo'd-phone cases ("07065" with no
306/// country code, "+0..." starting with zero, letters mixed in,
307/// dashes / spaces / parens that proper E.164 doesn't allow).
308/// Users who need a softer "accept anything that looks vaguely
309/// phone-ish" check can write their own regex via
310/// `#[form(regex = "...", message = "...")]`.
311pub const PHONE_E164_PATTERN: &str = r"^\+[1-9]\d{1,14}$";
312
313/// URL validator — http(s) only, requires a host, accepts an
314/// optional path/query/fragment. Conservative on purpose:
315/// `ftp://`, `mailto:`, etc. get rejected so a form that promises
316/// "URL" doesn't end up persisting a non-web scheme.
317pub const URL_PATTERN: &str = r"^https?://[A-Za-z0-9._~:%/?#\[\]@!$&'()*+,;=-]+$";
318
319// =========================================================================
320// Field types. Each owns its name, value (after parsing), and a list
321// of validators that fire in order. `render_html` emits the matching
322// HTML input.
323// =========================================================================
324
325/// How to parse a submitted FK id string. Resolved from the target
326/// model's PK SqlType at render/validate time.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum PkKind {
329    BigInt,
330    Uuid,
331    Text,
332}
333
334/// What HTML `<input type>` a field renders as. The form module
335/// uses this for `render_html`; it's the same set the admin's
336/// `input_kind` produces.
337#[derive(Debug, Clone, Copy)]
338pub enum InputKind {
339    Text,
340    Number,
341    Email,
342    /// gaps2 #19 follow-up — `<input type="tel">` so mobile
343    /// browsers pop the number keypad. Phone fields don't get
344    /// browser-side validation (there's no canonical phone format
345    /// the browser knows about), so the server-side regex is what
346    /// catches typo'd input.
347    Tel,
348    /// `<input type="url">`. Browser does shallow validation
349    /// (requires a scheme + host) but the server-side regex is
350    /// stricter about which schemes are allowed.
351    Url,
352    Password,
353    Checkbox,
354    Date,
355    Time,
356    DatetimeLocal,
357    Textarea,
358    /// `<input type="file">`. Submission is opaque: the admin's
359    /// multipart handler stores the upload and puts the resulting
360    /// storage *key* (a plain string) into the form data, which the
361    /// `FileField` / `ImageField` newtype is constructed from. The
362    /// rendered input never echoes the key as its `value` (browsers
363    /// reject programmatic file-input values), so it has no prefill.
364    File,
365    /// Closed-set enum (`#[umbral(choices)]`). Options are compile-time
366    /// `(value, label)` pairs from `ChoiceField`. Rendered as a
367    /// `<select>`, not an `<input>`.
368    Select,
369    /// FK / forward O2O to another model. Options are fetched at render
370    /// time (async). `label_field` overrides the default label column.
371    ModelChoice {
372        target_table: &'static str,
373        label_field: Option<&'static str>,
374        pk_kind: PkKind,
375    },
376    /// M2M relation. Submits a list of child ids; written as junction
377    /// rows after the parent insert.
378    ModelMultiChoice {
379        target_table: &'static str,
380        label_field: Option<&'static str>,
381        pk_kind: PkKind,
382    },
383}
384
385impl InputKind {
386    fn html_type(self) -> &'static str {
387        match self {
388            InputKind::Text | InputKind::Textarea => "text",
389            InputKind::Number => "number",
390            InputKind::Email => "email",
391            InputKind::Tel => "tel",
392            InputKind::Url => "url",
393            InputKind::Password => "password",
394            InputKind::Checkbox => "checkbox",
395            InputKind::Date => "date",
396            InputKind::Time => "time",
397            InputKind::DatetimeLocal => "datetime-local",
398            InputKind::File => "file",
399            // `Select` / `ModelChoice` / `ModelMultiChoice` have no
400            // `<input type>`; their render arms build a `<select>` and
401            // never call `html_type`. "text" is a harmless default to
402            // keep the match exhaustive.
403            InputKind::Select
404            | InputKind::ModelChoice { .. }
405            | InputKind::ModelMultiChoice { .. } => "text",
406        }
407    }
408}
409
410/// A single form field: name + kind + validators. The field doesn't
411/// own its parsed value; `validate` reads from the form-data map and
412/// pushes errors onto the accumulator.
413pub struct Field {
414    pub name: String,
415    pub kind: InputKind,
416    pub required: bool,
417    pub validators: Vec<Box<dyn Validator>>,
418    /// `(value, label)` pairs for `Select` fields. Empty for every
419    /// other kind. For `ModelChoice` / `ModelMultiChoice` the options
420    /// are fetched async at render time (Task 6), so they stay empty
421    /// here too.
422    pub options: Vec<(String, String)>,
423}
424
425impl Field {
426    /// New text field. Caller adds validators via builder methods.
427    pub fn text(name: impl Into<String>) -> Self {
428        Self {
429            name: name.into(),
430            kind: InputKind::Text,
431            required: true,
432            validators: vec![Box::new(Required)],
433            options: Vec::new(),
434        }
435    }
436
437    /// New email field. Carries `EmailFormat` by default.
438    pub fn email(name: impl Into<String>) -> Self {
439        let mut f = Self::text(name);
440        f.kind = InputKind::Email;
441        f.validators.push(Box::new(EmailFormat));
442        f
443    }
444
445    /// Attach a regex-pattern validator to an existing field.
446    /// Composes with `Required`, `MinLength`, `MaxLength`, etc. —
447    /// the regex check fires after the others, so empty / missing
448    /// values surface the right "is required" error rather than
449    /// a confusing "doesn't match pattern" error.
450    ///
451    /// The pattern is compiled eagerly — an invalid regex panics at
452    /// construction time. The derive macro short-circuits this by
453    /// emitting the literal pattern, so a malformed
454    /// `#[form(regex = "...")]` surfaces as a panic in tests rather
455    /// than silently passing every input.
456    ///
457    /// Use `{field}` in the message to interpolate the field name.
458    ///
459    /// ```ignore
460    /// let f = Field::text("invoice_id")
461    ///     .regex(r"^INV-\d{6}$", "{field} must look like `INV-123456`");
462    /// ```
463    pub fn regex(mut self, pattern: &str, message: impl Into<String>) -> Self {
464        self.validators
465            .push(Box::new(RegexFormat::new(pattern, message)));
466        self
467    }
468
469    /// New phone field — E.164 international format
470    /// (`+<country><subscriber>`, e.g. `+14155551234`). Catches the
471    /// common typo'd-phone cases ("07065", "+0…", letters mixed in,
472    /// dashes / spaces / parens that proper E.164 doesn't allow).
473    /// Renders as `<input type="tel">` so mobile browsers pop the
474    /// number keypad.
475    ///
476    /// Soft-validation case ("accept anything phone-ish, even
477    /// without country code"): use `Field::text` + your own
478    /// `.regex(...)`. The strict E.164 pattern here is the right
479    /// default because every form that asks for a phone number
480    /// SHOULD be storing them in E.164 (the only shape that
481    /// round-trips across providers / SMS gateways / address books).
482    pub fn phone(name: impl Into<String>) -> Self {
483        let mut f = Self::text(name);
484        f.kind = InputKind::Tel;
485        f.validators.push(Box::new(RegexFormat::new(
486            PHONE_E164_PATTERN,
487            "{field} must be E.164 format — `+` then country code then number, no spaces",
488        )));
489        f
490    }
491
492    /// New URL field — http(s) only, requires a host. Conservative:
493    /// `ftp://` / `mailto:` / etc. get rejected so a form that
494    /// promises "URL" doesn't persist a non-web scheme.
495    pub fn url(name: impl Into<String>) -> Self {
496        let mut f = Self::text(name);
497        f.kind = InputKind::Url;
498        f.validators.push(Box::new(RegexFormat::new(
499            URL_PATTERN,
500            "{field} must be an http(s):// URL",
501        )));
502        f
503    }
504
505    /// New password field. Identical validation rules to text; the
506    /// difference is the rendered `<input type="password">` so the
507    /// browser masks input.
508    pub fn password(name: impl Into<String>) -> Self {
509        let mut f = Self::text(name);
510        f.kind = InputKind::Password;
511        f
512    }
513
514    /// New file field — renders `<input type="file">`. One kind covers
515    /// both `FileField` and `ImageField`: the Form-side input is just a
516    /// file input (the admin's image-preview is a column-`widget`
517    /// concern, not a form-input concern).
518    ///
519    /// The submitted value is the opaque storage *key* the admin's
520    /// multipart handler stored after the upload; there's nothing to
521    /// validate about a key string beyond required/optional, so the
522    /// only validator is `Required` (dropped via `.optional()` for a
523    /// nullable field). Length / regex / format validators don't apply.
524    pub fn file(name: impl Into<String>) -> Self {
525        Self {
526            name: name.into(),
527            kind: InputKind::File,
528            required: true,
529            validators: vec![Box::new(Required)],
530            options: Vec::new(),
531        }
532    }
533
534    /// New integer field. Validates that the value parses as `i64`.
535    pub fn integer(name: impl Into<String>) -> Self {
536        Self {
537            name: name.into(),
538            kind: InputKind::Number,
539            required: true,
540            validators: vec![Box::new(Required), Box::new(IntegerFormat)],
541            options: Vec::new(),
542        }
543    }
544
545    /// New floating-point field. Renders as `<input type="number">`
546    /// (no `step` set; HTML's default accepts decimals). Validates
547    /// only `Required`; the macro's parse step is what catches
548    /// non-numeric input — the field-level validator would reject
549    /// integer literals which is the wrong shape for an f64 field.
550    pub fn float(name: impl Into<String>) -> Self {
551        Self {
552            name: name.into(),
553            kind: InputKind::Number,
554            required: true,
555            validators: vec![Box::new(Required), Box::new(FloatFormat)],
556            options: Vec::new(),
557        }
558    }
559
560    /// New boolean field. Required-by-default would be wrong here
561    /// (HTML emits the field key only when the box is checked), so
562    /// boolean fields skip `Required`.
563    pub fn boolean(name: impl Into<String>) -> Self {
564        Self {
565            name: name.into(),
566            kind: InputKind::Checkbox,
567            required: false,
568            validators: Vec::new(),
569            options: Vec::new(),
570        }
571    }
572
573    /// New closed-set select field. `options` are `(value, label)`
574    /// pairs from a `ChoiceField`'s `VALUES`/`LABELS`. `nullable`
575    /// prepends a leading empty option and drops `Required`.
576    pub fn select(name: impl Into<String>, options: Vec<(String, String)>, nullable: bool) -> Self {
577        let mut opts = options;
578        if nullable {
579            opts.insert(0, (String::new(), String::new()));
580        }
581        Self {
582            name: name.into(),
583            kind: InputKind::Select,
584            required: !nullable,
585            validators: Vec::new(),
586            options: opts,
587        }
588    }
589
590    /// New single-select FK field. `options` are fetched async at
591    /// render time (Task 6); at validate time only `pk_kind` is used to
592    /// parse the submitted id.
593    pub fn model_choice(
594        name: impl Into<String>,
595        target_table: &'static str,
596        label_field: Option<&'static str>,
597        pk_kind: PkKind,
598        nullable: bool,
599    ) -> Self {
600        Self {
601            name: name.into(),
602            kind: InputKind::ModelChoice {
603                target_table,
604                label_field,
605                pk_kind,
606            },
607            required: !nullable,
608            validators: Vec::new(),
609            options: Vec::new(),
610        }
611    }
612
613    /// New multi-select M2M field. Options are fetched async at render
614    /// time; submission is a list of child ids written to the junction
615    /// table after the parent insert.
616    pub fn model_multi_choice(
617        name: impl Into<String>,
618        target_table: &'static str,
619        label_field: Option<&'static str>,
620        pk_kind: PkKind,
621    ) -> Self {
622        Self {
623            name: name.into(),
624            kind: InputKind::ModelMultiChoice {
625                target_table,
626                label_field,
627                pk_kind,
628            },
629            required: false,
630            validators: Vec::new(),
631            options: Vec::new(),
632        }
633    }
634
635    /// Mark the field as optional. The `validate` method
636    /// short-circuits when `required` is false and the value is
637    /// empty, so the `Required` validator (if any) doesn't fire on
638    /// an empty optional field. Validators that wrap non-empty
639    /// values (`MinLength`, `Pattern`, ...) still run when there's
640    /// something to check.
641    pub fn optional(mut self) -> Self {
642        self.required = false;
643        self
644    }
645
646    /// Add a `MinLength(n)` validator. Builder method, returns self.
647    pub fn min_length(mut self, n: usize) -> Self {
648        self.validators.push(Box::new(MinLength(n)));
649        self
650    }
651
652    /// Add a `MaxLength(n)` validator. Builder method, returns self.
653    pub fn max_length(mut self, n: usize) -> Self {
654        self.validators.push(Box::new(MaxLength(n)));
655        self
656    }
657
658    /// Add a custom validator. Named `with_validator` rather than
659    /// `add` so it doesn't shadow `std::ops::Add::add` for clippy.
660    pub fn with_validator(mut self, v: impl Validator + 'static) -> Self {
661        self.validators.push(Box::new(v));
662        self
663    }
664
665    /// Run every validator over `value`. Errors push onto `errors`.
666    /// An empty value on a non-required field skips validation
667    /// entirely (an optional empty input is valid).
668    pub fn validate(&self, value: &str, errors: &mut ValidationErrors) {
669        if !self.required && value.is_empty() {
670            return;
671        }
672        for v in &self.validators {
673            if let Err(msg) = v.check(&self.name, value) {
674                errors.add(&self.name, msg);
675            }
676        }
677    }
678
679    /// Render the field as a single HTML `<input>` element. The
680    /// `value` is the form's prefill (empty for a fresh form, the
681    /// raw user input on a re-render after validation failed).
682    pub fn render_html(&self, value: &str) -> String {
683        let safe_value = html_escape(value);
684        let name = html_escape(&self.name);
685        let required = if self.required { " required" } else { "" };
686        match self.kind {
687            InputKind::Textarea => {
688                format!("<textarea name=\"{name}\"{required}>{safe_value}</textarea>",)
689            }
690            InputKind::Checkbox => {
691                let checked = if value == "true" || value == "on" || value == "1" {
692                    " checked"
693                } else {
694                    ""
695                };
696                format!("<input type=\"checkbox\" name=\"{name}\" value=\"true\"{checked}>",)
697            }
698            InputKind::Select => {
699                let mut s = format!("<select name=\"{name}\"{required}>", required = required);
700                for (val, label) in &self.options {
701                    let selected = if val == value { " selected" } else { "" };
702                    s.push_str(&format!(
703                        "<option value=\"{v}\"{selected}>{l}</option>",
704                        v = html_escape(val),
705                        l = html_escape(label),
706                    ));
707                }
708                s.push_str("</select>");
709                s
710            }
711            InputKind::File => format!(
712                // No `value` attribute: browsers reject programmatic
713                // file-input values, and echoing the storage key into
714                // the markup would leak it. The prefill is intentionally
715                // dropped.
716                "<input type=\"file\" name=\"{name}\"{required}>",
717            ),
718            other => format!(
719                "<input type=\"{ty}\" name=\"{name}\" value=\"{safe_value}\"{required}>",
720                ty = other.html_type(),
721            ),
722        }
723    }
724
725    /// Async render entry point. `ModelChoice` / `ModelMultiChoice`
726    /// fetch their options first, then render the `<select>`; every
727    /// other kind defers to the sync `render_html`.
728    pub async fn render_html_async(&self, value: &str) -> String {
729        match self.kind {
730            InputKind::ModelChoice {
731                target_table,
732                label_field,
733                ..
734            } => {
735                let options =
736                    crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
737                self.render_select(&options, value, false)
738            }
739            InputKind::ModelMultiChoice {
740                target_table,
741                label_field,
742                ..
743            } => {
744                let options =
745                    crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
746                self.render_select(&options, value, true)
747            }
748            _ => self.render_html(value),
749        }
750    }
751
752    /// Shared `<select>` writer for `ModelChoice` / `ModelMultiChoice`.
753    /// `multiple` adds the `multiple` attribute. `selected` matches the
754    /// option value against the prefill `value`.
755    fn render_select(&self, options: &[(String, String)], value: &str, multiple: bool) -> String {
756        let multiple_attr = if multiple { " multiple" } else { "" };
757        let required = if self.required { " required" } else { "" };
758        let mut s = format!(
759            "<select name=\"{name}\"{multiple_attr}{required}>",
760            name = html_escape(&self.name),
761        );
762        if !multiple && !self.required {
763            s.push_str("<option value=\"\"></option>");
764        }
765        for (val, label) in options {
766            let selected = if val == value { " selected" } else { "" };
767            s.push_str(&format!(
768                "<option value=\"{v}\"{selected}>{l}</option>",
769                v = html_escape(val),
770                l = html_escape(label),
771            ));
772        }
773        s.push_str("</select>");
774        s
775    }
776}
777
778/// `IntegerFormat` is a private validator used by `Field::integer`.
779/// Not exported as a builder method because every numeric field
780/// already gets it.
781struct IntegerFormat;
782impl Validator for IntegerFormat {
783    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
784        value
785            .parse::<i64>()
786            .map(|_| ())
787            .map_err(|_| format!("{field_name} must be a whole number"))
788    }
789}
790
791/// `FloatFormat` is the float-field counterpart. Accepts anything
792/// that parses as `f64`, which includes integer literals like `"42"`
793/// — JS's `parseFloat` does the same.
794struct FloatFormat;
795impl Validator for FloatFormat {
796    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
797        value
798            .parse::<f64>()
799            .map(|_| ())
800            .map_err(|_| format!("{field_name} must be a number"))
801    }
802}
803
804// =========================================================================
805// HTML escaping. Inline so the module doesn't pull in an extra crate.
806// Covers the five chars the OWASP cheat sheet flags.
807// =========================================================================
808
809fn html_escape(input: &str) -> String {
810    let mut out = String::with_capacity(input.len());
811    for ch in input.chars() {
812        match ch {
813            '&' => out.push_str("&amp;"),
814            '<' => out.push_str("&lt;"),
815            '>' => out.push_str("&gt;"),
816            '"' => out.push_str("&quot;"),
817            '\'' => out.push_str("&#x27;"),
818            other => out.push(other),
819        }
820    }
821    out
822}
823
824// =========================================================================
825// gaps2 #19 — `Form<T>` axum extractor + `FormErrors` lifter
826//
827// The architectural rule (per gaps2 #19's spec): validation errors
828// originate at the ORM's `WriteError`. Every surface MAPS them, none
829// REDEFINES them. `ValidationErrors` is the form-specific producer;
830// `WriteError::Multiple` is the unified consumer. `FormErrors` is a
831// thin wrapper around `WriteError` that adds the
832// template-friendly flat view (`errors.name` → first error string).
833// =========================================================================
834
835use crate::orm::write::WriteError;
836
837/// Form-validation error envelope. Wraps the ORM's `WriteError` so
838/// every surface (REST 400 bodies, admin form spans, HTML form
839/// renders) sees the same structured shape. The template helper
840/// `as_template_ctx` produces the flat single-string-per-field view
841/// that most form templates ask for.
842///
843/// Not `Clone` because `WriteError` carries a `sqlx::Error` variant
844/// that's also not Clone. If you need a cheap copyable bundle of
845/// rendered messages, use [`Self::as_template_ctx`] which returns
846/// an owned `serde_json::Map`.
847#[derive(Debug)]
848pub struct FormErrors {
849    inner: WriteError,
850    /// The raw form pairs the user submitted, captured before
851    /// validation ran. Lets the handler re-render the form template
852    /// pre-filled with what the user typed — see
853    /// [`Self::raw_values`] and [`Self::raw_as_json`]. The
854    /// extractor (`Form::from_request`) carries this through
855    /// automatically; the `From<ValidationErrors>` path leaves it
856    /// empty (no raw input was ever in scope), which is the right
857    /// default for handlers that build a `FormErrors` from scratch
858    /// for ad-hoc errors.
859    raw: HashMap<String, String>,
860}
861
862impl FormErrors {
863    /// Wrap any [`WriteError`]. Use [`From`] for free conversion in
864    /// `?` chains. The raw values default to empty — call
865    /// [`Self::with_raw`] when you have the submitted pairs in
866    /// scope (typically only inside an axum extractor).
867    pub fn new(err: WriteError) -> Self {
868        Self {
869            inner: err,
870            raw: HashMap::new(),
871        }
872    }
873
874    /// Construct a `FormErrors` carrying both the validation
875    /// failure AND the raw form pairs the user submitted. The raw
876    /// pairs let the handler re-render the form pre-filled with
877    /// what the user typed instead of falling back to
878    /// `T::default()` (which loses every keystroke).
879    pub fn with_raw(err: WriteError, raw: HashMap<String, String>) -> Self {
880        Self { inner: err, raw }
881    }
882
883    /// Borrow the raw form pairs the user submitted, if the
884    /// extractor captured them. Empty when the [`FormErrors`] was
885    /// constructed via [`Self::new`] or any `From` impl that
886    /// doesn't see the request body.
887    pub fn raw_values(&self) -> &HashMap<String, String> {
888        &self.raw
889    }
890
891    /// JSON-shaped view of the raw values, ready to drop straight
892    /// into a template context as the `form` key so existing
893    /// `{{ form.<field> }}` references repopulate the user's
894    /// input. The map is `String → String` so every value
895    /// serialises to a JSON string — templates that need typed
896    /// access should call [`Self::raw_values`] and convert per
897    /// field.
898    pub fn raw_as_json(&self) -> serde_json::Value {
899        serde_json::Value::Object(
900            self.raw
901                .iter()
902                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
903                .collect(),
904        )
905    }
906
907    /// Borrow the underlying [`WriteError`] — keeps every accessor
908    /// available (`field_errors()`, `non_field_errors()`,
909    /// `error_code()`).
910    pub fn as_write_error(&self) -> &WriteError {
911        &self.inner
912    }
913
914    /// Move out the underlying [`WriteError`] (e.g. to feed a
915    /// REST-style error-body builder).
916    pub fn into_write_error(self) -> WriteError {
917        self.inner
918    }
919
920    /// Per-field error map — see [`WriteError::field_errors`].
921    pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
922        self.inner.field_errors()
923    }
924
925    /// Cross-field / non-field error list — see
926    /// [`WriteError::non_field_errors`].
927    pub fn non_field_errors(&self) -> Vec<String> {
928        self.inner.non_field_errors()
929    }
930
931    /// Template-friendly flat view: each field maps to its FIRST
932    /// error message (string), plus the FIRST non-field error under
933    /// the `form` key. Renders directly under the `errors` context
934    /// key — templates write `{{ errors.name }}` or
935    /// `{% if errors.form %}`.
936    ///
937    /// For templates that need to render EVERY error per field
938    /// (rare), call [`field_errors`] / [`non_field_errors`]
939    /// directly and pass the maps as-is.
940    pub fn as_template_ctx(&self) -> serde_json::Map<String, serde_json::Value> {
941        let mut out = serde_json::Map::new();
942        for (key, msgs) in self.field_errors() {
943            if let Some(first) = msgs.into_iter().next() {
944                out.insert(key, serde_json::Value::String(first));
945            }
946        }
947        if let Some(first) = self.non_field_errors().into_iter().next() {
948            out.insert("form".to_string(), serde_json::Value::String(first));
949        }
950        out
951    }
952
953    /// Render `template` with this failed submission bound into scope
954    /// and return the complete HTTP response. The one-liner for a form
955    /// handler's `Err` arm:
956    ///
957    /// ```ignore
958    /// let msg = match form.into_result() {
959    ///     Ok(v) => v,
960    ///     Err(errs) => return Ok(errs.render("contact.html")),
961    /// };
962    /// ```
963    ///
964    /// What the template sees:
965    ///
966    /// - `form` — the raw pairs the user submitted, so
967    ///   `{{ form.<field> }}` repopulates every keystroke.
968    /// - `errors` — the flat per-field view from
969    ///   [`Self::as_template_ctx`], plus a default form-level summary
970    ///   under `errors.form` ("Please fix the highlighted fields and
971    ///   try again.") when no non-field error supplied one — every
972    ///   form page wants the banner, so the framework defaults it.
973    /// - Anything ambient (`csrf_token` / `csrf_input` / `user`) via
974    ///   the normal render merge.
975    ///
976    /// Status is `422 Unprocessable Entity`. A template failure
977    /// returns a plain 500 carrying the render error. Extra context
978    /// keys (page flags, chrome): [`Self::render_with`].
979    pub fn render(&self, template: &str) -> axum::response::Response {
980        self.render_with(template, serde_json::Map::new())
981    }
982
983    /// [`Self::render`] plus caller-supplied top-level context keys.
984    /// `extra` wins over the `form` / `errors` bindings on key
985    /// collision — the caller is more specific than the default.
986    pub fn render_with(
987        &self,
988        template: &str,
989        extra: serde_json::Map<String, serde_json::Value>,
990    ) -> axum::response::Response {
991        use axum::response::IntoResponse;
992
993        let mut errors = self.as_template_ctx();
994        errors.entry("form".to_string()).or_insert_with(|| {
995            serde_json::Value::String(
996                "Please fix the highlighted fields and try again.".to_string(),
997            )
998        });
999
1000        let mut ctx = serde_json::Map::new();
1001        ctx.insert("form".to_string(), self.raw_as_json());
1002        ctx.insert("errors".to_string(), serde_json::Value::Object(errors));
1003        for (k, v) in extra {
1004            ctx.insert(k, v);
1005        }
1006
1007        match crate::templates::render(template, &serde_json::Value::Object(ctx)) {
1008            Ok(html) => (
1009                axum::http::StatusCode::UNPROCESSABLE_ENTITY,
1010                axum::response::Html(html),
1011            )
1012                .into_response(),
1013            Err(e) => {
1014                // The template name and raw minijinja error can carry
1015                // internal paths / line numbers — log them server-side,
1016                // never disclose them to the client. Mirrors the 500
1017                // handler's generic-body-plus-tracing pattern in
1018                // `errors.rs::render_500`.
1019                tracing::error!("form re-render failed for `{template}`: {e}");
1020                (
1021                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1022                    "Internal Server Error",
1023                )
1024                    .into_response()
1025            }
1026        }
1027    }
1028}
1029
1030impl std::fmt::Display for FormErrors {
1031    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1032        write!(f, "{}", self.inner)
1033    }
1034}
1035
1036impl std::error::Error for FormErrors {}
1037
1038impl From<WriteError> for FormErrors {
1039    fn from(e: WriteError) -> Self {
1040        Self::new(e)
1041    }
1042}
1043
1044/// Lift the form-primitive [`ValidationErrors`] into the canonical
1045/// [`WriteError`]. Each per-field message becomes a
1046/// `WriteError::Validator { field, message }`; non-field messages
1047/// become an `Anonymous` validator carrying the bare message.
1048/// Wrapped under `WriteError::Multiple` when there's more than one.
1049impl From<ValidationErrors> for WriteError {
1050    fn from(e: ValidationErrors) -> Self {
1051        let mut out: Vec<WriteError> = Vec::new();
1052        for (field, msgs) in e.fields {
1053            for message in msgs {
1054                out.push(WriteError::Validator {
1055                    field: field.clone(),
1056                    message,
1057                });
1058            }
1059        }
1060        for message in e.non_field {
1061            out.push(WriteError::Validator {
1062                field: String::new(),
1063                message,
1064            });
1065        }
1066        if out.len() == 1 {
1067            out.into_iter().next().expect("len == 1")
1068        } else {
1069            WriteError::Multiple { errors: out }
1070        }
1071    }
1072}
1073
1074impl From<ValidationErrors> for FormErrors {
1075    fn from(e: ValidationErrors) -> Self {
1076        Self::new(e.into())
1077    }
1078}
1079
1080/// Axum extractor that validates a form body before the handler
1081/// runs. On extraction success the wrapped result is
1082/// `Ok(T)` (the validated struct); on validation failure the
1083/// wrapped result is `Err(FormErrors)`. The HTTP layer never
1084/// rejects — handlers ALWAYS see a `Form<T>` and decide what to
1085/// render via [`Self::into_result`].
1086///
1087/// ```ignore
1088/// use umbral::forms::Form;
1089///
1090/// pub async fn submit(form: Form<ContactForm>) -> impl IntoResponse {
1091///     match form.into_result() {
1092///         Ok(valid)  => persist_and_redirect(valid).await,
1093///         Err(errs)  => render_form_with_errors(errs),
1094///     }
1095/// }
1096/// ```
1097///
1098/// The "always wrap, handler unwraps" shape (vs. axum's rejection-
1099/// type pattern) lets the handler render the form template with
1100/// the user's original input AND the per-field errors in one place
1101/// — no double-render dance, no rejection-type IntoResponse impl
1102/// to write per form.
1103pub struct Form<T> {
1104    inner: Result<T, FormErrors>,
1105}
1106
1107impl<T> Form<T> {
1108    /// Construct a `Form<T>` carrying a validated value.
1109    pub fn valid(value: T) -> Self {
1110        Self { inner: Ok(value) }
1111    }
1112
1113    /// Construct a `Form<T>` carrying validation errors.
1114    pub fn invalid(errors: FormErrors) -> Self {
1115        Self { inner: Err(errors) }
1116    }
1117
1118    /// Move the wrapped `Result` out. Handlers branch on this.
1119    pub fn into_result(self) -> Result<T, FormErrors> {
1120        self.inner
1121    }
1122
1123    /// Borrow the wrapped `Result` for inspection without consuming.
1124    pub fn as_result(&self) -> Result<&T, &FormErrors> {
1125        self.inner.as_ref()
1126    }
1127}
1128
1129impl<T, S> axum::extract::FromRequest<S> for Form<T>
1130where
1131    T: FormValidate + serde::de::DeserializeOwned + Send + 'static,
1132    S: Send + Sync,
1133{
1134    type Rejection = axum::response::Response;
1135
1136    async fn from_request(
1137        req: axum::extract::Request,
1138        _state: &S,
1139    ) -> Result<Self, Self::Rejection> {
1140        use axum::body::to_bytes;
1141        use axum::http::StatusCode;
1142        use axum::response::IntoResponse;
1143
1144        // BROKEN-8: this extractor only understands
1145        // `application/x-www-form-urlencoded`. A client POSTing JSON or
1146        // multipart used to have its body parse to an empty map and then
1147        // get a wall of "field required" errors — mis-diagnosing a wrong
1148        // Content-Type as missing fields. Reject a present-but-wrong
1149        // Content-Type up front with 415, like axum's own `Form`.
1150        let content_type = req
1151            .headers()
1152            .get(axum::http::header::CONTENT_TYPE)
1153            .and_then(|v| v.to_str().ok())
1154            .map(|s| s.to_ascii_lowercase());
1155        if let Some(ct) = &content_type
1156            && !ct.starts_with("application/x-www-form-urlencoded")
1157        {
1158            return Err((
1159                StatusCode::UNSUPPORTED_MEDIA_TYPE,
1160                "this endpoint expects application/x-www-form-urlencoded form data",
1161            )
1162                .into_response());
1163        }
1164
1165        // Read the body up to the CONFIGURED limit. Defaults to 16 MiB
1166        // (`Settings::max_form_body_bytes`); set `UMBRAL_MAX_FORM_BODY_BYTES`, or
1167        // `0` to disable the cap entirely. Buffering an unbounded urlencoded
1168        // body is a DoS risk, so a cap is the default — but it's no longer
1169        // hardcoded, and `0` removes it for dev / large forms.
1170        const FALLBACK_MAX_FORM_BODY: usize = 16 * 1024 * 1024;
1171        let max_body = match crate::settings::get_opt() {
1172            Some(s) => match s.max_form_body_bytes {
1173                None | Some(0) => usize::MAX, // explicitly disabled = no cap
1174                Some(n) => n,
1175            },
1176            None => FALLBACK_MAX_FORM_BODY, // Settings not published (low-level tests)
1177        };
1178        let bytes = match to_bytes(req.into_body(), max_body).await {
1179            Ok(b) => b,
1180            Err(_) => {
1181                return Err((
1182                    StatusCode::PAYLOAD_TOO_LARGE,
1183                    "form body exceeds the configured limit (Settings::max_form_body_bytes)",
1184                )
1185                    .into_response());
1186            }
1187        };
1188
1189        // Parse x-www-form-urlencoded. We decode into an ORDERED list of
1190        // `(key, value)` pairs first — NOT straight into a
1191        // `HashMap<String, String>` — because a map keeps only the LAST
1192        // value for a repeated key, so a multi-select
1193        // (`roles=admin&roles=editor`) would silently lose every value but
1194        // the last. Repeated keys are collapsed by joining their values
1195        // with `,`, which is exactly the CSV shape the multi-value parsers
1196        // split back apart (`orm::multichoice` for `MultiChoice<E>`,
1197        // `forms_runtime::parse_multi_ids` for `M2M<T>`, both `.split(',')`).
1198        // Single-value keys are inserted verbatim, so the String->String
1199        // contract every `#[derive(Form)]` impl relies on is unchanged.
1200        //
1201        // An empty body parses to an empty list (Ok) — `FormValidate::validate`
1202        // then sees every field as missing and surfaces the right per-field
1203        // "required" errors. BROKEN-8: a genuinely MALFORMED body must not
1204        // be swallowed into an empty map (that re-runs as bogus "field
1205        // required" errors); surface it as a 400 naming the parse failure.
1206        let ordered: Vec<(String, String)> = match serde_urlencoded::from_bytes(&bytes) {
1207            Ok(pairs) => pairs,
1208            Err(e) => {
1209                return Err((
1210                    StatusCode::BAD_REQUEST,
1211                    format!("malformed urlencoded form body: {e}"),
1212                )
1213                    .into_response());
1214            }
1215        };
1216        let mut pairs: std::collections::HashMap<String, String> =
1217            std::collections::HashMap::with_capacity(ordered.len());
1218        for (key, value) in ordered {
1219            match pairs.entry(key) {
1220                std::collections::hash_map::Entry::Occupied(mut slot) => {
1221                    let joined = slot.get_mut();
1222                    joined.push(',');
1223                    joined.push_str(&value);
1224                }
1225                std::collections::hash_map::Entry::Vacant(slot) => {
1226                    slot.insert(value);
1227                }
1228            }
1229        }
1230
1231        // Run validation. On success, we've already proven the data
1232        // fits T's shape — return Ok(T). On failure, lift the
1233        // ValidationErrors to a FormErrors AND attach the raw
1234        // pairs so the handler can render the template pre-filled
1235        // with what the user typed. Without this the user loses
1236        // every keystroke on validation failure — see gaps2 #19
1237        // follow-up commit for the bug screenshot that prompted
1238        // this change.
1239        match T::validate(&pairs).await {
1240            Ok(value) => Ok(Self::valid(value)),
1241            Err(errs) => {
1242                let write_err: WriteError = errs.into();
1243                Ok(Self::invalid(FormErrors::with_raw(write_err, pairs)))
1244            }
1245        }
1246    }
1247}
1248
1249// =========================================================================
1250// Tests live inline because the surface is pure (no DB, no async).
1251// =========================================================================
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256
1257    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1258        pairs
1259            .iter()
1260            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1261            .collect()
1262    }
1263
1264    #[test]
1265    fn required_field_rejects_empty_value() {
1266        let f = Field::text("username");
1267        let mut errs = ValidationErrors::new();
1268        let form = data(&[("username", "")]);
1269        f.validate(form.get("username").unwrap(), &mut errs);
1270        assert!(errs.fields.contains_key("username"));
1271        assert!(errs.fields["username"][0].contains("required"));
1272    }
1273
1274    #[test]
1275    fn optional_field_with_empty_value_passes() {
1276        let f = Field::text("bio").optional();
1277        let mut errs = ValidationErrors::new();
1278        f.validate("", &mut errs);
1279        assert!(errs.is_empty());
1280    }
1281
1282    #[test]
1283    fn min_max_length_combine_on_one_field() {
1284        let f = Field::text("title").min_length(3).max_length(5);
1285        let mut errs = ValidationErrors::new();
1286        f.validate("ab", &mut errs);
1287        assert!(errs.fields["title"][0].contains("at least 3"));
1288
1289        let mut errs = ValidationErrors::new();
1290        f.validate("toolong", &mut errs);
1291        assert!(errs.fields["title"][0].contains("at most 5"));
1292
1293        let mut errs = ValidationErrors::new();
1294        f.validate("abcd", &mut errs);
1295        assert!(errs.is_empty());
1296    }
1297
1298    #[test]
1299    fn integer_field_rejects_non_numeric_input() {
1300        let f = Field::integer("age");
1301        let mut errs = ValidationErrors::new();
1302        f.validate("twelve", &mut errs);
1303        assert!(errs.fields["age"][0].contains("whole number"));
1304
1305        let mut errs = ValidationErrors::new();
1306        f.validate("42", &mut errs);
1307        assert!(errs.is_empty());
1308    }
1309
1310    #[test]
1311    fn email_field_runs_the_built_in_format_check() {
1312        let f = Field::email("email");
1313
1314        let mut errs = ValidationErrors::new();
1315        f.validate("not-an-email", &mut errs);
1316        assert!(!errs.is_empty());
1317
1318        let mut errs = ValidationErrors::new();
1319        f.validate("alice@example.com", &mut errs);
1320        assert!(errs.is_empty());
1321
1322        // Local part missing
1323        let mut errs = ValidationErrors::new();
1324        f.validate("@example.com", &mut errs);
1325        assert!(!errs.is_empty());
1326
1327        // Domain missing a dot
1328        let mut errs = ValidationErrors::new();
1329        f.validate("alice@example", &mut errs);
1330        assert!(!errs.is_empty());
1331    }
1332
1333    #[test]
1334    fn non_field_errors_propagate_through_into_result() {
1335        let mut errs = ValidationErrors::new();
1336        errs.add_non_field("passwords do not match");
1337        let result = errs.into_result();
1338        match result {
1339            Err(e) => {
1340                assert_eq!(e.non_field.len(), 1);
1341                assert!(e.non_field[0].contains("passwords"));
1342            }
1343            Ok(_) => panic!("non-field error should fail into_result"),
1344        }
1345    }
1346
1347    #[test]
1348    fn render_html_escapes_user_input_against_xss() {
1349        let f = Field::text("title");
1350        let rendered = f.render_html("<script>alert(1)</script>");
1351        assert!(rendered.contains("&lt;script&gt;"));
1352        assert!(!rendered.contains("<script>alert"));
1353        assert!(rendered.contains("name=\"title\""));
1354        assert!(rendered.contains("required"));
1355    }
1356
1357    #[test]
1358    fn render_html_emits_the_right_input_type_per_field_kind() {
1359        assert!(Field::text("a").render_html("").contains("type=\"text\""));
1360        assert!(Field::email("a").render_html("").contains("type=\"email\""));
1361        assert!(
1362            Field::password("a")
1363                .render_html("")
1364                .contains("type=\"password\"")
1365        );
1366        assert!(
1367            Field::integer("a")
1368                .render_html("")
1369                .contains("type=\"number\"")
1370        );
1371        assert!(
1372            Field::boolean("a")
1373                .render_html("")
1374                .contains("type=\"checkbox\"")
1375        );
1376    }
1377
1378    #[test]
1379    fn boolean_field_renders_checked_when_value_is_truthy() {
1380        let f = Field::boolean("is_admin");
1381        assert!(f.render_html("true").contains(" checked"));
1382        assert!(f.render_html("on").contains(" checked"));
1383        assert!(f.render_html("1").contains(" checked"));
1384        assert!(!f.render_html("").contains(" checked"));
1385        assert!(!f.render_html("false").contains(" checked"));
1386    }
1387
1388    /// Demo composition: a tiny LoginForm built from primitive
1389    /// fields. Stands in for what a `#[derive(Form)]` would produce.
1390    /// Validates a HashMap, returns a typed struct, accumulates
1391    /// every field's errors.
1392    #[derive(Debug, PartialEq, Eq)]
1393    struct LoginForm {
1394        username: String,
1395        password: String,
1396    }
1397
1398    impl LoginForm {
1399        fn validate(form: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
1400            let username_field = Field::text("username").min_length(3).max_length(150);
1401            let password_field = Field::password("password").min_length(8);
1402            let mut errs = ValidationErrors::new();
1403            let username = form.get("username").cloned().unwrap_or_default();
1404            let password = form.get("password").cloned().unwrap_or_default();
1405            username_field.validate(&username, &mut errs);
1406            password_field.validate(&password, &mut errs);
1407            errs.into_result()?;
1408            Ok(Self { username, password })
1409        }
1410    }
1411
1412    #[test]
1413    fn login_form_demo_validates_happy_path() {
1414        let input = data(&[("username", "alice"), ("password", "hunter2-stronger")]);
1415        let form = LoginForm::validate(&input).expect("happy path");
1416        assert_eq!(form.username, "alice");
1417        assert_eq!(form.password, "hunter2-stronger");
1418    }
1419
1420    #[test]
1421    fn login_form_demo_collects_every_field_error_at_once() {
1422        let input = data(&[("username", "ab"), ("password", "short")]);
1423        let err = LoginForm::validate(&input).expect_err("both fields fail");
1424        assert!(err.fields.contains_key("username"));
1425        assert!(err.fields.contains_key("password"));
1426        assert!(err.fields["username"][0].contains("at least 3"));
1427        assert!(err.fields["password"][0].contains("at least 8"));
1428    }
1429
1430    // =====================================================================
1431    // gaps2 #19 follow-up — FormErrors carries the raw form pairs so the
1432    // handler can re-render the template pre-filled with what the user
1433    // typed instead of falling back to `T::default()` (which loses every
1434    // keystroke). Screenshot 2026-06-10 01-03-09 reported the data-loss
1435    // bug pre-fix.
1436    // =====================================================================
1437
1438    // =====================================================================
1439    // gaps2 #19 follow-up — regex / phone / url validators
1440    // =====================================================================
1441
1442    #[test]
1443    fn phone_field_accepts_e164_format() {
1444        let f = Field::phone("phone");
1445        let mut errs = ValidationErrors::new();
1446        f.validate("+14155551234", &mut errs);
1447        assert!(errs.is_empty(), "valid E.164 should pass: {:?}", errs);
1448    }
1449
1450    #[test]
1451    fn phone_field_rejects_local_only_format() {
1452        // The bug report case: "07065" got accepted because the
1453        // field had only `optional + max_length`. With `Field::phone`
1454        // (== `#[form(phone)]`) the E.164 regex rejects it.
1455        let f = Field::phone("phone");
1456        let mut errs = ValidationErrors::new();
1457        f.validate("07065", &mut errs);
1458        assert!(errs.fields.contains_key("phone"));
1459        assert!(
1460            errs.fields["phone"][0].contains("E.164"),
1461            "error message names the format: {:?}",
1462            errs.fields["phone"][0]
1463        );
1464    }
1465
1466    #[test]
1467    fn phone_field_rejects_letters_and_punctuation() {
1468        let f = Field::phone("phone");
1469        for bad in &["+1-415-555-1234", "+1 (415) 555 1234", "+1abc", "+0123"] {
1470            let mut errs = ValidationErrors::new();
1471            f.validate(bad, &mut errs);
1472            assert!(
1473                errs.fields.contains_key("phone"),
1474                "should reject `{bad}`: {:?}",
1475                errs.fields
1476            );
1477        }
1478    }
1479
1480    #[test]
1481    fn url_field_accepts_http_and_https_only() {
1482        let f = Field::url("homepage");
1483        for good in &["https://example.com", "http://example.com/path?q=1"] {
1484            let mut errs = ValidationErrors::new();
1485            f.validate(good, &mut errs);
1486            assert!(errs.is_empty(), "should accept `{good}`: {:?}", errs);
1487        }
1488        for bad in &["ftp://example.com", "mailto:a@b.c", "example.com"] {
1489            let mut errs = ValidationErrors::new();
1490            f.validate(bad, &mut errs);
1491            assert!(
1492                errs.fields.contains_key("homepage"),
1493                "should reject `{bad}`: {:?}",
1494                errs.fields
1495            );
1496        }
1497    }
1498
1499    #[test]
1500    fn regex_validator_substitutes_field_in_message() {
1501        // {field} placeholder gets the actual field name — useful
1502        // for reusable messages across multiple forms.
1503        let f = Field::text("invoice_id")
1504            .regex(r"^INV-\d{6}$", "{field} must match the invoice pattern");
1505        let mut errs = ValidationErrors::new();
1506        f.validate("not-an-invoice", &mut errs);
1507        assert_eq!(
1508            errs.fields["invoice_id"][0],
1509            "invoice_id must match the invoice pattern"
1510        );
1511    }
1512
1513    #[test]
1514    fn regex_validator_composes_with_required_and_max_length() {
1515        // Order: Required runs FIRST (empty → "is required"),
1516        // then max_length, then regex. An empty value should
1517        // surface the "required" error, not "doesn't match pattern".
1518        let f = Field::text("code")
1519            .max_length(8)
1520            .regex(r"^[A-Z]{3}$", "{field} must be 3 uppercase letters");
1521
1522        let mut errs = ValidationErrors::new();
1523        f.validate("", &mut errs);
1524        assert!(
1525            errs.fields["code"][0].contains("required"),
1526            "empty surfaces required error first: {:?}",
1527            errs.fields["code"][0]
1528        );
1529
1530        let mut errs = ValidationErrors::new();
1531        f.validate("HELLO", &mut errs);
1532        assert!(
1533            errs.fields["code"][0].contains("3 uppercase"),
1534            "regex error fires when value is present but malformed: {:?}",
1535            errs.fields["code"][0]
1536        );
1537    }
1538
1539    #[test]
1540    fn form_errors_with_raw_round_trips_the_submitted_pairs() {
1541        let mut raw = HashMap::new();
1542        raw.insert("name".to_string(), "Bella Verifier".to_string());
1543        raw.insert("email".to_string(), "bella@invalid".to_string());
1544        raw.insert("phone".to_string(), "none".to_string());
1545
1546        let errs = FormErrors::with_raw(
1547            WriteError::Validator {
1548                field: "email".to_string(),
1549                message: "email's domain must contain at least one `.`".to_string(),
1550            },
1551            raw.clone(),
1552        );
1553
1554        // Raw values survive untouched.
1555        assert_eq!(
1556            errs.raw_values().get("name").map(|s| s.as_str()),
1557            Some("Bella Verifier"),
1558        );
1559        assert_eq!(
1560            errs.raw_values().get("phone").map(|s| s.as_str()),
1561            Some("none"),
1562        );
1563
1564        // JSON shape is a flat `{ field: "literal user input" }` map,
1565        // ready to drop straight into a template ctx as `form` so
1566        // `{{ form.name }}` repopulates.
1567        let json = errs.raw_as_json();
1568        let obj = json.as_object().expect("raw_as_json is an object");
1569        assert_eq!(
1570            obj.get("name").and_then(|v| v.as_str()),
1571            Some("Bella Verifier")
1572        );
1573        assert_eq!(obj.get("phone").and_then(|v| v.as_str()), Some("none"));
1574    }
1575
1576    #[test]
1577    fn field_name_is_html_escaped_in_rendered_markup() {
1578        // Field names are static struct identifiers in the derive-macro
1579        // norm, but `Field::text(user_supplied_name)` is a public path.
1580        // The `name` attribute must be HTML-escaped so a crafted name
1581        // can't break out of the attribute and inject markup/handlers.
1582        let malicious = "x\" onfocus=\"alert(1)";
1583
1584        // Text input
1585        let out = Field::text(malicious).render_html("");
1586        assert!(
1587            !out.contains("onfocus=\"alert(1)"),
1588            "text input leaked an unescaped name attribute: {out}"
1589        );
1590        assert!(out.contains("&quot;"), "name should be escaped: {out}");
1591
1592        // Textarea
1593        let mut ta = Field::text(malicious);
1594        ta.kind = InputKind::Textarea;
1595        let out = ta.render_html("");
1596        assert!(
1597            !out.contains("onfocus=\"alert(1)"),
1598            "textarea leaked an unescaped name attribute: {out}"
1599        );
1600
1601        // Checkbox
1602        let mut cb = Field::text(malicious);
1603        cb.kind = InputKind::Checkbox;
1604        let out = cb.render_html("");
1605        assert!(
1606            !out.contains("onfocus=\"alert(1)"),
1607            "checkbox leaked an unescaped name attribute: {out}"
1608        );
1609
1610        // Select
1611        let mut sel = Field::text(malicious);
1612        sel.kind = InputKind::Select;
1613        let out = sel.render_html("");
1614        assert!(
1615            !out.contains("onfocus=\"alert(1)"),
1616            "select leaked an unescaped name attribute: {out}"
1617        );
1618
1619        // File
1620        let mut file = Field::text(malicious);
1621        file.kind = InputKind::File;
1622        let out = file.render_html("");
1623        assert!(
1624            !out.contains("onfocus=\"alert(1)"),
1625            "file input leaked an unescaped name attribute: {out}"
1626        );
1627    }
1628
1629    #[tokio::test]
1630    async fn form_render_failure_does_not_leak_template_name_or_error() {
1631        // A form-template render failure must not disclose the template
1632        // name or the raw minijinja error (which can carry file paths /
1633        // line numbers) to the client. The client gets a generic 500;
1634        // the detail is logged server-side.
1635        let errs = FormErrors::new(WriteError::Validator {
1636            field: "email".to_string(),
1637            message: "invalid".to_string(),
1638        });
1639
1640        // A template name that doesn't exist forces `render` down its
1641        // error branch (missing template, or engine-not-initialised —
1642        // either way, `Err`).
1643        let secret_name = "signup/__nonexistent_secret_path__.html";
1644        let resp = errs.render(secret_name);
1645
1646        assert_eq!(resp.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
1647        let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
1648            .await
1649            .expect("collect body");
1650        let text = String::from_utf8_lossy(&body);
1651        assert!(
1652            !text.contains(secret_name),
1653            "500 body leaked the template name: {text}"
1654        );
1655        assert!(
1656            !text.contains("form re-render failed"),
1657            "500 body leaked internal render-error detail: {text}"
1658        );
1659    }
1660
1661    // A form the extractor can validate. `roles` is a multi-value field:
1662    // the extractor must hand `validate` the CSV-joined value of every
1663    // repeated `roles=` key, not just the last one.
1664    // `serde::Deserialize` only satisfies the extractor's trait bound;
1665    // the extractor itself no longer deserializes the body into `T`.
1666    #[derive(Debug, PartialEq, Eq, serde::Deserialize)]
1667    struct MultiSelectForm {
1668        roles: Vec<String>,
1669    }
1670
1671    #[async_trait::async_trait]
1672    impl FormValidate for MultiSelectForm {
1673        fn fields() -> Vec<Field> {
1674            vec![Field::text("roles").optional()]
1675        }
1676
1677        async fn validate(data: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
1678            // Mirrors how the MultiChoice / M2M runtime splits the joined
1679            // value back into its members (`.split(',')`, empties dropped).
1680            let roles = data
1681                .get("roles")
1682                .map(|raw| {
1683                    raw.split(',')
1684                        .map(str::trim)
1685                        .filter(|s| !s.is_empty())
1686                        .map(ToString::to_string)
1687                        .collect::<Vec<_>>()
1688                })
1689                .unwrap_or_default();
1690            Ok(Self { roles })
1691        }
1692    }
1693
1694    #[tokio::test]
1695    async fn form_extractor_keeps_every_value_of_a_repeated_key() {
1696        use axum::extract::FromRequest;
1697
1698        // A multi-select posts the same key once per selected option.
1699        // Parsing straight into a HashMap<String,String> would keep only
1700        // "editor"; the extractor must preserve both as a CSV value.
1701        let req = axum::http::Request::builder()
1702            .method("POST")
1703            .header(
1704                axum::http::header::CONTENT_TYPE,
1705                "application/x-www-form-urlencoded",
1706            )
1707            .body(axum::body::Body::from("roles=admin&roles=editor"))
1708            .expect("build request");
1709
1710        let form = Form::<MultiSelectForm>::from_request(req, &())
1711            .await
1712            .expect("extractor succeeds");
1713
1714        let value = form.into_result().expect("valid");
1715        assert_eq!(
1716            value.roles,
1717            vec!["admin".to_string(), "editor".to_string()],
1718            "repeated urlencoded key lost values — only the last survived"
1719        );
1720    }
1721
1722    #[test]
1723    fn form_errors_new_defaults_raw_to_empty_for_ad_hoc_construction() {
1724        // FormErrors::new doesn't see the request body — common shape
1725        // for handlers that construct an ad-hoc error after the
1726        // extractor ran. Raw map MUST default to empty, not panic.
1727        let errs = FormErrors::new(WriteError::Validator {
1728            field: "form".to_string(),
1729            message: "rate limited".to_string(),
1730        });
1731        assert!(errs.raw_values().is_empty());
1732        // JSON shape stays a valid empty object — template ctx
1733        // doesn't crash when nothing was submitted.
1734        let json = errs.raw_as_json();
1735        assert!(json.as_object().expect("object").is_empty());
1736    }
1737}