Skip to main content

autumn_web/
form.rs

1//! Changeset-style form helpers with validation and Maud rendering.
2//!
3//! # Overview
4//!
5//! [`Changeset<T>`] captures submitted form values together with per-field
6//! validation errors, enabling the create/edit/validate-failure round-trip
7//! in a single route handler — no manual flash-carrying, no conditional
8//! error-threading.
9//!
10//! [`ChangesetForm<T>`] is the axum extractor that decodes the request body
11//! (URL-encoded **or** multipart), runs validation, captures the CSRF token,
12//! and hands the handler a ready-to-use changeset — CSRF is emitted
13//! automatically when you call [`ChangesetForm::form_tag`].
14//!
15//! # Framework comparison
16//!
17//! | Framework | Changeset type | Rendering helper |
18//! |-----------|---------------|-----------------|
19//! | Phoenix (Elixir) | `Ecto.Changeset` | `<.input field={@form[:name]} />` |
20//! | Rails (Ruby) | `errors[:field]` | `f.text_field :name` |
21//! | Django (Python) | `forms.Form` | `{{ form.name.errors }}` |
22//! | Autumn (Rust) | `Changeset<T>` | `form.text_input("name", "Name")` |
23//!
24//! # Happy-path + validation-failure in ≤ 40 `LoC`
25//!
26//! ```rust,ignore
27//! use autumn_web::prelude::*;
28//! use autumn_web::form::{ChangesetForm, Changeset, submit_button};
29//! use serde::{Deserialize, Serialize};
30//! use validator::Validate;
31//! use axum::{http::StatusCode, response::IntoResponse};
32//!
33//! #[derive(Deserialize, Serialize, Validate)]
34//! struct GreetForm {
35//!     #[validate(length(min = 3, message = "Name must be at least 3 characters"))]
36//!     name: String,
37//!     #[validate(email(message = "Must be a valid email address"))]
38//!     email: String,
39//! }
40//!
41//! fn greet_form_partial(form: &ChangesetForm<GreetForm>, action: &str) -> Markup {
42//!     form.form_tag(action, "post", html! {
43//!         (form.text_input("name", "Full name"))
44//!         (form.text_input("email", "Email"))
45//!         (form.submit_button("Submit"))
46//!     })
47//! }
48//!
49//! #[get("/greet/new")]
50//! async fn new_greet(csrf: CsrfToken) -> Markup {
51//!     let blank = ChangesetForm::blank(GreetForm { name: String::new(), email: String::new() },
52//!                                     csrf.token());
53//!     greet_form_partial(&blank, "/greet")
54//! }
55//!
56//! #[post("/greet")]
57//! async fn create_greet(form: ChangesetForm<GreetForm>) -> impl IntoResponse {
58//!     match form.into_valid() {
59//!         Ok(f) => html! { p { "Hello, " (f.name) "!" } }.into_response(),
60//!         Err(form) => (StatusCode::UNPROCESSABLE_ENTITY,
61//!                       greet_form_partial(&form, "/greet")).into_response(),
62//!     }
63//! }
64//! ```
65//!
66//! # CSRF
67//!
68//! The CSRF token is captured automatically by the [`ChangesetForm`] extractor
69//! from the request extensions set by [`crate::security::CsrfLayer`].
70//! Calling [`ChangesetForm::form_tag`] emits the hidden `_csrf` input with no
71//! additional developer action in POST handlers.
72//!
73//! For GET handlers (new/edit forms), construct the form context via
74//! [`ChangesetForm::blank`], passing `csrf.token()` from a [`crate::security::CsrfToken`]
75//! extractor — the only extra line needed is the parameter itself.
76//!
77//! # Multipart
78//!
79//! When the `multipart` feature is enabled, [`ChangesetForm`] also decodes
80//! `multipart/form-data` bodies.  File fields are skipped; only text fields
81//! are decoded.  File upload storage is out of scope here (see issue #494).
82//!
83//! # Non-htmx fallback
84//!
85//! When JavaScript is disabled htmx falls back to a standard form POST.
86//! The handler pattern above still works: browsers display the 422 page
87//! inline.  For a redirect-after-post pattern, serialise `cs.errors()` into
88//! the flash store and redirect; restore on the next GET.
89
90use std::collections::HashMap;
91
92use axum::extract::{FromRequest, Request};
93use axum::response::IntoResponse;
94use serde::Serialize;
95
96// ── Changeset<T> ───────────────────────────────────────────────────
97
98/// Carries submitted form values and per-field validation errors.
99///
100/// Analogous to `Ecto.Changeset` in Phoenix or `errors[:field]` in Rails.
101///
102/// Obtain a `Changeset` from:
103/// - [`Changeset::new`] for a blank/valid changeset
104/// - [`IntoChangeset::into_changeset`] after manual construction
105/// - The [`ChangesetForm`] axum extractor (preferred)
106#[derive(Debug)]
107pub struct Changeset<T> {
108    data: T,
109    errors: HashMap<String, Vec<String>>,
110}
111
112impl<T> Changeset<T> {
113    /// Create a changeset with no errors (valid state).
114    pub fn new(data: T) -> Self {
115        Self {
116            data,
117            errors: HashMap::new(),
118        }
119    }
120
121    /// Create a changeset pre-loaded with field-level errors.
122    pub const fn from_errors(data: T, errors: HashMap<String, Vec<String>>) -> Self {
123        Self { data, errors }
124    }
125
126    /// Returns `true` when there are no field-level errors.
127    pub fn is_valid(&self) -> bool {
128        self.errors.is_empty()
129    }
130
131    /// Returns the validation messages for `field`, or an empty slice.
132    pub fn errors_for(&self, field: &str) -> &[String] {
133        self.errors.get(field).map_or(&[], Vec::as_slice)
134    }
135
136    /// Unwrap the inner data regardless of validity.
137    pub fn into_inner(self) -> T {
138        self.data
139    }
140
141    /// Consume the changeset, returning `Ok(T)` if valid or `Err(self)` if not.
142    ///
143    /// # Errors
144    ///
145    /// Returns `Err(self)` when there are field-level validation errors.
146    pub fn into_valid(self) -> Result<T, Self> {
147        if self.is_valid() {
148            Ok(self.data)
149        } else {
150            Err(self)
151        }
152    }
153
154    /// Shared reference to the inner data.
155    pub const fn data(&self) -> &T {
156        &self.data
157    }
158
159    /// All field errors as a map (field name → list of messages).
160    pub const fn errors(&self) -> &HashMap<String, Vec<String>> {
161        &self.errors
162    }
163}
164
165impl<T: Serialize> Changeset<T> {
166    /// Serialize the value of `field` from the inner data to a `String`.
167    ///
168    /// Used by rendering helpers to re-populate `<input value="…">` after a
169    /// failed submission.  Returns `None` for missing or non-scalar fields.
170    pub fn field_value(&self, field: &str) -> Option<String> {
171        let json = serde_json::to_value(&self.data).ok()?;
172        match json.get(field)? {
173            serde_json::Value::String(s) => Some(s.clone()),
174            serde_json::Value::Number(n) => Some(n.to_string()),
175            serde_json::Value::Bool(b) => Some(b.to_string()),
176            _ => None,
177        }
178    }
179}
180
181// ── IntoChangeset ──────────────────────────────────────────────────
182
183/// Validate `self` and wrap in a [`Changeset`].
184///
185/// Blanket-implemented for every type that implements [`validator::Validate`].
186pub trait IntoChangeset: Sized {
187    /// Run validation and produce a `Changeset<Self>`.
188    fn into_changeset(self) -> Changeset<Self>;
189}
190
191impl<T: validator::Validate> IntoChangeset for T {
192    fn into_changeset(self) -> Changeset<Self> {
193        match validator::Validate::validate(&self) {
194            Ok(()) => Changeset::new(self),
195            Err(errors) => Changeset::from_errors(self, validation_errors_to_map(&errors)),
196        }
197    }
198}
199
200// ── ChangesetForm<T> ───────────────────────────────────────────────
201
202/// Axum extractor that decodes a form body, runs validation, and captures the
203/// CSRF token — all in one step.
204///
205/// Supports both `application/x-www-form-urlencoded` (always) and
206/// `multipart/form-data` (when the `multipart` feature is enabled).
207///
208/// Unlike [`crate::validation::Valid`], this extractor **never** rejects with
209/// 422 — errors live in the [`Changeset`] and the handler decides how to
210/// respond.  Fails with 400 only when the body cannot be decoded into `T` at
211/// all.
212///
213/// # CSRF — no extra developer action in POST handlers
214///
215/// The extractor reads the `CsrfToken` from request extensions (placed there
216/// by [`crate::security::CsrfLayer`]).  Calling
217/// [`ChangesetForm::form_tag`] then emits the hidden `_csrf` input
218/// automatically — no separate `CsrfToken` parameter needed.
219///
220/// For GET handlers (new/edit), use [`ChangesetForm::blank`] and pass
221/// `csrf.token()` from a `CsrfToken` extractor.
222///
223/// # Example
224///
225/// ```rust,ignore
226/// #[post("/users")]
227/// async fn create(form: ChangesetForm<NewUser>) -> impl IntoResponse {
228///     match form.into_valid() {
229///         Ok(user) => { /* persist & redirect */ }
230///         Err(form) => (StatusCode::UNPROCESSABLE_ENTITY,
231///                       form.form_tag("/users", "post", html! {
232///                           (form.text_input("name", "Name"))
233///                           (form.submit_button("Save"))
234///                       })).into_response()
235///     }
236/// }
237/// ```
238pub struct ChangesetForm<T> {
239    /// The validated (or invalid) changeset.
240    pub changeset: Changeset<T>,
241    pub(crate) csrf_token: Option<String>,
242    pub(crate) csrf_field: String,
243}
244
245impl<T> ChangesetForm<T> {
246    /// Build a blank form context for GET handlers (new / edit).
247    ///
248    /// Wraps `data` in a valid [`Changeset`] and stores `csrf_token` so that
249    /// [`ChangesetForm::form_tag`] can emit the hidden input automatically.
250    ///
251    /// ```rust,ignore
252    /// #[get("/users/new")]
253    /// async fn new_user(csrf: CsrfToken) -> Markup {
254    ///     let ctx = ChangesetForm::blank(UserForm::default(), csrf.token());
255    ///     ctx.form_tag("/users", "post", html! { (ctx.text_input("name", "Name")) })
256    /// }
257    /// ```
258    pub fn blank(data: T, csrf_token: &str) -> Self {
259        Self {
260            changeset: Changeset::new(data),
261            csrf_token: Some(csrf_token.to_owned()),
262            csrf_field: "_csrf".to_owned(),
263        }
264    }
265
266    /// Construct a display-only `ChangesetForm` with no CSRF token.
267    ///
268    /// Use this on GET handlers where CSRF middleware is not active, or when
269    /// the form will be re-rendered purely for display (e.g. an initial blank
270    /// form on a page that does not enforce CSRF).  [`form_tag`](Self::form_tag)
271    /// will omit the hidden CSRF input when no token is stored.
272    #[must_use]
273    pub fn without_csrf(data: T) -> Self {
274        Self {
275            changeset: Changeset::new(data),
276            csrf_token: None,
277            csrf_field: "_csrf".to_owned(),
278        }
279    }
280
281    /// Wrap a pre-built [`Changeset`] (which may already carry validation errors)
282    /// in a `ChangesetForm` without a CSRF token.
283    ///
284    /// Useful in tests and cases where a `Changeset` was produced externally
285    /// (e.g. via [`IntoChangeset`]) before constructing a form for rendering.
286    #[must_use]
287    pub fn from_changeset(changeset: Changeset<T>) -> Self {
288        Self {
289            changeset,
290            csrf_token: None,
291            csrf_field: "_csrf".to_owned(),
292        }
293    }
294
295    /// Override the CSRF form-field name used by [`ChangesetForm::form_tag`].
296    ///
297    /// Call this when `security.csrf.form_field` is set to something other than
298    /// `"_csrf"` (e.g. `"authenticity_token"`).  The `CsrfFormField` extension
299    /// populated by [`from_request`](Self::from_request) sets this automatically
300    /// for POST handlers; use this builder on GET handlers that construct a blank
301    /// form with [`blank`](Self::blank).
302    #[must_use]
303    pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
304        self.csrf_field = field.into();
305        self
306    }
307
308    /// The CSRF token captured from the request, if the CSRF middleware is active.
309    pub fn csrf_token(&self) -> Option<&str> {
310        self.csrf_token.as_deref()
311    }
312
313    /// Consume and return only the inner [`Changeset`].
314    pub fn into_changeset(self) -> Changeset<T> {
315        self.changeset
316    }
317
318    /// Return `Ok(T)` if the changeset is valid, `Err(self)` if not.
319    ///
320    /// The `Err` branch returns the whole `ChangesetForm` (with its CSRF
321    /// token) so the handler can immediately call `form.form_tag()` to
322    /// re-render with inline errors.
323    ///
324    /// # Errors
325    ///
326    /// Returns `Err(self)` when the inner changeset has field-level validation errors.
327    pub fn into_valid(self) -> Result<T, Self> {
328        if self.changeset.is_valid() {
329            Ok(self.changeset.into_inner())
330        } else {
331            Err(self)
332        }
333    }
334}
335
336/// Dereferences to [`Changeset<T>`] so all changeset methods are available
337/// directly on `ChangesetForm<T>` — `form.is_valid()`, `form.errors_for(…)`,
338/// etc.
339impl<T> std::ops::Deref for ChangesetForm<T> {
340    type Target = Changeset<T>;
341    fn deref(&self) -> &Self::Target {
342        &self.changeset
343    }
344}
345
346/// Maud rendering methods — emit form HTML with automatic CSRF injection.
347#[cfg(feature = "maud")]
348impl<T: Serialize> ChangesetForm<T> {
349    /// Render a `<form>` element with the stored CSRF token injected as a
350    /// hidden input — the field name honours `security.csrf.form_field` from
351    /// config, so no developer action is required even for non-default names.
352    #[must_use]
353    #[allow(clippy::needless_pass_by_value)]
354    pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
355        form_tag_inner(
356            action,
357            method,
358            &self.csrf_field,
359            self.csrf_token.as_deref(),
360            content,
361        )
362    }
363
364    /// Render a labeled `<input type="text">` for `field` using the stored
365    /// changeset (value + errors).
366    pub fn text_input(&self, field: &str, label: &str) -> maud::Markup {
367        text_input(&self.changeset, field, label)
368    }
369
370    /// Render a `<button type="submit">` with `label`.
371    pub fn submit_button(&self, label: &str) -> maud::Markup {
372        submit_button(label)
373    }
374}
375
376impl<S, T> FromRequest<S> for ChangesetForm<T>
377where
378    S: Send + Sync,
379    T: serde::de::DeserializeOwned + validator::Validate,
380{
381    type Rejection = axum::response::Response;
382
383    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
384        // Capture CSRF token and field name before the body is consumed.
385        let csrf_token = req
386            .extensions()
387            .get::<crate::security::CsrfToken>()
388            .map(|t| t.token().to_string());
389        let csrf_field = req
390            .extensions()
391            .get::<crate::security::csrf::CsrfFormField>()
392            .map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
393
394        let data: T = decode_form_body(req, state).await?;
395
396        Ok(Self {
397            changeset: data.into_changeset(),
398            csrf_token,
399            csrf_field,
400        })
401    }
402}
403
404/// Decode a form body — URL-encoded always, multipart when that feature is on.
405async fn decode_form_body<T, S>(req: Request, state: &S) -> Result<T, axum::response::Response>
406where
407    T: serde::de::DeserializeOwned + validator::Validate,
408    S: Send + Sync,
409{
410    #[cfg(feature = "multipart")]
411    {
412        let content_type = req
413            .headers()
414            .get(http::header::CONTENT_TYPE)
415            .and_then(|v| v.to_str().ok())
416            .unwrap_or_default()
417            .to_string();
418        if content_type.starts_with("multipart/form-data") {
419            return decode_multipart(req, state).await;
420        }
421    }
422
423    let axum::extract::Form(data) = axum::extract::Form::<T>::from_request(req, state)
424        .await
425        .map_err(IntoResponse::into_response)?;
426    Ok(data)
427}
428
429/// Decode `multipart/form-data` text fields and deserialize into `T`.
430///
431/// File-upload fields are skipped (file storage is out of scope here).
432/// The collected text pairs are re-encoded as URL-encoded so that
433/// `serde_urlencoded` handles the same type coercions axum's `Form` does.
434#[cfg(feature = "multipart")]
435async fn decode_multipart<T, S>(req: Request, state: &S) -> Result<T, axum::response::Response>
436where
437    T: serde::de::DeserializeOwned,
438    S: Send + Sync,
439{
440    let mut multipart = axum::extract::Multipart::from_request(req, state)
441        .await
442        .map_err(IntoResponse::into_response)?;
443
444    let mut pairs: Vec<(String, String)> = Vec::new();
445
446    loop {
447        let field = multipart
448            .next_field()
449            .await
450            .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())?;
451
452        let Some(field) = field else { break };
453
454        let name = match field.name() {
455            Some(n) => n.to_string(),
456            None => continue,
457        };
458
459        // Skip file-upload fields; text-only decoding is in scope.
460        if field.file_name().is_some() {
461            continue;
462        }
463
464        let value = field
465            .text()
466            .await
467            .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())?;
468
469        pairs.push((name, value));
470    }
471
472    // Re-encode as URL-encoded so serde_urlencoded handles type coercions
473    // ("30" → u32, "true" → bool, etc.) consistently with the Form extractor.
474    let encoded = url::form_urlencoded::Serializer::new(String::new())
475        .extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
476        .finish();
477
478    serde_urlencoded::from_str::<T>(&encoded)
479        .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())
480}
481
482// ── Internal helpers ───────────────────────────────────────────────
483
484fn validation_errors_to_map(errors: &validator::ValidationErrors) -> HashMap<String, Vec<String>> {
485    let mut map = HashMap::new();
486    collect_errors(errors, "", &mut map);
487    map
488}
489
490fn collect_errors(
491    errors: &validator::ValidationErrors,
492    prefix: &str,
493    map: &mut HashMap<String, Vec<String>>,
494) {
495    for (field, kind) in errors.errors() {
496        let key = if prefix.is_empty() {
497            (*field).to_string()
498        } else {
499            format!("{prefix}.{field}")
500        };
501        match kind {
502            validator::ValidationErrorsKind::Field(errs) => {
503                let messages: Vec<String> = errs
504                    .iter()
505                    .map(|e| {
506                        e.message.as_ref().map_or_else(
507                            || format!("validation failed: {}", e.code),
508                            ToString::to_string,
509                        )
510                    })
511                    .collect();
512                map.entry(key).or_default().extend(messages);
513            }
514            validator::ValidationErrorsKind::Struct(nested) => {
515                collect_errors(nested, &key, map);
516            }
517            validator::ValidationErrorsKind::List(list) => {
518                for (idx, nested) in list {
519                    let indexed_key = format!("{key}[{idx}]");
520                    collect_errors(nested, &indexed_key, map);
521                }
522            }
523        }
524    }
525}
526
527// ── Standalone Maud helpers ─────────────────────────────────────────
528//
529// These are the building blocks used by `ChangesetForm` methods.
530// They are also public so GET handlers can use them with a bare `Changeset`.
531
532/// Render a `<form>` element wrapping `content`.
533///
534/// When `csrf_token` is `Some(token)`, a hidden `<input name="_csrf">` is
535/// emitted automatically — compatible with [`crate::security::CsrfLayer`]
536/// using the default field name `_csrf`.
537///
538/// In **POST** handlers, prefer [`ChangesetForm::form_tag`] which injects
539/// the token **and** honours any custom `security.csrf.form_field` from config.
540#[cfg(feature = "maud")]
541#[must_use]
542#[allow(clippy::needless_pass_by_value)]
543pub fn form_tag(
544    action: &str,
545    method: &str,
546    csrf_token: Option<&str>,
547    content: maud::Markup,
548) -> maud::Markup {
549    form_tag_inner(action, method, "_csrf", csrf_token, content)
550}
551
552/// Internal: render a `<form>` element using an explicit CSRF field name.
553#[cfg(feature = "maud")]
554#[allow(clippy::needless_pass_by_value)]
555fn form_tag_inner(
556    action: &str,
557    method: &str,
558    csrf_field: &str,
559    csrf_token: Option<&str>,
560    content: maud::Markup,
561) -> maud::Markup {
562    maud::html! {
563        form action=(action) method=(method) {
564            @if let Some(token) = csrf_token {
565                input type="hidden" name=(csrf_field) value=(token);
566            }
567            (content)
568        }
569    }
570}
571
572/// Render a labeled `<input type="text">` tied to a changeset field.
573///
574/// - Sets `name` and `id` to `field`
575/// - Populates `value` from the changeset's serialized data
576/// - Adds `aria-invalid="true"` + `aria-describedby` when errors exist
577/// - Emits a `<div role="alert">` with per-message `<p>` error elements
578#[cfg(feature = "maud")]
579#[must_use]
580pub fn text_input<T: Serialize>(
581    changeset: &Changeset<T>,
582    field: &str,
583    label: &str,
584) -> maud::Markup {
585    let errors = changeset.errors_for(field);
586    let has_errors = !errors.is_empty();
587    let value = changeset.field_value(field).unwrap_or_default();
588    let error_id = format!("{field}-error");
589
590    maud::html! {
591        div {
592            label for=(field) { (label) }
593            input
594                type="text"
595                id=(field)
596                name=(field)
597                value=(value)
598                aria-invalid=(if has_errors { "true" } else { "false" })
599                aria-describedby=(if has_errors { error_id.as_str() } else { "" });
600            @if has_errors {
601                div id=(error_id) role="alert" {
602                    @for error in errors {
603                        p { (error) }
604                    }
605                }
606            }
607        }
608    }
609}
610
611/// Render a `<button type="submit">` with `label`.
612#[cfg(feature = "maud")]
613#[must_use]
614pub fn submit_button(label: &str) -> maud::Markup {
615    maud::html! {
616        button type="submit" { (label) }
617    }
618}
619
620/// Render a labeled `<input type="password">` tied to a changeset field.
621///
622/// Like [`text_input`] but uses `type="password"` and never populates the
623/// `value` attribute — browsers must not auto-fill passwords into the markup
624/// and screen readers must not announce the value.
625///
626/// ARIA annotations (`aria-invalid`, `aria-describedby`, error block) behave
627/// identically to [`text_input`].
628#[cfg(feature = "maud")]
629#[must_use]
630pub fn password_input<T: Serialize>(
631    changeset: &Changeset<T>,
632    field: &str,
633    label: &str,
634) -> maud::Markup {
635    let errors = changeset.errors_for(field);
636    let has_errors = !errors.is_empty();
637    let error_id = format!("{field}-error");
638
639    maud::html! {
640        div {
641            label for=(field) { (label) }
642            input
643                type="password"
644                id=(field)
645                name=(field)
646                aria-invalid=(if has_errors { "true" } else { "false" })
647                aria-describedby=(if has_errors { error_id.as_str() } else { "" });
648            @if has_errors {
649                div id=(error_id) role="alert" {
650                    @for error in errors {
651                        p { (error) }
652                    }
653                }
654            }
655        }
656    }
657}
658
659/// Render a labeled `<textarea>` tied to a changeset field.
660///
661/// The current field value is emitted as the textarea body (not a `value`
662/// attribute). ARIA annotations behave identically to [`text_input`].
663#[cfg(feature = "maud")]
664#[must_use]
665pub fn textarea_input<T: Serialize>(
666    changeset: &Changeset<T>,
667    field: &str,
668    label: &str,
669) -> maud::Markup {
670    let errors = changeset.errors_for(field);
671    let has_errors = !errors.is_empty();
672    let value = changeset.field_value(field).unwrap_or_default();
673    let error_id = format!("{field}-error");
674
675    maud::html! {
676        div {
677            label for=(field) { (label) }
678            textarea
679                id=(field)
680                name=(field)
681                aria-invalid=(if has_errors { "true" } else { "false" })
682                aria-describedby=(if has_errors { error_id.as_str() } else { "" })
683                { (value) }
684            @if has_errors {
685                div id=(error_id) role="alert" {
686                    @for error in errors {
687                        p { (error) }
688                    }
689                }
690            }
691        }
692    }
693}
694
695/// Render a labeled `<input type="text">` for a required field.
696///
697/// Identical to [`text_input`] but adds `aria-required="true"` and the HTML
698/// `required` attribute, giving both AT users and browser-native validation
699/// the required-field signal without relying solely on color.
700#[cfg(feature = "maud")]
701#[must_use]
702pub fn required_text_input<T: Serialize>(
703    changeset: &Changeset<T>,
704    field: &str,
705    label: &str,
706) -> maud::Markup {
707    let errors = changeset.errors_for(field);
708    let has_errors = !errors.is_empty();
709    let value = changeset.field_value(field).unwrap_or_default();
710    let error_id = format!("{field}-error");
711
712    maud::html! {
713        div {
714            label for=(field) { (label) }
715            input
716                type="text"
717                id=(field)
718                name=(field)
719                value=(value)
720                required
721                aria-required="true"
722                aria-invalid=(if has_errors { "true" } else { "false" })
723                aria-describedby=(if has_errors { error_id.as_str() } else { "" });
724            @if has_errors {
725                div id=(error_id) role="alert" {
726                    @for error in errors {
727                        p { (error) }
728                    }
729                }
730            }
731        }
732    }
733}
734
735/// Render an ARIA live region for htmx swap announcements.
736///
737/// Emits `<div id="…" role="status" aria-live="polite" aria-atomic="true">`.
738/// Place this element in your page layout and update its content via
739/// `hx-swap-oob` to announce htmx-driven changes to screen readers without
740/// moving keyboard focus.
741///
742/// # Example
743///
744/// ```rust,ignore
745/// // In your page layout:
746/// (aria_live_region("htmx-status", ""))
747///
748/// // In an htmx response fragment (announces to screen readers):
749/// div id="htmx-status" role="status" aria-live="polite" aria-atomic="true"
750///     hx-swap-oob="true" {
751///     "Post submitted successfully"
752/// }
753/// ```
754#[cfg(feature = "maud")]
755#[must_use]
756pub fn aria_live_region(id: &str, message: &str) -> maud::Markup {
757    maud::html! {
758        div id=(id) role="status" aria-live="polite" aria-atomic="true" {
759            (message)
760        }
761    }
762}
763
764/// Render a visually-hidden skip-to-content link that becomes visible on focus.
765///
766/// Place this as the **first element inside `<body>`** so keyboard users can
767/// bypass repeated navigation and jump directly to main content.
768///
769/// The link carries the `skip-link` CSS class; pair it with the bundled
770/// Tailwind config's `skip-link` utility or add your own:
771///
772/// ```css
773/// .skip-link { position: absolute; top: -9999px; }
774/// .skip-link:focus { position: static; }
775/// ```
776#[cfg(feature = "maud")]
777#[must_use]
778pub fn skip_link(target: &str, label: &str) -> maud::Markup {
779    maud::html! {
780        a href=(target) class="skip-link" { (label) }
781    }
782}
783
784// ── Tests ──────────────────────────────────────────────────────────
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    // ── Changeset::new ─────────────────────────────────────────────
791
792    #[test]
793    fn new_changeset_is_valid() {
794        let cs = Changeset::new(42_i32);
795        assert!(cs.is_valid());
796    }
797
798    #[test]
799    fn new_changeset_has_no_errors() {
800        let cs = Changeset::new("hello");
801        assert!(cs.errors().is_empty());
802    }
803
804    #[test]
805    fn new_changeset_into_inner() {
806        let cs = Changeset::new(99_u8);
807        assert_eq!(cs.into_inner(), 99);
808    }
809
810    #[test]
811    fn new_changeset_data_ref() {
812        let cs = Changeset::new(vec![1, 2, 3]);
813        assert_eq!(cs.data(), &vec![1, 2, 3]);
814    }
815
816    // ── Changeset::from_errors ─────────────────────────────────────
817
818    #[test]
819    fn from_errors_changeset_is_invalid() {
820        let mut errors = HashMap::new();
821        errors.insert("name".to_string(), vec!["too short".to_string()]);
822        let cs = Changeset::from_errors("data", errors);
823        assert!(!cs.is_valid());
824    }
825
826    #[test]
827    fn from_errors_returns_correct_field_errors() {
828        let mut errors = HashMap::new();
829        errors.insert("email".to_string(), vec!["invalid email".to_string()]);
830        let cs = Changeset::from_errors("data", errors);
831        assert_eq!(cs.errors_for("email"), &["invalid email"]);
832    }
833
834    #[test]
835    fn errors_for_unknown_field_returns_empty_slice() {
836        let cs = Changeset::new("data");
837        assert!(cs.errors_for("nonexistent").is_empty());
838    }
839
840    #[test]
841    fn from_errors_multiple_messages_per_field() {
842        let mut errors = HashMap::new();
843        errors.insert(
844            "password".to_string(),
845            vec!["too short".to_string(), "must contain a digit".to_string()],
846        );
847        let cs = Changeset::from_errors("data", errors);
848        let msgs = cs.errors_for("password");
849        assert_eq!(msgs.len(), 2);
850        assert!(msgs.contains(&"too short".to_string()));
851        assert!(msgs.contains(&"must contain a digit".to_string()));
852    }
853
854    // ── Changeset::into_valid ──────────────────────────────────────
855
856    #[test]
857    fn into_valid_returns_ok_when_valid() {
858        let cs = Changeset::new(42_i32);
859        assert_eq!(cs.into_valid().unwrap(), 42);
860    }
861
862    #[test]
863    fn into_valid_returns_err_when_invalid() {
864        let mut errors = HashMap::new();
865        errors.insert("x".to_string(), vec!["err".to_string()]);
866        let cs = Changeset::from_errors(42_i32, errors);
867        assert!(cs.into_valid().is_err());
868    }
869
870    #[test]
871    fn into_valid_err_preserves_changeset() {
872        let mut errors = HashMap::new();
873        errors.insert("name".to_string(), vec!["required".to_string()]);
874        let cs = Changeset::from_errors(7_i32, errors);
875        let err_cs = cs.into_valid().unwrap_err();
876        assert_eq!(err_cs.into_inner(), 7);
877    }
878
879    // ── Changeset::field_value ─────────────────────────────────────
880
881    #[test]
882    fn field_value_returns_string_field() {
883        #[derive(serde::Serialize)]
884        struct Form {
885            name: String,
886        }
887        let cs = Changeset::new(Form {
888            name: "Alice".into(),
889        });
890        assert_eq!(cs.field_value("name"), Some("Alice".to_string()));
891    }
892
893    #[test]
894    fn field_value_returns_number_as_string() {
895        #[derive(serde::Serialize)]
896        struct Form {
897            age: u32,
898        }
899        let cs = Changeset::new(Form { age: 30 });
900        assert_eq!(cs.field_value("age"), Some("30".to_string()));
901    }
902
903    #[test]
904    fn field_value_returns_bool_as_string() {
905        #[derive(serde::Serialize)]
906        struct Form {
907            active: bool,
908        }
909        let cs = Changeset::new(Form { active: true });
910        assert_eq!(cs.field_value("active"), Some("true".to_string()));
911    }
912
913    #[test]
914    fn field_value_returns_none_for_missing_field() {
915        #[derive(serde::Serialize)]
916        struct Form {
917            name: String,
918        }
919        let cs = Changeset::new(Form {
920            name: "Alice".into(),
921        });
922        assert_eq!(cs.field_value("email"), None);
923    }
924
925    #[test]
926    fn field_value_after_errors_uses_submitted_data() {
927        #[derive(serde::Serialize)]
928        struct Form {
929            name: String,
930        }
931        let mut errors = HashMap::new();
932        errors.insert("name".to_string(), vec!["too short".to_string()]);
933        let cs = Changeset::from_errors(Form { name: "ab".into() }, errors);
934        assert_eq!(cs.field_value("name"), Some("ab".to_string()));
935    }
936
937    // ── IntoChangeset ──────────────────────────────────────────────
938
939    #[test]
940    fn into_changeset_valid_input_produces_no_errors() {
941        #[derive(validator::Validate)]
942        struct F {
943            #[validate(length(min = 3))]
944            name: String,
945        }
946        let cs = F {
947            name: "Alice".into(),
948        }
949        .into_changeset();
950        assert!(cs.is_valid());
951        assert!(cs.errors_for("name").is_empty());
952    }
953
954    #[test]
955    fn into_changeset_invalid_input_populates_errors() {
956        #[derive(validator::Validate)]
957        struct F {
958            #[validate(length(min = 5))]
959            name: String,
960        }
961        let cs = F { name: "ab".into() }.into_changeset();
962        assert!(!cs.is_valid());
963        assert!(!cs.errors_for("name").is_empty());
964    }
965
966    #[test]
967    fn into_changeset_preserves_data_on_failure() {
968        #[derive(validator::Validate)]
969        struct F {
970            #[validate(length(min = 5))]
971            name: String,
972        }
973        let cs = F { name: "ab".into() }.into_changeset();
974        assert_eq!(cs.data().name, "ab");
975    }
976
977    #[test]
978    fn into_changeset_multiple_fields_errors() {
979        #[derive(validator::Validate)]
980        struct F {
981            #[validate(length(min = 3))]
982            name: String,
983            #[validate(email)]
984            email: String,
985        }
986        let cs = F {
987            name: "a".into(),
988            email: "not-email".into(),
989        }
990        .into_changeset();
991        assert!(!cs.is_valid());
992        assert!(!cs.errors_for("name").is_empty());
993        assert!(!cs.errors_for("email").is_empty());
994    }
995
996    mod nested_validation {
997        use super::*;
998        use validator::Validate as _;
999
1000        #[derive(validator::Validate)]
1001        struct NestedAddress {
1002            #[validate(length(min = 3, message = "street too short"))]
1003            street: String,
1004        }
1005
1006        #[derive(validator::Validate)]
1007        struct PersonWithAddress {
1008            #[validate(nested)]
1009            address: NestedAddress,
1010        }
1011
1012        #[test]
1013        fn nested_struct_errors_are_flattened_with_dot_notation() {
1014            let cs = PersonWithAddress {
1015                address: NestedAddress { street: "x".into() },
1016            }
1017            .into_changeset();
1018            assert!(!cs.is_valid());
1019            assert!(!cs.errors_for("address.street").is_empty());
1020        }
1021    }
1022
1023    // ── ChangesetForm helpers ──────────────────────────────────────
1024
1025    #[test]
1026    fn changeset_form_blank_is_valid() {
1027        #[derive(validator::Validate, serde::Serialize)]
1028        struct F {
1029            #[validate(length(min = 1))]
1030            name: String,
1031        }
1032        let form = ChangesetForm::blank(F { name: "ok".into() }, "tok");
1033        assert!(form.is_valid()); // via Deref
1034        assert_eq!(form.csrf_token(), Some("tok"));
1035    }
1036
1037    #[test]
1038    fn changeset_form_deref_exposes_changeset_methods() {
1039        #[derive(validator::Validate)]
1040        struct F {
1041            #[validate(length(min = 3))]
1042            name: String,
1043        }
1044        let changeset = F { name: "ab".into() }.into_changeset();
1045        let form = ChangesetForm {
1046            changeset,
1047            csrf_token: None,
1048            csrf_field: "_csrf".into(),
1049        };
1050        // Deref gives access to Changeset methods
1051        assert!(!form.is_valid());
1052        assert!(!form.errors_for("name").is_empty());
1053    }
1054
1055    #[test]
1056    fn changeset_form_into_valid_ok() {
1057        #[derive(validator::Validate)]
1058        struct F {
1059            #[validate(length(min = 1))]
1060            name: String,
1061        }
1062        let form = ChangesetForm {
1063            changeset: F { name: "ok".into() }.into_changeset(),
1064            csrf_token: None,
1065            csrf_field: "_csrf".into(),
1066        };
1067        assert!(form.into_valid().is_ok());
1068    }
1069
1070    #[test]
1071    fn changeset_form_into_valid_err_preserves_csrf() {
1072        #[derive(Debug, validator::Validate)]
1073        struct F {
1074            #[validate(length(min = 5))]
1075            name: String,
1076        }
1077        let form = ChangesetForm {
1078            changeset: F { name: "ab".into() }.into_changeset(),
1079            csrf_token: Some("tok123".into()),
1080            csrf_field: "_csrf".into(),
1081        };
1082        let err_form = form.into_valid().unwrap_err();
1083        assert_eq!(err_form.csrf_token(), Some("tok123"));
1084    }
1085
1086    // ── Maud helpers ───────────────────────────────────────────────
1087
1088    #[cfg(feature = "maud")]
1089    #[test]
1090    fn form_tag_renders_action_and_method() {
1091        let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
1092        assert!(html.contains(r#"action="/users""#), "{html}");
1093        assert!(html.contains(r#"method="post""#), "{html}");
1094    }
1095
1096    #[cfg(feature = "maud")]
1097    #[test]
1098    fn form_tag_emits_csrf_hidden_input_when_token_provided() {
1099        let html = form_tag("/users", "post", Some("tok123"), maud::html! { "" }).into_string();
1100        assert!(html.contains(r#"name="_csrf""#), "{html}");
1101        assert!(html.contains(r#"value="tok123""#), "{html}");
1102        assert!(html.contains(r#"type="hidden""#), "{html}");
1103    }
1104
1105    #[cfg(feature = "maud")]
1106    #[test]
1107    fn form_tag_omits_csrf_input_when_none() {
1108        let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
1109        assert!(!html.contains("_csrf"), "{html}");
1110    }
1111
1112    #[cfg(feature = "maud")]
1113    #[test]
1114    fn form_tag_includes_content() {
1115        let html = form_tag("/x", "post", None, maud::html! { span { "inner" } }).into_string();
1116        assert!(html.contains("inner"), "{html}");
1117    }
1118
1119    #[cfg(feature = "maud")]
1120    #[test]
1121    fn changeset_form_form_tag_injects_stored_csrf() {
1122        #[derive(validator::Validate, serde::Serialize)]
1123        struct F {
1124            name: String,
1125        }
1126        let form = ChangesetForm::blank(
1127            F {
1128                name: String::new(),
1129            },
1130            "secret-token",
1131        );
1132        let html = form
1133            .form_tag("/x", "post", maud::html! { "" })
1134            .into_string();
1135        assert!(html.contains(r#"value="secret-token""#), "{html}");
1136        assert!(html.contains(r#"name="_csrf""#), "{html}");
1137    }
1138
1139    #[cfg(feature = "maud")]
1140    #[test]
1141    fn changeset_form_form_tag_honours_custom_csrf_field_name() {
1142        #[derive(validator::Validate, serde::Serialize)]
1143        struct F {
1144            name: String,
1145        }
1146        let form = ChangesetForm {
1147            changeset: Changeset::new(F {
1148                name: String::new(),
1149            }),
1150            csrf_token: Some("tok".into()),
1151            csrf_field: "authenticity_token".into(),
1152        };
1153        let html = form
1154            .form_tag("/x", "post", maud::html! { "" })
1155            .into_string();
1156        assert!(html.contains(r#"name="authenticity_token""#), "{html}");
1157        assert!(!html.contains(r#"name="_csrf""#), "{html}");
1158    }
1159
1160    #[cfg(feature = "maud")]
1161    #[test]
1162    fn text_input_renders_label_name_and_value() {
1163        #[derive(serde::Serialize)]
1164        struct F {
1165            name: String,
1166        }
1167        let cs = Changeset::new(F {
1168            name: "Alice".into(),
1169        });
1170        let html = text_input(&cs, "name", "Full Name").into_string();
1171        assert!(html.contains(r#"name="name""#), "{html}");
1172        assert!(html.contains(r#"value="Alice""#), "{html}");
1173        assert!(html.contains("Full Name"), "{html}");
1174    }
1175
1176    #[cfg(feature = "maud")]
1177    #[test]
1178    fn text_input_aria_invalid_false_when_no_errors() {
1179        #[derive(serde::Serialize)]
1180        struct F {
1181            name: String,
1182        }
1183        let cs = Changeset::new(F {
1184            name: "Alice".into(),
1185        });
1186        let html = text_input(&cs, "name", "Name").into_string();
1187        assert!(html.contains(r#"aria-invalid="false""#), "{html}");
1188        assert!(!html.contains(r#"role="alert""#), "{html}");
1189    }
1190
1191    #[cfg(feature = "maud")]
1192    #[test]
1193    fn text_input_aria_invalid_true_and_error_block_on_failure() {
1194        #[derive(serde::Serialize)]
1195        struct F {
1196            name: String,
1197        }
1198        let mut errors = HashMap::new();
1199        errors.insert("name".to_string(), vec!["too short".to_string()]);
1200        let cs = Changeset::from_errors(F { name: "ab".into() }, errors);
1201        let html = text_input(&cs, "name", "Name").into_string();
1202        assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1203        assert!(html.contains(r#"role="alert""#), "{html}");
1204        assert!(html.contains("too short"), "{html}");
1205    }
1206
1207    #[cfg(feature = "maud")]
1208    #[test]
1209    fn text_input_error_block_has_describedby_link() {
1210        #[derive(serde::Serialize)]
1211        struct F {
1212            email: String,
1213        }
1214        let mut errors = HashMap::new();
1215        errors.insert("email".to_string(), vec!["invalid".to_string()]);
1216        let cs = Changeset::from_errors(F { email: "x".into() }, errors);
1217        let html = text_input(&cs, "email", "Email").into_string();
1218        assert!(html.contains("email-error"), "{html}");
1219        assert!(html.contains(r#"aria-describedby="email-error""#), "{html}");
1220    }
1221
1222    #[cfg(feature = "maud")]
1223    #[test]
1224    fn text_input_multiple_errors_all_rendered() {
1225        #[derive(serde::Serialize)]
1226        struct F {
1227            password: String,
1228        }
1229        let mut errors = HashMap::new();
1230        errors.insert(
1231            "password".to_string(),
1232            vec!["too short".to_string(), "needs digit".to_string()],
1233        );
1234        let cs = Changeset::from_errors(
1235            F {
1236                password: "x".into(),
1237            },
1238            errors,
1239        );
1240        let html = text_input(&cs, "password", "Password").into_string();
1241        assert!(html.contains("too short"), "{html}");
1242        assert!(html.contains("needs digit"), "{html}");
1243    }
1244
1245    #[cfg(feature = "maud")]
1246    #[test]
1247    fn submit_button_renders_button_with_label() {
1248        let html = submit_button("Save").into_string();
1249        assert!(html.contains(r#"type="submit""#), "{html}");
1250        assert!(html.contains("Save"), "{html}");
1251    }
1252
1253    // ── RED: accessible form helpers ───────────────────────────────
1254
1255    #[cfg(feature = "maud")]
1256    #[test]
1257    fn password_input_renders_type_password() {
1258        #[derive(serde::Serialize)]
1259        struct F {
1260            password: String,
1261        }
1262        let cs = Changeset::new(F {
1263            password: String::new(),
1264        });
1265        let html = password_input(&cs, "password", "Password").into_string();
1266        assert!(html.contains(r#"type="password""#), "{html}");
1267        assert!(html.contains(r#"name="password""#), "{html}");
1268        assert!(html.contains("Password"), "{html}");
1269        // Must NOT expose the value in the rendered HTML
1270        assert!(!html.contains(r#"value=""#), "{html}");
1271    }
1272
1273    #[cfg(feature = "maud")]
1274    #[test]
1275    fn password_input_emits_aria_invalid_on_error() {
1276        #[derive(serde::Serialize)]
1277        struct F {
1278            password: String,
1279        }
1280        let mut errors = HashMap::new();
1281        errors.insert("password".to_string(), vec!["too short".to_string()]);
1282        let cs = Changeset::from_errors(
1283            F {
1284                password: "x".into(),
1285            },
1286            errors,
1287        );
1288        let html = password_input(&cs, "password", "Password").into_string();
1289        assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1290        assert!(html.contains(r#"role="alert""#), "{html}");
1291        assert!(html.contains("too short"), "{html}");
1292    }
1293
1294    #[cfg(feature = "maud")]
1295    #[test]
1296    fn textarea_input_renders_textarea_element() {
1297        #[derive(serde::Serialize)]
1298        struct F {
1299            bio: String,
1300        }
1301        let cs = Changeset::new(F {
1302            bio: "Hello world".into(),
1303        });
1304        let html = textarea_input(&cs, "bio", "Bio").into_string();
1305        assert!(html.contains("<textarea"), "{html}");
1306        assert!(html.contains(r#"name="bio""#), "{html}");
1307        assert!(html.contains(r#"id="bio""#), "{html}");
1308        assert!(html.contains("Bio"), "{html}");
1309        assert!(html.contains("Hello world"), "{html}");
1310    }
1311
1312    #[cfg(feature = "maud")]
1313    #[test]
1314    fn textarea_input_aria_invalid_on_error() {
1315        #[derive(serde::Serialize)]
1316        struct F {
1317            bio: String,
1318        }
1319        let mut errors = HashMap::new();
1320        errors.insert("bio".to_string(), vec!["required".to_string()]);
1321        let cs = Changeset::from_errors(F { bio: String::new() }, errors);
1322        let html = textarea_input(&cs, "bio", "Bio").into_string();
1323        assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1324        assert!(html.contains(r#"role="alert""#), "{html}");
1325        assert!(html.contains("required"), "{html}");
1326    }
1327
1328    #[cfg(feature = "maud")]
1329    #[test]
1330    fn required_text_input_emits_aria_required() {
1331        #[derive(serde::Serialize)]
1332        struct F {
1333            name: String,
1334        }
1335        let cs = Changeset::new(F {
1336            name: "Alice".into(),
1337        });
1338        let html = required_text_input(&cs, "name", "Name").into_string();
1339        assert!(html.contains(r#"aria-required="true""#), "{html}");
1340        assert!(html.contains("required"), "{html}");
1341        assert!(html.contains(r#"name="name""#), "{html}");
1342        assert!(html.contains("Name"), "{html}");
1343    }
1344
1345    #[cfg(feature = "maud")]
1346    #[test]
1347    fn required_text_input_preserves_error_handling() {
1348        #[derive(serde::Serialize)]
1349        struct F {
1350            name: String,
1351        }
1352        let mut errors = HashMap::new();
1353        errors.insert("name".to_string(), vec!["required".to_string()]);
1354        let cs = Changeset::from_errors(
1355            F {
1356                name: String::new(),
1357            },
1358            errors,
1359        );
1360        let html = required_text_input(&cs, "name", "Name").into_string();
1361        assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1362        assert!(html.contains(r#"aria-required="true""#), "{html}");
1363        assert!(html.contains(r#"role="alert""#), "{html}");
1364    }
1365
1366    #[cfg(feature = "maud")]
1367    #[test]
1368    fn aria_live_region_renders_role_status() {
1369        let html = aria_live_region("status-msg", "").into_string();
1370        assert!(html.contains(r#"role="status""#), "{html}");
1371        assert!(html.contains(r#"aria-live="polite""#), "{html}");
1372        assert!(html.contains(r#"id="status-msg""#), "{html}");
1373    }
1374
1375    #[cfg(feature = "maud")]
1376    #[test]
1377    fn aria_live_region_renders_message_content() {
1378        let html = aria_live_region("status-msg", "Form submitted").into_string();
1379        assert!(html.contains("Form submitted"), "{html}");
1380    }
1381
1382    #[cfg(feature = "maud")]
1383    #[test]
1384    fn skip_link_renders_anchor_with_href() {
1385        let html = skip_link("#main-content", "Skip to main content").into_string();
1386        assert!(html.contains(r##"href="#main-content""##), "{html}");
1387        assert!(html.contains("Skip to main content"), "{html}");
1388    }
1389
1390    #[cfg(feature = "maud")]
1391    #[test]
1392    fn skip_link_has_visually_hidden_class_for_focus_reveal() {
1393        let html = skip_link("#main", "Skip").into_string();
1394        assert!(html.contains("skip-link"), "{html}");
1395    }
1396
1397    // ── ChangesetForm extractor (axum integration) ─────────────────
1398
1399    mod extractor_tests {
1400        use super::*;
1401        use axum::{Router, body::Body, routing::post};
1402        use tower::ServiceExt;
1403
1404        #[derive(serde::Deserialize, validator::Validate)]
1405        struct TestForm {
1406            #[validate(length(min = 3))]
1407            name: String,
1408        }
1409
1410        #[tokio::test]
1411        async fn valid_form_body_produces_valid_changeset() {
1412            async fn handler(form: ChangesetForm<TestForm>) -> String {
1413                format!("valid={}", form.is_valid())
1414            }
1415            let resp = Router::new()
1416                .route("/test", post(handler))
1417                .oneshot(urlencoded_req("/test", "name=Alice"))
1418                .await
1419                .unwrap();
1420            assert_body(resp, "valid=true").await;
1421        }
1422
1423        #[tokio::test]
1424        async fn invalid_form_body_produces_invalid_changeset() {
1425            async fn handler(form: ChangesetForm<TestForm>) -> String {
1426                format!("valid={}", form.is_valid())
1427            }
1428            let resp = Router::new()
1429                .route("/test", post(handler))
1430                .oneshot(urlencoded_req("/test", "name=ab"))
1431                .await
1432                .unwrap();
1433            assert_body(resp, "valid=false").await;
1434        }
1435
1436        #[tokio::test]
1437        async fn invalid_form_exposes_field_errors() {
1438            async fn handler(form: ChangesetForm<TestForm>) -> String {
1439                form.errors_for("name").join("|")
1440            }
1441            let resp = Router::new()
1442                .route("/test", post(handler))
1443                .oneshot(urlencoded_req("/test", "name=ab"))
1444                .await
1445                .unwrap();
1446            let body = body_text(resp).await;
1447            assert!(!body.is_empty(), "expected errors, got empty string");
1448        }
1449
1450        #[tokio::test]
1451        async fn missing_required_field_returns_non_200() {
1452            async fn handler(form: ChangesetForm<TestForm>) -> String {
1453                format!("valid={}", form.is_valid())
1454            }
1455            let resp = Router::new()
1456                .route("/test", post(handler))
1457                .oneshot(urlencoded_req("/test", "other=value"))
1458                .await
1459                .unwrap();
1460            assert_ne!(resp.status(), axum::http::StatusCode::OK);
1461        }
1462
1463        #[tokio::test]
1464        async fn csrf_token_is_none_without_csrf_middleware() {
1465            async fn handler(form: ChangesetForm<TestForm>) -> String {
1466                form.csrf_token().unwrap_or("none").to_string()
1467            }
1468            let resp = Router::new()
1469                .route("/test", post(handler))
1470                .oneshot(urlencoded_req("/test", "name=Alice"))
1471                .await
1472                .unwrap();
1473            assert_body(resp, "none").await;
1474        }
1475
1476        #[tokio::test]
1477        async fn csrf_token_captured_from_request_extensions() {
1478            // Build a request with CsrfToken pre-inserted in extensions,
1479            // simulating what CsrfLayer does, then call from_request directly.
1480            use crate::security::CsrfToken;
1481
1482            let mut req = axum::http::Request::builder()
1483                .method("POST")
1484                .uri("/test")
1485                .header("Content-Type", "application/x-www-form-urlencoded")
1486                .body(Body::from("name=Alice"))
1487                .unwrap();
1488            req.extensions_mut()
1489                .insert(CsrfToken::new("secret-tok".to_string()));
1490
1491            let form = ChangesetForm::<TestForm>::from_request(req, &())
1492                .await
1493                .expect("extraction should succeed");
1494
1495            assert_eq!(form.csrf_token(), Some("secret-tok"));
1496        }
1497
1498        #[cfg(feature = "multipart")]
1499        #[tokio::test]
1500        async fn multipart_form_decodes_text_fields() {
1501            async fn handler(form: ChangesetForm<TestForm>) -> String {
1502                format!("valid={} name={}", form.is_valid(), form.data().name)
1503            }
1504            let resp = Router::new()
1505                .route("/test", post(handler))
1506                .oneshot(multipart_req("/test", "name", "Alice"))
1507                .await
1508                .unwrap();
1509            assert_body(resp, "valid=true name=Alice").await;
1510        }
1511
1512        #[cfg(feature = "multipart")]
1513        #[tokio::test]
1514        async fn multipart_form_validates_fields() {
1515            async fn handler(form: ChangesetForm<TestForm>) -> String {
1516                format!("valid={}", form.is_valid())
1517            }
1518            let resp = Router::new()
1519                .route("/test", post(handler))
1520                .oneshot(multipart_req("/test", "name", "ab"))
1521                .await
1522                .unwrap();
1523            assert_body(resp, "valid=false").await;
1524        }
1525
1526        // ── Helpers ────────────────────────────────────────────────
1527
1528        fn urlencoded_req(uri: &str, body: &'static str) -> axum::http::Request<Body> {
1529            axum::http::Request::builder()
1530                .method("POST")
1531                .uri(uri)
1532                .header("Content-Type", "application/x-www-form-urlencoded")
1533                .body(Body::from(body))
1534                .unwrap()
1535        }
1536
1537        #[cfg(feature = "multipart")]
1538        fn multipart_req(uri: &str, field: &str, value: &str) -> axum::http::Request<Body> {
1539            let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
1540            let body = format!(
1541                "--{boundary}\r\n\
1542                 Content-Disposition: form-data; name=\"{field}\"\r\n\r\n\
1543                 {value}\r\n\
1544                 --{boundary}--\r\n"
1545            );
1546            axum::http::Request::builder()
1547                .method("POST")
1548                .uri(uri)
1549                .header(
1550                    "Content-Type",
1551                    format!("multipart/form-data; boundary={boundary}"),
1552                )
1553                .body(Body::from(body))
1554                .unwrap()
1555        }
1556
1557        async fn body_text(resp: axum::response::Response) -> String {
1558            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1559                .await
1560                .unwrap();
1561            String::from_utf8(bytes.to_vec()).unwrap()
1562        }
1563
1564        async fn assert_body(resp: axum::response::Response, expected: &str) {
1565            assert_eq!(body_text(resp).await, expected);
1566        }
1567    }
1568}