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