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 = 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 required = if self.required { " required" } else { "" };
685        match self.kind {
686            InputKind::Textarea => format!(
687                "<textarea name=\"{name}\"{required}>{safe_value}</textarea>",
688                name = self.name,
689            ),
690            InputKind::Checkbox => {
691                let checked = if value == "true" || value == "on" || value == "1" {
692                    " checked"
693                } else {
694                    ""
695                };
696                format!(
697                    "<input type=\"checkbox\" name=\"{name}\" value=\"true\"{checked}>",
698                    name = self.name,
699                )
700            }
701            InputKind::Select => {
702                let mut s = format!(
703                    "<select name=\"{name}\"{required}>",
704                    name = self.name,
705                    required = required
706                );
707                for (val, label) in &self.options {
708                    let selected = if val == value { " selected" } else { "" };
709                    s.push_str(&format!(
710                        "<option value=\"{v}\"{selected}>{l}</option>",
711                        v = html_escape(val),
712                        l = html_escape(label),
713                    ));
714                }
715                s.push_str("</select>");
716                s
717            }
718            InputKind::File => format!(
719                // No `value` attribute: browsers reject programmatic
720                // file-input values, and echoing the storage key into
721                // the markup would leak it. The prefill is intentionally
722                // dropped.
723                "<input type=\"file\" name=\"{name}\"{required}>",
724                name = self.name,
725            ),
726            other => format!(
727                "<input type=\"{ty}\" name=\"{name}\" value=\"{safe_value}\"{required}>",
728                ty = other.html_type(),
729                name = self.name,
730            ),
731        }
732    }
733
734    /// Async render entry point. `ModelChoice` / `ModelMultiChoice`
735    /// fetch their options first, then render the `<select>`; every
736    /// other kind defers to the sync `render_html`.
737    pub async fn render_html_async(&self, value: &str) -> String {
738        match self.kind {
739            InputKind::ModelChoice {
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, false)
747            }
748            InputKind::ModelMultiChoice {
749                target_table,
750                label_field,
751                ..
752            } => {
753                let options =
754                    crate::orm::forms_runtime::fetch_model_options(target_table, label_field).await;
755                self.render_select(&options, value, true)
756            }
757            _ => self.render_html(value),
758        }
759    }
760
761    /// Shared `<select>` writer for `ModelChoice` / `ModelMultiChoice`.
762    /// `multiple` adds the `multiple` attribute. `selected` matches the
763    /// option value against the prefill `value`.
764    fn render_select(&self, options: &[(String, String)], value: &str, multiple: bool) -> String {
765        let multiple_attr = if multiple { " multiple" } else { "" };
766        let required = if self.required { " required" } else { "" };
767        let mut s = format!(
768            "<select name=\"{name}\"{multiple_attr}{required}>",
769            name = self.name,
770        );
771        if !multiple && !self.required {
772            s.push_str("<option value=\"\"></option>");
773        }
774        for (val, label) in options {
775            let selected = if val == value { " selected" } else { "" };
776            s.push_str(&format!(
777                "<option value=\"{v}\"{selected}>{l}</option>",
778                v = html_escape(val),
779                l = html_escape(label),
780            ));
781        }
782        s.push_str("</select>");
783        s
784    }
785}
786
787/// `IntegerFormat` is a private validator used by `Field::integer`.
788/// Not exported as a builder method because every numeric field
789/// already gets it.
790struct IntegerFormat;
791impl Validator for IntegerFormat {
792    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
793        value
794            .parse::<i64>()
795            .map(|_| ())
796            .map_err(|_| format!("{field_name} must be a whole number"))
797    }
798}
799
800/// `FloatFormat` is the float-field counterpart. Accepts anything
801/// that parses as `f64`, which includes integer literals like `"42"`
802/// — JS's `parseFloat` does the same.
803struct FloatFormat;
804impl Validator for FloatFormat {
805    fn check(&self, field_name: &str, value: &str) -> Result<(), String> {
806        value
807            .parse::<f64>()
808            .map(|_| ())
809            .map_err(|_| format!("{field_name} must be a number"))
810    }
811}
812
813// =========================================================================
814// HTML escaping. Inline so the module doesn't pull in an extra crate.
815// Covers the five chars the OWASP cheat sheet flags.
816// =========================================================================
817
818fn html_escape(input: &str) -> String {
819    let mut out = String::with_capacity(input.len());
820    for ch in input.chars() {
821        match ch {
822            '&' => out.push_str("&amp;"),
823            '<' => out.push_str("&lt;"),
824            '>' => out.push_str("&gt;"),
825            '"' => out.push_str("&quot;"),
826            '\'' => out.push_str("&#x27;"),
827            other => out.push(other),
828        }
829    }
830    out
831}
832
833// =========================================================================
834// gaps2 #19 — `Form<T>` axum extractor + `FormErrors` lifter
835//
836// The architectural rule (per gaps2 #19's spec): validation errors
837// originate at the ORM's `WriteError`. Every surface MAPS them, none
838// REDEFINES them. `ValidationErrors` is the form-specific producer;
839// `WriteError::Multiple` is the unified consumer. `FormErrors` is a
840// thin wrapper around `WriteError` that adds the
841// template-friendly flat view (`errors.name` → first error string).
842// =========================================================================
843
844use crate::orm::write::WriteError;
845
846/// Form-validation error envelope. Wraps the ORM's `WriteError` so
847/// every surface (REST 400 bodies, admin form spans, HTML form
848/// renders) sees the same structured shape. The template helper
849/// `as_template_ctx` produces the flat single-string-per-field view
850/// that most form templates ask for.
851///
852/// Not `Clone` because `WriteError` carries a `sqlx::Error` variant
853/// that's also not Clone. If you need a cheap copyable bundle of
854/// rendered messages, use [`Self::as_template_ctx`] which returns
855/// an owned `serde_json::Map`.
856#[derive(Debug)]
857pub struct FormErrors {
858    inner: WriteError,
859    /// The raw form pairs the user submitted, captured before
860    /// validation ran. Lets the handler re-render the form template
861    /// pre-filled with what the user typed — see
862    /// [`Self::raw_values`] and [`Self::raw_as_json`]. The
863    /// extractor (`Form::from_request`) carries this through
864    /// automatically; the `From<ValidationErrors>` path leaves it
865    /// empty (no raw input was ever in scope), which is the right
866    /// default for handlers that build a `FormErrors` from scratch
867    /// for ad-hoc errors.
868    raw: HashMap<String, String>,
869}
870
871impl FormErrors {
872    /// Wrap any [`WriteError`]. Use [`From`] for free conversion in
873    /// `?` chains. The raw values default to empty — call
874    /// [`Self::with_raw`] when you have the submitted pairs in
875    /// scope (typically only inside an axum extractor).
876    pub fn new(err: WriteError) -> Self {
877        Self {
878            inner: err,
879            raw: HashMap::new(),
880        }
881    }
882
883    /// Construct a `FormErrors` carrying both the validation
884    /// failure AND the raw form pairs the user submitted. The raw
885    /// pairs let the handler re-render the form pre-filled with
886    /// what the user typed instead of falling back to
887    /// `T::default()` (which loses every keystroke).
888    pub fn with_raw(err: WriteError, raw: HashMap<String, String>) -> Self {
889        Self { inner: err, raw }
890    }
891
892    /// Borrow the raw form pairs the user submitted, if the
893    /// extractor captured them. Empty when the [`FormErrors`] was
894    /// constructed via [`Self::new`] or any `From` impl that
895    /// doesn't see the request body.
896    pub fn raw_values(&self) -> &HashMap<String, String> {
897        &self.raw
898    }
899
900    /// JSON-shaped view of the raw values, ready to drop straight
901    /// into a template context as the `form` key so existing
902    /// `{{ form.<field> }}` references repopulate the user's
903    /// input. The map is `String → String` so every value
904    /// serialises to a JSON string — templates that need typed
905    /// access should call [`Self::raw_values`] and convert per
906    /// field.
907    pub fn raw_as_json(&self) -> serde_json::Value {
908        serde_json::Value::Object(
909            self.raw
910                .iter()
911                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
912                .collect(),
913        )
914    }
915
916    /// Borrow the underlying [`WriteError`] — keeps every accessor
917    /// available (`field_errors()`, `non_field_errors()`,
918    /// `error_code()`).
919    pub fn as_write_error(&self) -> &WriteError {
920        &self.inner
921    }
922
923    /// Move out the underlying [`WriteError`] (e.g. to feed a
924    /// REST-style error-body builder).
925    pub fn into_write_error(self) -> WriteError {
926        self.inner
927    }
928
929    /// Per-field error map — see [`WriteError::field_errors`].
930    pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
931        self.inner.field_errors()
932    }
933
934    /// Cross-field / non-field error list — see
935    /// [`WriteError::non_field_errors`].
936    pub fn non_field_errors(&self) -> Vec<String> {
937        self.inner.non_field_errors()
938    }
939
940    /// Template-friendly flat view: each field maps to its FIRST
941    /// error message (string), plus the FIRST non-field error under
942    /// the `form` key. Renders directly under the `errors` context
943    /// key — templates write `{{ errors.name }}` or
944    /// `{% if errors.form %}`.
945    ///
946    /// For templates that need to render EVERY error per field
947    /// (rare), call [`field_errors`] / [`non_field_errors`]
948    /// directly and pass the maps as-is.
949    pub fn as_template_ctx(&self) -> serde_json::Map<String, serde_json::Value> {
950        let mut out = serde_json::Map::new();
951        for (key, msgs) in self.field_errors() {
952            if let Some(first) = msgs.into_iter().next() {
953                out.insert(key, serde_json::Value::String(first));
954            }
955        }
956        if let Some(first) = self.non_field_errors().into_iter().next() {
957            out.insert("form".to_string(), serde_json::Value::String(first));
958        }
959        out
960    }
961
962    /// Render `template` with this failed submission bound into scope
963    /// and return the complete HTTP response. The one-liner for a form
964    /// handler's `Err` arm:
965    ///
966    /// ```ignore
967    /// let msg = match form.into_result() {
968    ///     Ok(v) => v,
969    ///     Err(errs) => return Ok(errs.render("contact.html")),
970    /// };
971    /// ```
972    ///
973    /// What the template sees:
974    ///
975    /// - `form` — the raw pairs the user submitted, so
976    ///   `{{ form.<field> }}` repopulates every keystroke.
977    /// - `errors` — the flat per-field view from
978    ///   [`Self::as_template_ctx`], plus a default form-level summary
979    ///   under `errors.form` ("Please fix the highlighted fields and
980    ///   try again.") when no non-field error supplied one — every
981    ///   form page wants the banner, so the framework defaults it.
982    /// - Anything ambient (`csrf_token` / `csrf_input` / `user`) via
983    ///   the normal render merge.
984    ///
985    /// Status is `422 Unprocessable Entity`. A template failure
986    /// returns a plain 500 carrying the render error. Extra context
987    /// keys (page flags, chrome): [`Self::render_with`].
988    pub fn render(&self, template: &str) -> axum::response::Response {
989        self.render_with(template, serde_json::Map::new())
990    }
991
992    /// [`Self::render`] plus caller-supplied top-level context keys.
993    /// `extra` wins over the `form` / `errors` bindings on key
994    /// collision — the caller is more specific than the default.
995    pub fn render_with(
996        &self,
997        template: &str,
998        extra: serde_json::Map<String, serde_json::Value>,
999    ) -> axum::response::Response {
1000        use axum::response::IntoResponse;
1001
1002        let mut errors = self.as_template_ctx();
1003        errors.entry("form".to_string()).or_insert_with(|| {
1004            serde_json::Value::String(
1005                "Please fix the highlighted fields and try again.".to_string(),
1006            )
1007        });
1008
1009        let mut ctx = serde_json::Map::new();
1010        ctx.insert("form".to_string(), self.raw_as_json());
1011        ctx.insert("errors".to_string(), serde_json::Value::Object(errors));
1012        for (k, v) in extra {
1013            ctx.insert(k, v);
1014        }
1015
1016        match crate::templates::render(template, &serde_json::Value::Object(ctx)) {
1017            Ok(html) => (
1018                axum::http::StatusCode::UNPROCESSABLE_ENTITY,
1019                axum::response::Html(html),
1020            )
1021                .into_response(),
1022            Err(e) => (
1023                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1024                format!("form re-render failed for `{template}`: {e}"),
1025            )
1026                .into_response(),
1027        }
1028    }
1029}
1030
1031impl std::fmt::Display for FormErrors {
1032    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1033        write!(f, "{}", self.inner)
1034    }
1035}
1036
1037impl std::error::Error for FormErrors {}
1038
1039impl From<WriteError> for FormErrors {
1040    fn from(e: WriteError) -> Self {
1041        Self::new(e)
1042    }
1043}
1044
1045/// Lift the form-primitive [`ValidationErrors`] into the canonical
1046/// [`WriteError`]. Each per-field message becomes a
1047/// `WriteError::Validator { field, message }`; non-field messages
1048/// become an `Anonymous` validator carrying the bare message.
1049/// Wrapped under `WriteError::Multiple` when there's more than one.
1050impl From<ValidationErrors> for WriteError {
1051    fn from(e: ValidationErrors) -> Self {
1052        let mut out: Vec<WriteError> = Vec::new();
1053        for (field, msgs) in e.fields {
1054            for message in msgs {
1055                out.push(WriteError::Validator {
1056                    field: field.clone(),
1057                    message,
1058                });
1059            }
1060        }
1061        for message in e.non_field {
1062            out.push(WriteError::Validator {
1063                field: String::new(),
1064                message,
1065            });
1066        }
1067        if out.len() == 1 {
1068            out.into_iter().next().expect("len == 1")
1069        } else {
1070            WriteError::Multiple { errors: out }
1071        }
1072    }
1073}
1074
1075impl From<ValidationErrors> for FormErrors {
1076    fn from(e: ValidationErrors) -> Self {
1077        Self::new(e.into())
1078    }
1079}
1080
1081/// Axum extractor that validates a form body before the handler
1082/// runs. On extraction success the wrapped result is
1083/// `Ok(T)` (the validated struct); on validation failure the
1084/// wrapped result is `Err(FormErrors)`. The HTTP layer never
1085/// rejects — handlers ALWAYS see a `Form<T>` and decide what to
1086/// render via [`Self::into_result`].
1087///
1088/// ```ignore
1089/// use umbral::forms::Form;
1090///
1091/// pub async fn submit(form: Form<ContactForm>) -> impl IntoResponse {
1092///     match form.into_result() {
1093///         Ok(valid)  => persist_and_redirect(valid).await,
1094///         Err(errs)  => render_form_with_errors(errs),
1095///     }
1096/// }
1097/// ```
1098///
1099/// The "always wrap, handler unwraps" shape (vs. axum's rejection-
1100/// type pattern) lets the handler render the form template with
1101/// the user's original input AND the per-field errors in one place
1102/// — no double-render dance, no rejection-type IntoResponse impl
1103/// to write per form.
1104pub struct Form<T> {
1105    inner: Result<T, FormErrors>,
1106}
1107
1108impl<T> Form<T> {
1109    /// Construct a `Form<T>` carrying a validated value.
1110    pub fn valid(value: T) -> Self {
1111        Self { inner: Ok(value) }
1112    }
1113
1114    /// Construct a `Form<T>` carrying validation errors.
1115    pub fn invalid(errors: FormErrors) -> Self {
1116        Self { inner: Err(errors) }
1117    }
1118
1119    /// Move the wrapped `Result` out. Handlers branch on this.
1120    pub fn into_result(self) -> Result<T, FormErrors> {
1121        self.inner
1122    }
1123
1124    /// Borrow the wrapped `Result` for inspection without consuming.
1125    pub fn as_result(&self) -> Result<&T, &FormErrors> {
1126        self.inner.as_ref()
1127    }
1128}
1129
1130impl<T, S> axum::extract::FromRequest<S> for Form<T>
1131where
1132    T: FormValidate + serde::de::DeserializeOwned + Send + 'static,
1133    S: Send + Sync,
1134{
1135    type Rejection = axum::response::Response;
1136
1137    async fn from_request(
1138        req: axum::extract::Request,
1139        _state: &S,
1140    ) -> Result<Self, Self::Rejection> {
1141        use axum::body::to_bytes;
1142        use axum::http::StatusCode;
1143        use axum::response::IntoResponse;
1144
1145        // BROKEN-8: this extractor only understands
1146        // `application/x-www-form-urlencoded`. A client POSTing JSON or
1147        // multipart used to have its body parse to an empty map and then
1148        // get a wall of "field required" errors — mis-diagnosing a wrong
1149        // Content-Type as missing fields. Reject a present-but-wrong
1150        // Content-Type up front with 415, like axum's own `Form`.
1151        let content_type = req
1152            .headers()
1153            .get(axum::http::header::CONTENT_TYPE)
1154            .and_then(|v| v.to_str().ok())
1155            .map(|s| s.to_ascii_lowercase());
1156        if let Some(ct) = &content_type
1157            && !ct.starts_with("application/x-www-form-urlencoded")
1158        {
1159            return Err((
1160                StatusCode::UNSUPPORTED_MEDIA_TYPE,
1161                "this endpoint expects application/x-www-form-urlencoded form data",
1162            )
1163                .into_response());
1164        }
1165
1166        // Read the body up to the CONFIGURED limit. Defaults to 16 MiB
1167        // (`Settings::max_form_body_bytes`); set `UMBRAL_MAX_FORM_BODY_BYTES`, or
1168        // `0` to disable the cap entirely. Buffering an unbounded urlencoded
1169        // body is a DoS risk, so a cap is the default — but it's no longer
1170        // hardcoded, and `0` removes it for dev / large forms.
1171        const FALLBACK_MAX_FORM_BODY: usize = 16 * 1024 * 1024;
1172        let max_body = match crate::settings::get_opt() {
1173            Some(s) => match s.max_form_body_bytes {
1174                None | Some(0) => usize::MAX, // explicitly disabled = no cap
1175                Some(n) => n,
1176            },
1177            None => FALLBACK_MAX_FORM_BODY, // Settings not published (low-level tests)
1178        };
1179        let bytes = match to_bytes(req.into_body(), max_body).await {
1180            Ok(b) => b,
1181            Err(_) => {
1182                return Err((
1183                    StatusCode::PAYLOAD_TOO_LARGE,
1184                    "form body exceeds the configured limit (Settings::max_form_body_bytes)",
1185                )
1186                    .into_response());
1187            }
1188        };
1189
1190        // Parse x-www-form-urlencoded into a String->String map. An empty
1191        // body parses to an empty map (Ok) — `FormValidate::validate` then
1192        // sees every field as missing and surfaces the right per-field
1193        // "required" errors. BROKEN-8: a genuinely MALFORMED body must not
1194        // be swallowed into an empty map (that re-runs as bogus "field
1195        // required" errors); surface it as a 400 naming the parse failure.
1196        let pairs: std::collections::HashMap<String, String> =
1197            match serde_urlencoded::from_bytes(&bytes) {
1198                Ok(pairs) => pairs,
1199                Err(e) => {
1200                    return Err((
1201                        StatusCode::BAD_REQUEST,
1202                        format!("malformed urlencoded form body: {e}"),
1203                    )
1204                        .into_response());
1205                }
1206            };
1207
1208        // Run validation. On success, we've already proven the data
1209        // fits T's shape — return Ok(T). On failure, lift the
1210        // ValidationErrors to a FormErrors AND attach the raw
1211        // pairs so the handler can render the template pre-filled
1212        // with what the user typed. Without this the user loses
1213        // every keystroke on validation failure — see gaps2 #19
1214        // follow-up commit for the bug screenshot that prompted
1215        // this change.
1216        match T::validate(&pairs).await {
1217            Ok(value) => Ok(Self::valid(value)),
1218            Err(errs) => {
1219                let write_err: WriteError = errs.into();
1220                Ok(Self::invalid(FormErrors::with_raw(write_err, pairs)))
1221            }
1222        }
1223    }
1224}
1225
1226// =========================================================================
1227// Tests live inline because the surface is pure (no DB, no async).
1228// =========================================================================
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233
1234    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1235        pairs
1236            .iter()
1237            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1238            .collect()
1239    }
1240
1241    #[test]
1242    fn required_field_rejects_empty_value() {
1243        let f = Field::text("username");
1244        let mut errs = ValidationErrors::new();
1245        let form = data(&[("username", "")]);
1246        f.validate(form.get("username").unwrap(), &mut errs);
1247        assert!(errs.fields.contains_key("username"));
1248        assert!(errs.fields["username"][0].contains("required"));
1249    }
1250
1251    #[test]
1252    fn optional_field_with_empty_value_passes() {
1253        let f = Field::text("bio").optional();
1254        let mut errs = ValidationErrors::new();
1255        f.validate("", &mut errs);
1256        assert!(errs.is_empty());
1257    }
1258
1259    #[test]
1260    fn min_max_length_combine_on_one_field() {
1261        let f = Field::text("title").min_length(3).max_length(5);
1262        let mut errs = ValidationErrors::new();
1263        f.validate("ab", &mut errs);
1264        assert!(errs.fields["title"][0].contains("at least 3"));
1265
1266        let mut errs = ValidationErrors::new();
1267        f.validate("toolong", &mut errs);
1268        assert!(errs.fields["title"][0].contains("at most 5"));
1269
1270        let mut errs = ValidationErrors::new();
1271        f.validate("abcd", &mut errs);
1272        assert!(errs.is_empty());
1273    }
1274
1275    #[test]
1276    fn integer_field_rejects_non_numeric_input() {
1277        let f = Field::integer("age");
1278        let mut errs = ValidationErrors::new();
1279        f.validate("twelve", &mut errs);
1280        assert!(errs.fields["age"][0].contains("whole number"));
1281
1282        let mut errs = ValidationErrors::new();
1283        f.validate("42", &mut errs);
1284        assert!(errs.is_empty());
1285    }
1286
1287    #[test]
1288    fn email_field_runs_the_built_in_format_check() {
1289        let f = Field::email("email");
1290
1291        let mut errs = ValidationErrors::new();
1292        f.validate("not-an-email", &mut errs);
1293        assert!(!errs.is_empty());
1294
1295        let mut errs = ValidationErrors::new();
1296        f.validate("alice@example.com", &mut errs);
1297        assert!(errs.is_empty());
1298
1299        // Local part missing
1300        let mut errs = ValidationErrors::new();
1301        f.validate("@example.com", &mut errs);
1302        assert!(!errs.is_empty());
1303
1304        // Domain missing a dot
1305        let mut errs = ValidationErrors::new();
1306        f.validate("alice@example", &mut errs);
1307        assert!(!errs.is_empty());
1308    }
1309
1310    #[test]
1311    fn non_field_errors_propagate_through_into_result() {
1312        let mut errs = ValidationErrors::new();
1313        errs.add_non_field("passwords do not match");
1314        let result = errs.into_result();
1315        match result {
1316            Err(e) => {
1317                assert_eq!(e.non_field.len(), 1);
1318                assert!(e.non_field[0].contains("passwords"));
1319            }
1320            Ok(_) => panic!("non-field error should fail into_result"),
1321        }
1322    }
1323
1324    #[test]
1325    fn render_html_escapes_user_input_against_xss() {
1326        let f = Field::text("title");
1327        let rendered = f.render_html("<script>alert(1)</script>");
1328        assert!(rendered.contains("&lt;script&gt;"));
1329        assert!(!rendered.contains("<script>alert"));
1330        assert!(rendered.contains("name=\"title\""));
1331        assert!(rendered.contains("required"));
1332    }
1333
1334    #[test]
1335    fn render_html_emits_the_right_input_type_per_field_kind() {
1336        assert!(Field::text("a").render_html("").contains("type=\"text\""));
1337        assert!(Field::email("a").render_html("").contains("type=\"email\""));
1338        assert!(
1339            Field::password("a")
1340                .render_html("")
1341                .contains("type=\"password\"")
1342        );
1343        assert!(
1344            Field::integer("a")
1345                .render_html("")
1346                .contains("type=\"number\"")
1347        );
1348        assert!(
1349            Field::boolean("a")
1350                .render_html("")
1351                .contains("type=\"checkbox\"")
1352        );
1353    }
1354
1355    #[test]
1356    fn boolean_field_renders_checked_when_value_is_truthy() {
1357        let f = Field::boolean("is_admin");
1358        assert!(f.render_html("true").contains(" checked"));
1359        assert!(f.render_html("on").contains(" checked"));
1360        assert!(f.render_html("1").contains(" checked"));
1361        assert!(!f.render_html("").contains(" checked"));
1362        assert!(!f.render_html("false").contains(" checked"));
1363    }
1364
1365    /// Demo composition: a tiny LoginForm built from primitive
1366    /// fields. Stands in for what a `#[derive(Form)]` would produce.
1367    /// Validates a HashMap, returns a typed struct, accumulates
1368    /// every field's errors.
1369    #[derive(Debug, PartialEq, Eq)]
1370    struct LoginForm {
1371        username: String,
1372        password: String,
1373    }
1374
1375    impl LoginForm {
1376        fn validate(form: &HashMap<String, String>) -> Result<Self, ValidationErrors> {
1377            let username_field = Field::text("username").min_length(3).max_length(150);
1378            let password_field = Field::password("password").min_length(8);
1379            let mut errs = ValidationErrors::new();
1380            let username = form.get("username").cloned().unwrap_or_default();
1381            let password = form.get("password").cloned().unwrap_or_default();
1382            username_field.validate(&username, &mut errs);
1383            password_field.validate(&password, &mut errs);
1384            errs.into_result()?;
1385            Ok(Self { username, password })
1386        }
1387    }
1388
1389    #[test]
1390    fn login_form_demo_validates_happy_path() {
1391        let input = data(&[("username", "alice"), ("password", "hunter2-stronger")]);
1392        let form = LoginForm::validate(&input).expect("happy path");
1393        assert_eq!(form.username, "alice");
1394        assert_eq!(form.password, "hunter2-stronger");
1395    }
1396
1397    #[test]
1398    fn login_form_demo_collects_every_field_error_at_once() {
1399        let input = data(&[("username", "ab"), ("password", "short")]);
1400        let err = LoginForm::validate(&input).expect_err("both fields fail");
1401        assert!(err.fields.contains_key("username"));
1402        assert!(err.fields.contains_key("password"));
1403        assert!(err.fields["username"][0].contains("at least 3"));
1404        assert!(err.fields["password"][0].contains("at least 8"));
1405    }
1406
1407    // =====================================================================
1408    // gaps2 #19 follow-up — FormErrors carries the raw form pairs so the
1409    // handler can re-render the template pre-filled with what the user
1410    // typed instead of falling back to `T::default()` (which loses every
1411    // keystroke). Screenshot 2026-06-10 01-03-09 reported the data-loss
1412    // bug pre-fix.
1413    // =====================================================================
1414
1415    // =====================================================================
1416    // gaps2 #19 follow-up — regex / phone / url validators
1417    // =====================================================================
1418
1419    #[test]
1420    fn phone_field_accepts_e164_format() {
1421        let f = Field::phone("phone");
1422        let mut errs = ValidationErrors::new();
1423        f.validate("+14155551234", &mut errs);
1424        assert!(errs.is_empty(), "valid E.164 should pass: {:?}", errs);
1425    }
1426
1427    #[test]
1428    fn phone_field_rejects_local_only_format() {
1429        // The bug report case: "07065" got accepted because the
1430        // field had only `optional + max_length`. With `Field::phone`
1431        // (== `#[form(phone)]`) the E.164 regex rejects it.
1432        let f = Field::phone("phone");
1433        let mut errs = ValidationErrors::new();
1434        f.validate("07065", &mut errs);
1435        assert!(errs.fields.contains_key("phone"));
1436        assert!(
1437            errs.fields["phone"][0].contains("E.164"),
1438            "error message names the format: {:?}",
1439            errs.fields["phone"][0]
1440        );
1441    }
1442
1443    #[test]
1444    fn phone_field_rejects_letters_and_punctuation() {
1445        let f = Field::phone("phone");
1446        for bad in &["+1-415-555-1234", "+1 (415) 555 1234", "+1abc", "+0123"] {
1447            let mut errs = ValidationErrors::new();
1448            f.validate(bad, &mut errs);
1449            assert!(
1450                errs.fields.contains_key("phone"),
1451                "should reject `{bad}`: {:?}",
1452                errs.fields
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn url_field_accepts_http_and_https_only() {
1459        let f = Field::url("homepage");
1460        for good in &["https://example.com", "http://example.com/path?q=1"] {
1461            let mut errs = ValidationErrors::new();
1462            f.validate(good, &mut errs);
1463            assert!(errs.is_empty(), "should accept `{good}`: {:?}", errs);
1464        }
1465        for bad in &["ftp://example.com", "mailto:a@b.c", "example.com"] {
1466            let mut errs = ValidationErrors::new();
1467            f.validate(bad, &mut errs);
1468            assert!(
1469                errs.fields.contains_key("homepage"),
1470                "should reject `{bad}`: {:?}",
1471                errs.fields
1472            );
1473        }
1474    }
1475
1476    #[test]
1477    fn regex_validator_substitutes_field_in_message() {
1478        // {field} placeholder gets the actual field name — useful
1479        // for reusable messages across multiple forms.
1480        let f = Field::text("invoice_id")
1481            .regex(r"^INV-\d{6}$", "{field} must match the invoice pattern");
1482        let mut errs = ValidationErrors::new();
1483        f.validate("not-an-invoice", &mut errs);
1484        assert_eq!(
1485            errs.fields["invoice_id"][0],
1486            "invoice_id must match the invoice pattern"
1487        );
1488    }
1489
1490    #[test]
1491    fn regex_validator_composes_with_required_and_max_length() {
1492        // Order: Required runs FIRST (empty → "is required"),
1493        // then max_length, then regex. An empty value should
1494        // surface the "required" error, not "doesn't match pattern".
1495        let f = Field::text("code")
1496            .max_length(8)
1497            .regex(r"^[A-Z]{3}$", "{field} must be 3 uppercase letters");
1498
1499        let mut errs = ValidationErrors::new();
1500        f.validate("", &mut errs);
1501        assert!(
1502            errs.fields["code"][0].contains("required"),
1503            "empty surfaces required error first: {:?}",
1504            errs.fields["code"][0]
1505        );
1506
1507        let mut errs = ValidationErrors::new();
1508        f.validate("HELLO", &mut errs);
1509        assert!(
1510            errs.fields["code"][0].contains("3 uppercase"),
1511            "regex error fires when value is present but malformed: {:?}",
1512            errs.fields["code"][0]
1513        );
1514    }
1515
1516    #[test]
1517    fn form_errors_with_raw_round_trips_the_submitted_pairs() {
1518        let mut raw = HashMap::new();
1519        raw.insert("name".to_string(), "Bella Verifier".to_string());
1520        raw.insert("email".to_string(), "bella@invalid".to_string());
1521        raw.insert("phone".to_string(), "none".to_string());
1522
1523        let errs = FormErrors::with_raw(
1524            WriteError::Validator {
1525                field: "email".to_string(),
1526                message: "email's domain must contain at least one `.`".to_string(),
1527            },
1528            raw.clone(),
1529        );
1530
1531        // Raw values survive untouched.
1532        assert_eq!(
1533            errs.raw_values().get("name").map(|s| s.as_str()),
1534            Some("Bella Verifier"),
1535        );
1536        assert_eq!(
1537            errs.raw_values().get("phone").map(|s| s.as_str()),
1538            Some("none"),
1539        );
1540
1541        // JSON shape is a flat `{ field: "literal user input" }` map,
1542        // ready to drop straight into a template ctx as `form` so
1543        // `{{ form.name }}` repopulates.
1544        let json = errs.raw_as_json();
1545        let obj = json.as_object().expect("raw_as_json is an object");
1546        assert_eq!(
1547            obj.get("name").and_then(|v| v.as_str()),
1548            Some("Bella Verifier")
1549        );
1550        assert_eq!(obj.get("phone").and_then(|v| v.as_str()), Some("none"));
1551    }
1552
1553    #[test]
1554    fn form_errors_new_defaults_raw_to_empty_for_ad_hoc_construction() {
1555        // FormErrors::new doesn't see the request body — common shape
1556        // for handlers that construct an ad-hoc error after the
1557        // extractor ran. Raw map MUST default to empty, not panic.
1558        let errs = FormErrors::new(WriteError::Validator {
1559            field: "form".to_string(),
1560            message: "rate limited".to_string(),
1561        });
1562        assert!(errs.raw_values().is_empty());
1563        // JSON shape stays a valid empty object — template ctx
1564        // doesn't crash when nothing was submitted.
1565        let json = errs.raw_as_json();
1566        assert!(json.as_object().expect("object").is_empty());
1567    }
1568}