Skip to main content

autumn_web/
nested_form.rs

1//! Nested (`has_many`) form binding — a parent struct plus one child
2//! collection, decoded and validated in a single extractor.
3//!
4//! # Overview
5//!
6//! [`NestedChangesetForm<P, C>`] is the nested-form counterpart of
7//! [`ChangesetForm<T>`](crate::form::ChangesetForm): it decodes a form body
8//! carrying a parent struct `P` **and** a repeated child collection `C`
9//! (rendered with input names like `items[0][sku]`, `items[1][sku]`, …),
10//! runs [`validator::Validate`] on the parent and on every non-destroyed
11//! child row, and captures per-field errors so the whole form can be
12//! re-rendered inline after a failed submission.
13//!
14//! The child collection is identified by the [`NestedChild::COLLECTION`]
15//! constant, which names the field group in input names
16//! (`items[i][field]`) and in combined error keys (`items[1].quantity`).
17//!
18//! # Wire format
19//!
20//! ```text
21//! name=Order+1                 // parent field
22//! items[0][sku]=A-1            // child row 0, subfield `sku`
23//! items[0][quantity]=2         // child row 0, subfield `quantity`
24//! items[1][sku]=B-2            // child row 1
25//! items[1][quantity]=3
26//! items[1][_destroy]=1         // optional: mark row 1 for removal
27//! ```
28//!
29//! Row indices need not be contiguous — client-side removal can leave gaps
30//! (`items[0]`, `items[2]`) which are compacted in ascending order to
31//! preserve row order. The compacted 0-based position is what appears in
32//! combined error keys and is what a renderer iterates.
33//!
34//! # Binding and validating
35//!
36//! ```rust
37//! use autumn_web::nested_form::{decode_nested_urlencoded, NestedChild};
38//!
39//! #[derive(serde::Deserialize, validator::Validate)]
40//! struct NewOrder {
41//!     #[validate(length(min = 1))]
42//!     name: String,
43//! }
44//!
45//! #[derive(serde::Deserialize, validator::Validate)]
46//! struct NewLineItem {
47//!     #[validate(length(min = 1))]
48//!     sku: String,
49//!     #[validate(range(min = 1))]
50//!     quantity: i32,
51//! }
52//!
53//! impl NestedChild for NewLineItem {
54//!     const COLLECTION: &'static str = "items";
55//! }
56//!
57//! let pairs = vec![
58//!     ("name".to_string(), "Order 1".to_string()),
59//!     ("items[0][sku]".to_string(), "A-1".to_string()),
60//!     ("items[0][quantity]".to_string(), "2".to_string()),
61//!     // second row is invalid: quantity is below the range minimum
62//!     ("items[1][sku]".to_string(), "B-2".to_string()),
63//!     ("items[1][quantity]".to_string(), "0".to_string()),
64//! ];
65//!
66//! let changeset = decode_nested_urlencoded::<NewOrder, NewLineItem>(&pairs)
67//!     .expect("parent decodes");
68//!
69//! // The invalid child surfaces under its per-row combined key, and the whole
70//! // changeset refuses to yield a valid `(parent, children)` pair.
71//! assert!(!changeset.errors_for("items[1].quantity").is_empty());
72//! assert!(changeset.errors_for("items[0].quantity").is_empty());
73//! assert!(!changeset.is_valid());
74//! assert!(changeset.into_valid().is_err());
75//! ```
76//!
77//! # Per-row error keys
78//!
79//! Errors are addressable with `#[validate(nested)]`-style combined keys of
80//! the shape `"{COLLECTION}[{i}].{sub}"`. A child whose `quantity` fails
81//! validation on the (compacted) second row surfaces under
82//! [`errors_for("items[1].quantity")`](NestedChangeset::errors_for); a bare
83//! `"items[1]"` returns that row's row-level (parse) error. Parent field
84//! errors keep their plain field-name keys and are delegated to
85//! [`Changeset::errors_for`]. This is exactly the key shape a Maud renderer
86//! reads back per row via [`RowScope::errors_for`], so a failed submission
87//! re-renders each field's message inline next to the offending input.
88//!
89//! # `_destroy` marker
90//!
91//! A child subfield named `_destroy` with a truthy value (`"1"`, `"true"`,
92//! `"on"`) marks its row for removal. Destroyed rows are **retained** for
93//! re-rendering (so the checkbox state survives a round-trip) but never
94//! contribute to `valid_children`, so [`into_valid`](NestedChangeset::into_valid)
95//! drops them from the returned child vector. [`RowScope::destroy_checkbox`]
96//! renders the durable no-JS control that drives this.
97//!
98//! # Rendering: `inputs_for` + htmx / no-JS
99//!
100//! With the `maud` feature, [`inputs_for`] renders the repeating child block:
101//! it re-emits every submitted row (values + inline errors) and then appends
102//! at least one blank template row so a user can add a child **without any
103//! JavaScript** — the pre-rendered blank row's `items[n][…]` inputs post like
104//! any other. Because the browser submits that blank row's empty inputs, the
105//! decoder applies Rails-style `reject_if: :all_blank`: a child row whose every
106//! non-`_destroy` subfield is blank (empty or whitespace-only) is **ignored**
107//! entirely — never decoded, validated, or persisted — so the auto-rendered
108//! blank template row is safe with no JS and never becomes a phantom child that
109//! blocks submission (on a required-field child) or saves an empty row (on an
110//! all-optional child). Supplying [`InputsForOptions::add_row_url`] additionally emits an
111//! htmx "Add row" button (`hx-get` + `hx-swap="beforeend"`, with
112//! `hx-params="index"` so only the `hx-vals` `index` is sent — no form fields
113//! and no CSRF/submit token — are serialized into the fragment request);
114//! [`nested_row_fragment`] renders the server response
115//! for that endpoint. htmx is a progressive enhancement layered over the no-JS
116//! path — it is never required.
117//!
118//! # Handler + atomic save
119//!
120//! ```rust,ignore
121//! #[post("/orders")]
122//! async fn create(
123//!     mut db: Db,
124//!     form: NestedChangesetForm<NewOrder, NewLineItem>,
125//! ) -> impl IntoResponse {
126//!     match form.into_valid() {
127//!         Ok((order, items)) => save_order_with_items(&mut db, order, items).await,
128//!         Err(form) => (StatusCode::UNPROCESSABLE_ENTITY, render(&form)).into_response(),
129//!     }
130//! }
131//! ```
132//!
133//! A parent and its children must be persisted **atomically** — a half-saved
134//! order with only some of its line items is never acceptable. Do it inside a
135//! **single** [`Db::tx`](crate::db::Db::tx): insert the parent, read back its
136//! generated `id`, stamp each child's foreign key with that `id`, and insert
137//! the children — all on the one `conn` the closure is handed. Returning `Err`
138//! from anywhere in the closure (a failing child, a DB constraint violation)
139//! rolls the **whole** transaction back, so neither the parent nor any child
140//! row is left behind.
141//!
142//! ```rust,ignore
143//! use scoped_futures::ScopedFutureExt;
144//!
145//! db.tx(|conn| async move {
146//!     let order = diesel::insert_into(orders::table)
147//!         .values(&new_order)
148//!         .returning(Order::as_returning())
149//!         .get_result(conn)
150//!         .await?;
151//!     for mut item in new_items {
152//!         item.order_id = order.id; // stamp the FK from the freshly-read parent id
153//!         diesel::insert_into(line_items::table)
154//!             .values(&item)
155//!             .execute(conn)
156//!             .await?; // any Err here rolls back the parent insert too
157//!     }
158//!     Ok::<_, diesel::result::Error>(order.id)
159//! }.scope_boxed())
160//! .await
161//! ```
162//!
163//! Use **raw diesel inserts on `conn`**, not a generated
164//! [`Repository`](macro@crate::repository) `create`: that `create` opens its *own*
165//! `Db::tx`, and `Db::tx` cannot be re-entered on the same connection — the
166//! nested call trips the nested-transaction guard and returns a `400`. Keep the
167//! whole parent-plus-children unit of work in the one outer `tx`.
168//!
169//! See `tests/integration/nested_form_atomic_save.rs` for the rollback
170//! correctness gate (a failing child leaves **zero** rows in either table) and
171//! `tests/integration/nested_form_order_example.rs` for the full create flow.
172
173// autumn-panic-gate: request-path module — production code path must be panic-free.
174// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
175// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
176#![cfg_attr(
177    not(test),
178    deny(
179        clippy::unwrap_used,
180        clippy::expect_used,
181        clippy::panic,
182        clippy::unreachable,
183        clippy::todo,
184        clippy::unimplemented,
185        clippy::indexing_slicing,
186        clippy::string_slice,
187        clippy::arithmetic_side_effects,
188    )
189)]
190
191use std::collections::{BTreeMap, BTreeSet, HashMap};
192
193use axum::extract::{FromRequest, Request};
194use axum::response::IntoResponse;
195
196use crate::form::{
197    Changeset, IntoChangeset, decode_urlencoded_dropping_blank_optional_fields,
198    validation_errors_to_map,
199};
200#[cfg(feature = "maud")]
201use crate::form::{FieldKind, FieldOptions, FieldSpec, render_field};
202
203// ── NestedChild ────────────────────────────────────────────────────
204
205/// A child row type bound as part of a nested (`has_many`) form.
206///
207/// The [`COLLECTION`](NestedChild::COLLECTION) constant names the field
208/// group used in input names (`items[i][field]`) and in combined error
209/// keys (`items[1].quantity`).
210pub trait NestedChild: serde::de::DeserializeOwned + validator::Validate + Send {
211    /// Field-group name used in input names (`items[i][field]`) and error
212    /// keys (`items[1].quantity`). e.g. `"items"`.
213    const COLLECTION: &'static str;
214}
215
216// ── NestedRow ──────────────────────────────────────────────────────
217
218/// One submitted child row, retained for re-rendering regardless of whether
219/// it parsed or validated.
220#[derive(Debug, Clone)]
221pub struct NestedRow {
222    /// Raw submitted subfield values (`sku` → `["A-1"]`), used to pre-fill
223    /// inputs when re-rendering after a failed submission. A repeated subfield
224    /// (`items[0][tags]=a&items[0][tags]=b`) keeps every value in order
225    /// (issue #3394).
226    values: HashMap<String, Vec<String>>,
227    /// Per-subfield validation (or parse) errors. A row that failed to parse
228    /// records its error under the offending subfield's name (via
229    /// `serde_path_to_error`), falling back to the empty-string (row-level) key
230    /// only when no field can be identified.
231    errors: HashMap<String, Vec<String>>,
232    /// `true` when the row carried a truthy `_destroy` marker.
233    destroyed: bool,
234}
235
236impl NestedRow {
237    /// The raw submitted value for subfield `sub`, if present. For a repeated
238    /// subfield this is the last value; [`values`](Self::values) returns all
239    /// of them.
240    #[must_use]
241    pub fn value(&self, sub: &str) -> Option<&str> {
242        self.values
243            .get(sub)
244            .and_then(|v| v.last())
245            .map(String::as_str)
246    }
247
248    /// Every raw submitted value for subfield `sub`, in submission order, or an
249    /// empty slice. Use it to re-render a `Vec` subfield (a checkbox group or a
250    /// `<select multiple>`) after a failed submission (issue #3394).
251    #[must_use]
252    pub fn values(&self, sub: &str) -> &[String] {
253        self.values.get(sub).map_or(&[], Vec::as_slice)
254    }
255
256    /// Validation messages for subfield `sub`, or an empty slice.
257    ///
258    /// A row-level parse failure is stored under the empty-string key, so
259    /// `row.errors_for("")` returns any whole-row decode error.
260    #[must_use]
261    pub fn errors_for(&self, sub: &str) -> &[String] {
262        self.errors.get(sub).map_or(&[], Vec::as_slice)
263    }
264
265    /// `true` when the row was marked for removal via a truthy `_destroy`.
266    #[must_use]
267    pub const fn is_destroyed(&self) -> bool {
268        self.destroyed
269    }
270
271    /// Every error keyed by subfield name (empty key = row-level parse error).
272    #[must_use]
273    pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
274        &self.errors
275    }
276}
277
278// ── NestedChangeset ────────────────────────────────────────────────
279
280/// A parent [`Changeset<P>`] plus its bound child collection.
281///
282/// Obtain one from [`decode_nested_urlencoded`] or (preferred) the
283/// [`NestedChangesetForm`] axum extractor.
284#[derive(Debug)]
285pub struct NestedChangeset<P, C> {
286    /// The parent changeset (values + per-field errors), reusing the existing
287    /// [`Changeset`] pipeline.
288    pub parent: Changeset<P>,
289    /// The submitted child rows in compacted order, retained for re-render.
290    rows: Vec<NestedRow>,
291    /// `Some(children)` iff the parent is valid **and** every non-destroyed
292    /// row both parsed and validated; `None` otherwise.
293    valid_children: Option<Vec<C>>,
294}
295
296impl<P, C: NestedChild> NestedChangeset<P, C> {
297    /// Build a blank, **non-validating** nested changeset for the initial
298    /// `new` (create) render, before the user has submitted anything.
299    ///
300    /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
301    /// wraps `parent` in a valid [`Changeset`] (via [`Changeset::new`], which
302    /// records **no** errors — it does **not** run [`validator::Validate`])
303    /// with **zero** child rows. So `is_valid()` returns `true`,
304    /// `errors_for(..)` is empty, `rows()` is empty, and `into_valid()` returns
305    /// `Ok((parent, vec![]))` even when a required parent field is still empty.
306    ///
307    /// Use this for the initial GET render of an **empty** (create) form so the
308    /// blank page does not show a premature "field is required" error or
309    /// `aria-invalid="true"` before the user has typed. For an **edit** render
310    /// that must display existing child rows (with their persisted ids), use
311    /// [`seeded`](NestedChangeset::seeded) instead — `blank` produces zero child
312    /// rows and cannot pre-fill or preserve existing line items. Use
313    /// [`decode_nested_urlencoded`] (or the [`NestedChangesetForm`] extractor)
314    /// for the POST decode that validates a real submission and re-renders with
315    /// inline errors.
316    #[must_use]
317    pub fn blank(parent: P) -> Self {
318        Self {
319            parent: Changeset::new(parent),
320            rows: Vec::new(),
321            valid_children: Some(Vec::new()),
322        }
323    }
324
325    /// `true` when the parent is valid, every non-destroyed row has no
326    /// errors, and the children all parsed and validated.
327    #[must_use]
328    pub fn is_valid(&self) -> bool {
329        self.parent.is_valid()
330            && self.valid_children.is_some()
331            && self.rows.iter().all(|r| r.destroyed || r.errors.is_empty())
332    }
333
334    /// Consume the changeset, returning `Ok((parent, children))` when valid or
335    /// `Err(self)` (with all rows retained for re-render) when not.
336    ///
337    /// The returned child vector contains only non-destroyed rows, in order.
338    ///
339    /// # Errors
340    ///
341    /// Returns `Err(self)` when the parent or any non-destroyed child row has
342    /// validation errors, or any child failed to parse.
343    #[allow(
344        clippy::result_large_err,
345        reason = "the Err variant intentionally returns the whole changeset (rows + raw \
346                  values) so the handler can re-render the form inline with errors"
347    )]
348    pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
349        if !self.is_valid() {
350            return Err(self);
351        }
352        let Self {
353            parent,
354            rows,
355            valid_children,
356        } = self;
357        // `is_valid()` guarantees both arms below take the happy path; the
358        // reconstruction branches keep the code panic-free without relying on
359        // that invariant.
360        match valid_children {
361            Some(children) => match parent.into_valid() {
362                Ok(p) => Ok((p, children)),
363                Err(parent) => Err(Self {
364                    parent,
365                    rows,
366                    valid_children: None,
367                }),
368            },
369            None => Err(Self {
370                parent,
371                rows,
372                valid_children: None,
373            }),
374        }
375    }
376
377    /// Validation messages for `key`, or an empty slice.
378    ///
379    /// Supports both parent field keys (delegated to
380    /// [`Changeset::errors_for`]) and combined child keys of the form
381    /// `"{COLLECTION}[{i}].{sub}"` (e.g. `"items[1].quantity"`). A bare
382    /// `"{COLLECTION}[{i}]"` returns that row's row-level (parse) errors.
383    #[must_use]
384    pub fn errors_for(&self, key: &str) -> &[String] {
385        if let Some((idx, sub)) = parse_combined_child_key(key, C::COLLECTION) {
386            return self.rows.get(idx).map_or(&[], |r| r.errors_for(sub));
387        }
388        self.parent.errors_for(key)
389    }
390
391    /// The submitted child rows in compacted (ascending-index) order.
392    #[must_use]
393    pub fn rows(&self) -> &[NestedRow] {
394        &self.rows
395    }
396
397    /// The child collection name, i.e. [`NestedChild::COLLECTION`].
398    #[must_use]
399    pub const fn collection_name(&self) -> &'static str {
400        C::COLLECTION
401    }
402}
403
404/// Edit-render seeding — pre-populate a non-validating changeset with existing
405/// persisted children.
406///
407/// The extra [`serde::Serialize`] bound lives on this `impl` block alone, so the
408/// core [`NestedChild`] trait stays serialize-free; only callers that seed an
409/// edit form need their child type to be serializable.
410impl<P, C: NestedChild + serde::Serialize> NestedChangeset<P, C> {
411    /// Build a **non-validating**, valid nested changeset that pre-renders one
412    /// row per existing `child`, for the initial `edit` render of a form that
413    /// already has persisted children.
414    ///
415    /// Where [`blank`](NestedChangeset::blank) produces **zero** child rows (for
416    /// the create/new page), `seeded` produces exactly one [`NestedRow`] per
417    /// element of `children`, so an edit form can display and preserve existing
418    /// line items — including each child's `id` as a hidden input (via
419    /// [`RowScope::hidden_input`]) — before the first submit. That hidden `id`
420    /// also makes the in-scope `_destroy`-on-existing-children flow usable: the
421    /// row round-trips its identity so ticking Remove on a persisted child
422    /// submits a real, identifiable row for the handler to delete.
423    ///
424    /// Each row's raw `values` are produced by serializing the child with
425    /// [`serde_html_form::to_string`] (a `Vec` field becomes repeated keys) and
426    /// parsing the result back into the
427    /// per-subfield map — the **same** string representation
428    /// [`decode_nested_urlencoded`] populates from a real submission, so
429    /// [`RowScope::value`] pre-fills a seeded row's inputs identically to a
430    /// re-rendered submitted row.
431    ///
432    /// Like `blank`, this does **not** run [`validator::Validate`]: it wraps
433    /// `parent` in a valid [`Changeset`] (via [`Changeset::new`]) and keeps
434    /// `valid_children = Some(children)`, so `is_valid()` returns `true`,
435    /// `errors_for(..)` is empty, every seeded row reports no errors and is not
436    /// destroyed, and `into_valid()` returns `Ok((parent, children))`. Use the
437    /// POST decode path ([`decode_nested_urlencoded`] / the
438    /// [`NestedChangesetForm`] extractor) to validate the actual edit
439    /// submission.
440    ///
441    /// Deeper reconciliation of persisted children — reordering, or deep-diffing
442    /// submitted rows against the seeded set to compute per-row inserts /
443    /// updates / deletes — is intentionally **out of scope** here (see issue
444    /// #1346's out-of-scope list) and remains a follow-up; `seeded` only
445    /// pre-renders the existing rows so the no-JS edit + `_destroy` flow works.
446    #[must_use]
447    pub fn seeded(parent: P, children: Vec<C>) -> Self {
448        let rows = children
449            .iter()
450            .map(|child| {
451                let mut values: HashMap<String, Vec<String>> = HashMap::new();
452                // Serialize the child to the same `field=value` wire form the
453                // decoder reads, then parse it back so `RowScope::value` pre-fills
454                // seeded rows identically to a re-rendered submitted row. A
455                // serialization failure (never expected for a plain struct) simply
456                // yields an empty value map rather than panicking on the request path.
457                if let Ok(encoded) = serde_html_form::to_string(child) {
458                    for (k, v) in url::form_urlencoded::parse(encoded.as_bytes()) {
459                        values
460                            .entry(k.into_owned())
461                            .or_default()
462                            .push(v.into_owned());
463                    }
464                }
465                NestedRow {
466                    values,
467                    errors: HashMap::new(),
468                    destroyed: false,
469                }
470            })
471            .collect();
472        Self {
473            parent: Changeset::new(parent),
474            rows,
475            valid_children: Some(children),
476        }
477    }
478}
479
480// ── Decoder ────────────────────────────────────────────────────────
481
482/// Decode URL-encoded `pairs` into a [`NestedChangeset<P, C>`].
483///
484/// Pairs whose key matches `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` are child
485/// subfields (captured index + subfield name); everything else is a parent
486/// pair. Child pairs are grouped by numeric index into ascending order and
487/// **compacted** so non-contiguous indices (gaps from client-side removal)
488/// still yield sequential rows preserving submission order. Subfield order
489/// within a row is preserved.
490///
491/// A `_destroy` subfield with a truthy value (`"1"`, `"true"`, `"on"`) marks
492/// its row destroyed; destroyed rows are retained (for re-render) but never
493/// contribute to `valid_children`.
494///
495/// Following Rails' `reject_if: :all_blank`, a child row whose every
496/// non-`_destroy` subfield value is blank (empty or whitespace-only after
497/// trimming) is **ignored**: it is not decoded, not validated, not retained in
498/// `rows`, and never counts toward `valid_children` — exactly as if that index
499/// had never been submitted. This is what makes the blank template row
500/// [`inputs_for`] auto-renders safe with no JavaScript. A row with at least one
501/// non-blank non-`_destroy` value is kept and validated as usual, so a
502/// partially filled row that omits a required field still surfaces per-field
503/// errors.
504///
505/// Both the parent and each non-destroyed child are decoded through the same
506/// blank-optional-dropping path
507/// [`ChangesetForm`](crate::form::ChangesetForm) uses, so string→typed
508/// coercion (`quantity=5` → `i32`) comes for free. A repeated key fills a
509/// `Vec<T>` field. On a single-value field it is an error (issue #3394): for
510/// the parent, the decode fails; for a row, the error is on that subfield.
511///
512/// # Unstorable bytes
513///
514/// Submitted text values are swept for NUL (`U+0000`) before anything decodes
515/// them (issue #2423), and a parent field or child subfield that carried one
516/// gets [`NUL_CHARACTER_FIELD_ERROR`](crate::form::NUL_CHARACTER_FIELD_ERROR)
517/// alongside whatever the validators said. `_destroy` is excluded from the
518/// sweep — cleaning a marker read only for truthiness would change it — and so
519/// are the *default* names of the framework's plumbing fields, since this
520/// function takes raw pairs and never sees the request extensions that carry
521/// their configured names. [`NestedChangesetForm`] additionally exempts those.
522///
523/// # Errors
524///
525/// Returns `Err(message)` when the **parent** fails to decode (a malformed,
526/// non-blank typed value) — mirroring the hard-400 contract of
527/// [`ChangesetForm`](crate::form::ChangesetForm). A **child** row that fails
528/// to parse is not a hard error: it is retained with a row-level error and
529/// marks the changeset invalid.
530pub fn decode_nested_urlencoded<P, C>(
531    pairs: &[(String, String)],
532) -> Result<NestedChangeset<P, C>, String>
533where
534    P: serde::de::DeserializeOwned + validator::Validate,
535    C: NestedChild,
536{
537    decode_nested_with_nul_message(pairs, crate::form::NUL_CHARACTER_FIELD_ERROR)
538        .map_err(|e| e.to_string())
539}
540
541/// Like [`decode_nested_urlencoded`], but the caller sets the message for a NUL
542/// byte.
543/// The error keeps its status, so the extractor answers 422 for a repeated
544/// parent field and 400 for the rest.
545fn decode_nested_with_nul_message<P, C>(
546    pairs: &[(String, String)],
547    nul_message: &str,
548) -> Result<NestedChangeset<P, C>, crate::form::FormDecodeError>
549where
550    P: serde::de::DeserializeOwned + validator::Validate,
551    C: NestedChild,
552{
553    let collection = C::COLLECTION;
554
555    // Split parent pairs from grouped child subfields. `BTreeMap` keeps the
556    // rows in ascending index order; enumerating it later compacts gaps.
557    let mut parent_pairs: Vec<(String, String)> = Vec::new();
558    let mut child_groups: BTreeMap<usize, Vec<(String, String)>> = BTreeMap::new();
559    for (key, value) in pairs {
560        if let Some((idx, sub)) = parse_child_key(key, collection) {
561            child_groups
562                .entry(idx)
563                .or_default()
564                .push((sub.to_string(), value.clone()));
565        } else {
566            parent_pairs.push((key.clone(), value.clone()));
567        }
568    }
569
570    // #2423: sweep NUL bytes out of the submitted values before anything
571    // decodes them, remembering which names carried one so the byte lands as
572    // an ordinary error on the field or subfield that held it. Postgres cannot
573    // store `0x00` in a TEXT column, so without this the row parses, validates,
574    // and fails only at the database with an unhandled 500. Parent fields and
575    // each row's subfields are swept separately because they are keyed
576    // separately in the rendered form.
577    // `_destroy` is excluded from the sweep, not merely from the reporting.
578    // Cleaning it would rewrite the marker itself: `is_truthy` trims only
579    // whitespace, so `"1\u{0}"` reads as false today and would read as *true*
580    // after stripping — silently destroying a row the author submitted to keep.
581    // It is never written to a column, so leaving the byte in it costs nothing.
582    let parent_nul_fields: Vec<String> = crate::form::strip_nul_from_pairs(&mut parent_pairs)
583        .into_iter()
584        // A direct caller of this public function passes the raw pairs, tokens
585        // and all, and never sees the request extensions that carry the
586        // configurable field names — so exempt the defaults here. The
587        // extractor additionally exempts the configured names.
588        .filter(|field| !crate::form::is_plumbing_field(field))
589        .collect();
590    let child_nul_fields: BTreeMap<usize, Vec<String>> = child_groups
591        .iter_mut()
592        .map(|(idx, subfields)| {
593            // Deduplicated for the same reason `strip_nul_from_pairs` is: a
594            // subfield may be submitted any number of times, and one message
595            // per *pair* would let a crafted body inflate a single row's error
596            // list without bound. One message per offending subfield.
597            let mut swept: BTreeSet<String> = BTreeSet::new();
598            for (sub, value) in subfields.iter_mut() {
599                if sub != DESTROY_SUBFIELD && value.as_bytes().contains(&0) {
600                    *value = crate::normalize::strip_nul(value);
601                    if !swept.contains(sub.as_str()) {
602                        swept.insert(sub.clone());
603                    }
604                }
605            }
606            (*idx, swept.into_iter().collect::<Vec<String>>())
607        })
608        .collect();
609
610    // Decode the parent through the shared blank-optional-dropping path.
611    let parent_encoded = encode_pairs(&parent_pairs);
612    let parent_data: P =
613        decode_urlencoded_dropping_blank_optional_fields::<P>(parent_encoded.as_bytes())?;
614    let mut parent = parent_data.into_changeset();
615    for field in parent_nul_fields {
616        parent.add_error(field, nul_message);
617    }
618
619    let mut rows: Vec<NestedRow> = Vec::new();
620    let mut children: Vec<C> = Vec::new();
621    let mut all_children_ok = true;
622
623    for (idx, subfields) in child_groups {
624        match decode_child_row::<C>(
625            &subfields,
626            child_nul_fields.get(&idx).map_or(&[], Vec::as_slice),
627            nul_message,
628        ) {
629            ChildRow::Dropped => {}
630            ChildRow::Bound { row, child } => {
631                if let Some(child) = child {
632                    children.push(child);
633                }
634                rows.push(row);
635            }
636            ChildRow::Rejected(row) => {
637                all_children_ok = false;
638                rows.push(row);
639            }
640        }
641    }
642
643    let valid_children = if parent.is_valid() && all_children_ok {
644        Some(children)
645    } else {
646        None
647    };
648
649    Ok(NestedChangeset {
650        parent,
651        rows,
652        valid_children,
653    })
654}
655
656/// The outcome of decoding one submitted child row.
657enum ChildRow<C> {
658    /// A blank template row (Rails `reject_if: :all_blank`) — dropped
659    /// entirely, exactly as if that index had never been submitted.
660    Dropped,
661    /// Retained for re-render and contributing `child` to the bound children:
662    /// `Some` when the row parsed and validated, `None` when it is marked
663    /// destroyed and so has nothing to write. Neither blocks `into_valid`.
664    Bound {
665        /// The row as re-rendered.
666        row: NestedRow,
667        /// The bound child, absent for a destroyed row.
668        child: Option<C>,
669    },
670    /// Retained for re-render carrying per-subfield errors; blocks
671    /// `into_valid`.
672    Rejected(NestedRow),
673}
674
675/// Decode one submitted child row from its grouped subfields.
676///
677/// `nul_fields` names the subfields of this row whose submitted value carried
678/// a NUL byte (#2423); the values in `subfields` have already been cleaned, so
679/// this function only sets the message for them, from `nul_message`.
680fn decode_child_row<C: NestedChild>(
681    subfields: &[(String, String)],
682    nul_fields: &[String],
683    nul_message: &str,
684) -> ChildRow<C> {
685    let mut values: HashMap<String, Vec<String>> = HashMap::new();
686    let mut destroyed = false;
687    // Subfields decoded into `C`, excluding the `_destroy` marker (not a
688    // field of `C`).
689    let mut decode_pairs: Vec<(String, String)> = Vec::new();
690    for (sub, val) in subfields {
691        values.entry(sub.clone()).or_default().push(val.clone());
692        if sub == DESTROY_SUBFIELD {
693            if is_truthy(val) {
694                destroyed = true;
695            }
696        } else {
697            decode_pairs.push((sub.clone(), val.clone()));
698        }
699    }
700
701    // Rails `reject_if: :all_blank`: when every non-`_destroy` subfield value is blank —
702    // empty or whitespace-only after trimming — drop the row entirely. This is the
703    // auto-rendered blank template row `inputs_for` emits for the no-JS "add a child"
704    // path, not a real submitted child, so treat it as if the index was never submitted:
705    // do not decode, validate, retain, or count it toward the children. A row with at
706    // least one non-blank, non-`_destroy` value is kept and validated as usual, so a
707    // partially filled row still surfaces its per-field errors. `decode_pairs` already
708    // excludes `_destroy`, so an all-blank row that also carries `_destroy` is dropped
709    // here too, the same outcome as the destroy path.
710    let all_blank = decode_pairs.iter().all(|(_, val)| val.trim().is_empty());
711
712    // A genuinely blank template row drops, destroyed or not — unchanged.
713    if all_blank && nul_fields.is_empty() {
714        return ChildRow::Dropped;
715    }
716
717    // #2423: a destroyed row is never written, so there is nothing to reject
718    // there — the cleaned values are retained only so the row re-renders. This
719    // has to be decided *before* the NUL rejection below: a row whose only
720    // remaining content was an unstorable byte is still a row the author asked
721    // to delete, and rejecting it would block `into_valid` and leave them
722    // unable to remove it.
723    if destroyed {
724        return ChildRow::Bound {
725            row: NestedRow {
726                values,
727                errors: HashMap::new(),
728                destroyed,
729            },
730            child: None,
731        };
732    }
733
734    // #2423: values were cleaned before the blank check, so a subfield whose
735    // only content was a NUL now reads as blank. That must not make the row
736    // *vanish* — the author did submit something, it just could not be stored
737    // — so a row that carried a NUL is reported even when nothing legible is
738    // left of it.
739    if all_blank {
740        let mut errors: HashMap<String, Vec<String>> = HashMap::new();
741        for field in nul_fields {
742            errors
743                .entry(field.clone())
744                .or_default()
745                .push(nul_message.to_owned());
746        }
747        return ChildRow::Rejected(NestedRow {
748            values,
749            errors,
750            destroyed,
751        });
752    }
753
754    let mut errors: HashMap<String, Vec<String>> = HashMap::new();
755    let mut child: Option<C> = None;
756
757    let encoded = encode_pairs(&decode_pairs);
758    match decode_urlencoded_dropping_blank_optional_fields::<C>(encoded.as_bytes()) {
759        Ok(decoded) => match validator::Validate::validate(&decoded) {
760            Ok(()) => child = Some(decoded),
761            Err(ve) => errors = validation_errors_to_map(&ve),
762        },
763        Err(e) => {
764            // Row parse failure — deserialization failed before validation,
765            // as when `sku` is filled but the required numeric `quantity` is
766            // malformed or present-but-blank. Key the message under the
767            // offending subfield so it surfaces as `items[i].{field}`,
768            // rendered by `errors_for("{field}")` next to the offending input
769            // rather than only under the row-level "" key.
770            //
771            // The blank-optional retry inside
772            // `decode_urlencoded_dropping_blank_optional_fields` can drop a
773            // present-but-blank typed field (`quantity=`) before serde sees it,
774            // so the primary error is then a `missing field` report at the row
775            // root with an empty path, losing the field name the row helpers
776            // need. `recover_child_error_field` recovers it by preferring the
777            // primary error's `missing field` message, with a raw non-dropping
778            // re-decode only as a last resort. Only if every layer fails does it
779            // fall back to the row-level "" key, so the error is never silently
780            // dropped.
781            let field = recover_child_error_field::<C>(&e, &decode_pairs);
782            errors.entry(field).or_default().push(e.to_string());
783        }
784    }
785
786    // #2423: a NUL byte in this row's text is a rejection in its own right,
787    // recorded alongside whatever the validators said.
788    for field in nul_fields {
789        errors
790            .entry(field.clone())
791            .or_default()
792            .push(nul_message.to_owned());
793        child = None;
794    }
795
796    let row = NestedRow {
797        values,
798        errors,
799        destroyed,
800    };
801    match child {
802        Some(child) => ChildRow::Bound {
803            row,
804            child: Some(child),
805        },
806        None => ChildRow::Rejected(row),
807    }
808}
809
810/// Parse `key` as a child subfield reference `COLLECTION[<idx>][<sub>]`,
811/// returning `(idx, sub)`. Hand-parses the bracket pattern (no `regex`
812/// dependency), matching `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` with
813/// `COLLECTION` treated literally.
814fn parse_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
815    let rest = key.strip_prefix(collection)?;
816    let rest = rest.strip_prefix('[')?;
817    let close = rest.find(']')?;
818    let (idx_str, after) = rest.split_at(close);
819    let idx: usize = idx_str.parse().ok()?;
820    // `after` begins with the `]` that `close` pointed at.
821    let after = after.strip_prefix(']')?;
822    let after = after.strip_prefix('[')?;
823    let close2 = after.find(']')?;
824    let (sub, tail) = after.split_at(close2);
825    // The subfield must be the final segment: exactly a trailing `]`.
826    if tail != "]" || sub.is_empty() {
827        return None;
828    }
829    Some((idx, sub))
830}
831
832/// Parse a combined error key `COLLECTION[<idx>]` optionally followed by
833/// `.<sub>`, returning `(idx, sub)` where `sub` is `""` for the bare form.
834/// Returns `None` when the key is not a child key (so it falls through to the
835/// parent).
836fn parse_combined_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
837    let rest = key.strip_prefix(collection)?;
838    let rest = rest.strip_prefix('[')?;
839    let close = rest.find(']')?;
840    let (idx_str, after) = rest.split_at(close);
841    let idx: usize = idx_str.parse().ok()?;
842    let after = after.strip_prefix(']')?;
843    if after.is_empty() {
844        return Some((idx, ""));
845    }
846    let sub = after.strip_prefix('.')?;
847    if sub.is_empty() {
848        return None;
849    }
850    Some((idx, sub))
851}
852
853/// Re-encode `pairs` as an `application/x-www-form-urlencoded` string so the
854/// shared form decode path can re-parse them.
855fn encode_pairs(pairs: &[(String, String)]) -> String {
856    url::form_urlencoded::Serializer::new(String::new())
857        .extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
858        .finish()
859}
860
861/// Last `Map`/`Enum` key in a `serde_path_to_error` path, if any is non-empty.
862///
863/// This is the subfield the decode failure is attributed to (e.g. `quantity`
864/// for `items[i][quantity]`). `Seq`/`Unknown` segments carry no field name.
865fn last_path_field(path: &serde_path_to_error::Path) -> Option<String> {
866    path.iter()
867        .filter_map(|seg| match seg {
868            serde_path_to_error::Segment::Map { key }
869            | serde_path_to_error::Segment::Enum { variant: key } => Some(key.clone()),
870            serde_path_to_error::Segment::Seq { .. } | serde_path_to_error::Segment::Unknown => {
871                None
872            }
873        })
874        .next_back()
875        .filter(|f| !f.is_empty())
876}
877
878/// Extract the field name from serde's stable "missing field" message — the
879/// backtick-quoted identifier in [`serde::de::Error::missing_field`]'s
880/// `Display` format — if present.
881fn missing_field_name(msg: &str) -> Option<String> {
882    let after = msg.split_once("missing field `")?.1;
883    let name = after.split_once('`')?.0;
884    (!name.is_empty()).then(|| name.to_owned())
885}
886
887/// Recover the offending subfield name for a child-row parse failure so the
888/// error can be keyed to `items[i].{field}` (rendered next to the input) rather
889/// than only under the row-level "" key.
890///
891/// Layered because the blank-optional retry inside
892/// [`decode_urlencoded_dropping_blank_optional_fields`] can DROP a
893/// present-but-blank typed field (`quantity=`) before serde ever sees it, so
894/// the primary decode reports a root-level missing-field error for `quantity`
895/// with an empty path — losing the field name the row helpers need:
896///
897/// 1. The primary decode's `serde_path_to_error` path (last `Map`/`Enum` key).
898///    For a malformed non-blank value (`quantity=abc`) this already yields the
899///    field, since that pair is never dropped.
900/// 2. If empty, the backtick-quoted identifier parsed from the PRIMARY error's
901///    missing-field message. The blank-dropping decode stops on the first typed
902///    field it can't satisfy, so this names the truly-required field even when
903///    an earlier OPTIONAL blank precedes it — e.g. a child with an optional
904///    `weight` before a required `quantity`, both submitted blank, reports the
905///    required `quantity` as missing, not `weight`. It also covers the
906///    present-but-blank required field (dropped → missing) and the field truly
907///    never submitted. This is preferred over the raw re-decode below precisely
908///    so the error lands on the required field, not a harmless optional blank a
909///    non-dropping pass would trip on first.
910/// 3. Only if BOTH are empty (a rare root-level error carrying neither a path
911///    nor a missing-field message), a raw re-decode over the ORIGINAL row pairs
912///    with blanks RETAINED (no dropping), mirroring the shared helper's
913///    deserializer minus the drop loop, and its last `Map`/`Enum` key.
914/// 4. Otherwise `""` (row-level key) so the error is never silently dropped.
915///
916/// This runs ONLY on the already-failed path to LOCATE the field; it does not
917/// affect the success path. Legitimately-blank optional fields still decode to
918/// `None` via the primary blank-dropping decode, which succeeds for them and
919/// never reaches here.
920fn recover_child_error_field<C: serde::de::DeserializeOwned>(
921    primary: &crate::form::FormDecodeError,
922    decode_pairs: &[(String, String)],
923) -> String {
924    // 0. A repeated single-value subfield (issue #3394) names itself.
925    let Some(primary) = primary.invalid_error() else {
926        return primary.field().unwrap_or_default().to_owned();
927    };
928
929    // 1. Primary decode path (present-but-malformed value at a real path).
930    if let Some(field) = last_path_field(primary.path()) {
931        return field;
932    }
933
934    // 2. The `missing field \`X\`` identifier from the PRIMARY error — names
935    //    the required field the blank-dropping pass actually stopped on.
936    if let Some(field) = missing_field_name(&primary.inner().to_string()) {
937        return field;
938    }
939
940    // 3. Fallback: raw (non-dropping) re-decode over the original row pairs,
941    //    for the rare root-level error with neither a path nor a missing-field
942    //    message.
943    let encoded = encode_pairs(decode_pairs);
944    let deserializer = serde_html_form::Deserializer::from_bytes(encoded.as_bytes());
945    if let Err(raw_err) = serde_path_to_error::deserialize::<_, C>(deserializer)
946        && let Some(field) = last_path_field(raw_err.path())
947    {
948        return field;
949    }
950
951    // 4. Row-level key.
952    String::new()
953}
954
955/// Whether a `_destroy` marker value counts as "destroy this row".
956/// The subfield name that marks a submitted child row for destruction.
957///
958/// Not a column: its value is only ever read for truthiness, so it is left out
959/// of the #2423 NUL sweep entirely (see `decode_nested_urlencoded`).
960const DESTROY_SUBFIELD: &str = "_destroy";
961
962fn is_truthy(value: &str) -> bool {
963    let trimmed = value.trim();
964    trimmed == "1" || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("on")
965}
966
967// ── NestedChangesetForm extractor ──────────────────────────────────
968
969/// Axum extractor that decodes a nested (`has_many`) form body, validates the
970/// parent and every non-destroyed child row, and captures the CSRF and
971/// submit-token context for re-rendering.
972///
973/// Mirrors [`ChangesetForm`](crate::form::ChangesetForm): errors live in the
974/// [`NestedChangeset`] rather than rejecting with 422; the handler decides how
975/// to respond. Only `application/x-www-form-urlencoded` bodies are accepted
976/// (multipart is a follow-up).
977pub struct NestedChangesetForm<P, C> {
978    /// The validated (or invalid) nested changeset.
979    pub changeset: NestedChangeset<P, C>,
980    csrf_token: Option<String>,
981    csrf_field: String,
982    submit_token: Option<String>,
983    submit_field: String,
984}
985
986impl<P, C: NestedChild> NestedChangesetForm<P, C> {
987    /// Build a blank nested-form context for the initial `new` (create) GET
988    /// render, before any submission.
989    ///
990    /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
991    /// wraps `parent` in a **non-validating** [`NestedChangeset::blank`] (no
992    /// child rows, no errors) so the initial page renders clean — no premature
993    /// "field is required" message or `aria-invalid="true"` before the user has
994    /// typed. For an **edit** render that must show existing children, use
995    /// [`seeded`](Self::seeded) instead (`blank` renders zero child rows).
996    /// Contrast the POST path (the [`NestedChangesetForm`] extractor /
997    /// [`decode_nested_urlencoded`]), which validates the submission and
998    /// re-renders inline errors.
999    ///
1000    /// `csrf_token` is the token from a `CsrfToken` extractor, or `None` when
1001    /// CSRF middleware is not active. The CSRF and submit-token field names
1002    /// default to `_csrf` / `_submit_token` (exactly what the extractor falls
1003    /// back to when the corresponding config extensions are absent); when the
1004    /// app customizes `security.csrf.form_field`, set it with
1005    /// [`with_csrf_field`](Self::with_csrf_field) so
1006    /// [`form_tag`](Self::form_tag) emits the right hidden field name.
1007    ///
1008    /// The submit token starts `None`, so a bare `blank(..).form_tag(..)` emits
1009    /// **no** submit-token hidden input and the first submission is not protected
1010    /// against double-submit ([`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1011    /// passes tokenless mutating requests through). Supply the minted token on the
1012    /// initial GET with [`with_submit_token`](Self::with_submit_token) (and
1013    /// [`with_submit_field`](Self::with_submit_field) if the field name is
1014    /// customized) so the **first** submit carries a token too:
1015    ///
1016    /// ```rust,ignore
1017    /// #[get("/orders/new")]
1018    /// async fn new_order(csrf: CsrfToken, submit: SubmitToken) -> Markup {
1019    ///     let form = NestedChangesetForm::<NewOrder, NewLineItem>::blank(
1020    ///         NewOrder::default(),
1021    ///         Some(csrf.token().to_owned()),
1022    ///     )
1023    ///     .with_submit_token(Some(submit.token().to_owned()));
1024    ///     form.form_tag("/orders", "post", /* … */)
1025    /// }
1026    /// ```
1027    #[must_use]
1028    pub fn blank(parent: P, csrf_token: Option<String>) -> Self {
1029        Self {
1030            changeset: NestedChangeset::blank(parent),
1031            csrf_token,
1032            csrf_field: "_csrf".to_owned(),
1033            submit_token: None,
1034            submit_field: "_submit_token".to_owned(),
1035        }
1036    }
1037
1038    /// Wrap a pre-built [`NestedChangeset`] (which may already carry validation
1039    /// errors) in a form for rendering, with no CSRF/submit token.
1040    ///
1041    /// Mirrors [`ChangesetForm::from_changeset`](crate::form::ChangesetForm::from_changeset):
1042    /// useful in tests and cases where a `NestedChangeset` was produced
1043    /// externally (e.g. via [`decode_nested_urlencoded`]) before constructing a
1044    /// form for re-render. The CSRF/submit-token field names default to
1045    /// `_csrf` / `_submit_token`; add a token with
1046    /// [`with_csrf_field`](Self::with_csrf_field) as needed.
1047    #[must_use]
1048    pub fn from_changeset(changeset: NestedChangeset<P, C>) -> Self {
1049        Self {
1050            changeset,
1051            csrf_token: None,
1052            csrf_field: "_csrf".to_owned(),
1053            submit_token: None,
1054            submit_field: "_submit_token".to_owned(),
1055        }
1056    }
1057
1058    /// Override the CSRF form-field name used by [`form_tag`](Self::form_tag).
1059    ///
1060    /// Mirrors
1061    /// [`ChangesetForm::with_csrf_field`](crate::form::ChangesetForm::with_csrf_field):
1062    /// call this on a [`blank`](Self::blank) GET-handler form when
1063    /// `security.csrf.form_field` is customized (e.g. `"authenticity_token"`).
1064    /// The extractor captures the configured name automatically on the POST
1065    /// path.
1066    #[must_use]
1067    pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
1068        self.csrf_field = field.into();
1069        self
1070    }
1071
1072    /// Supply the one-time submit token to a [`blank`](Self::blank) (or
1073    /// [`from_changeset`](Self::from_changeset)) GET-handler form so
1074    /// [`form_tag`](Self::form_tag) emits the hidden submit-token input on the
1075    /// **initial** render — protecting the very first submission against
1076    /// double-submit, not just later 422 re-renders.
1077    ///
1078    /// [`blank`](Self::blank) leaves this `None` (the initial page renders no
1079    /// submit-token field otherwise), and [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1080    /// passes tokenless mutating requests through unchanged — so without calling
1081    /// this the first create-form submit is unprotected. Source the token from a
1082    /// [`SubmitToken`](crate::security::SubmitToken) extractor on the GET handler.
1083    /// The extractor captures it automatically on the POST re-render path.
1084    ///
1085    /// When the app customizes `security.submit_token.field_name`, pair this with
1086    /// [`with_submit_field`](Self::with_submit_field) so the hidden input carries
1087    /// the right name.
1088    #[must_use]
1089    pub fn with_submit_token(mut self, token: Option<String>) -> Self {
1090        self.submit_token = token;
1091        self
1092    }
1093
1094    /// Override the submit-token form-field name used by
1095    /// [`form_tag`](Self::form_tag).
1096    ///
1097    /// Mirrors [`with_csrf_field`](Self::with_csrf_field): call this on a
1098    /// [`blank`](Self::blank) GET-handler form when
1099    /// `security.submit_token.field_name` is customized (the default is
1100    /// `_submit_token`). The extractor captures the configured name automatically
1101    /// on the POST path.
1102    #[must_use]
1103    pub fn with_submit_field(mut self, field: impl Into<String>) -> Self {
1104        self.submit_field = field.into();
1105        self
1106    }
1107
1108    /// The CSRF token captured from the request, if the CSRF middleware is active.
1109    #[must_use]
1110    pub fn csrf_token(&self) -> Option<&str> {
1111        self.csrf_token.as_deref()
1112    }
1113
1114    /// The CSRF form-field name (honours `security.csrf.form_field`).
1115    #[must_use]
1116    pub fn csrf_field(&self) -> &str {
1117        &self.csrf_field
1118    }
1119
1120    /// The one-time submit token captured from the request, if the
1121    /// submit-token middleware is active.
1122    #[must_use]
1123    pub fn submit_token(&self) -> Option<&str> {
1124        self.submit_token.as_deref()
1125    }
1126
1127    /// The submit-token form-field name (honours
1128    /// `security.submit_token.field_name`).
1129    #[must_use]
1130    pub fn submit_field(&self) -> &str {
1131        &self.submit_field
1132    }
1133
1134    /// Consume and return only the inner [`NestedChangeset`].
1135    pub fn into_changeset(self) -> NestedChangeset<P, C> {
1136        self.changeset
1137    }
1138
1139    /// Return `Ok((parent, children))` when valid, `Err(self)` when not.
1140    ///
1141    /// The `Err` branch retains the CSRF/submit context so the handler can
1142    /// immediately re-render the form with inline errors.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns `Err(self)` when the inner changeset has validation errors.
1147    #[allow(
1148        clippy::result_large_err,
1149        reason = "the Err variant intentionally returns the whole form (changeset + CSRF/submit \
1150                  context) so the handler can re-render inline with errors"
1151    )]
1152    pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
1153        let Self {
1154            changeset,
1155            csrf_token,
1156            csrf_field,
1157            submit_token,
1158            submit_field,
1159        } = self;
1160        match changeset.into_valid() {
1161            Ok(pair) => Ok(pair),
1162            Err(changeset) => Err(Self {
1163                changeset,
1164                csrf_token,
1165                csrf_field,
1166                submit_token,
1167                submit_field,
1168            }),
1169        }
1170    }
1171}
1172
1173/// Edit-render seeding for the form context — mirrors
1174/// [`NestedChangeset::seeded`], carrying the extra [`serde::Serialize`] bound on
1175/// this `impl` block alone.
1176impl<P, C: NestedChild + serde::Serialize> NestedChangesetForm<P, C> {
1177    /// Build a nested-form context for the initial `edit` GET render,
1178    /// pre-populated with the existing persisted `children`.
1179    ///
1180    /// Mirrors [`blank`](Self::blank)'s CSRF handling (the CSRF/submit-token
1181    /// field names default to `_csrf` / `_submit_token`, and the submit token
1182    /// starts `None`), but wraps [`NestedChangeset::seeded`] instead of
1183    /// [`NestedChangeset::blank`]: the changeset pre-renders one row per existing
1184    /// child so the edit page shows and preserves current line items — with their
1185    /// `id`s carried as hidden inputs (via [`RowScope::hidden_input`]) — and the
1186    /// no-JS `_destroy` removal of an existing child works before the first
1187    /// submit.
1188    ///
1189    /// The same builder methods used with [`blank`](Self::blank) apply here:
1190    /// [`with_csrf_field`](Self::with_csrf_field) for a customized
1191    /// `security.csrf.form_field`, and [`with_submit_token`](Self::with_submit_token)
1192    /// / [`with_submit_field`](Self::with_submit_field) to emit the one-time
1193    /// submit-token hidden input on the initial edit render. `csrf_token` is the
1194    /// token from a `CsrfToken` extractor, or `None` when CSRF middleware is not
1195    /// active.
1196    #[must_use]
1197    pub fn seeded(parent: P, children: Vec<C>, csrf_token: Option<String>) -> Self {
1198        Self {
1199            changeset: NestedChangeset::seeded(parent, children),
1200            csrf_token,
1201            csrf_field: "_csrf".to_owned(),
1202            submit_token: None,
1203            submit_field: "_submit_token".to_owned(),
1204        }
1205    }
1206}
1207
1208/// Maud rendering — emit the `<form>` open tag with the **captured** CSRF (and
1209/// submit-token) hidden fields injected.
1210#[cfg(feature = "maud")]
1211impl<P, C: NestedChild> NestedChangesetForm<P, C> {
1212    /// Render a `<form>` element wrapping `content`, injecting the CSRF hidden
1213    /// input under the **captured** field name (honouring
1214    /// `security.csrf.form_field`) and — when present — the one-time
1215    /// submit-token hidden input under its captured field name.
1216    ///
1217    /// Mirrors [`ChangesetForm::form_tag`](crate::form::ChangesetForm::form_tag),
1218    /// including the `PUT`/`PATCH`/`DELETE` → hidden `_method` override. **Prefer
1219    /// this over the standalone [`crate::form::form_tag`]** for a nested-form
1220    /// re-render: the standalone helper hardcodes the default `_csrf` field name
1221    /// and would emit the wrong hidden field for an app that customized
1222    /// `security.csrf.form_field`, so the next submit's CSRF check would reject
1223    /// the form. This method uses the field name the extractor captured (or the
1224    /// one set via [`with_csrf_field`](Self::with_csrf_field) on a
1225    /// [`blank`](Self::blank) form), keeping CSRF parity across the re-render.
1226    ///
1227    /// The submit-token hidden input is emitted only when a submit token is
1228    /// present. The POST re-render path captures it automatically; on the initial
1229    /// GET render from [`blank`](Self::blank), supply it with
1230    /// [`with_submit_token`](Self::with_submit_token) so the **first** submit is
1231    /// protected against double-submit too — otherwise a bare
1232    /// `blank(..).form_tag(..)` create form carries no submit token and its first
1233    /// submission passes through [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1234    /// unprotected.
1235    #[must_use]
1236    #[allow(clippy::needless_pass_by_value)]
1237    pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
1238        crate::form::form_tag_inner(
1239            action,
1240            method,
1241            &self.csrf_field,
1242            self.csrf_token.as_deref(),
1243            None,
1244            maud::html! {
1245                @if let Some(token) = self.submit_token.as_deref() {
1246                    input type="hidden" name=(self.submit_field) value=(token);
1247                }
1248                (content)
1249            },
1250        )
1251    }
1252}
1253
1254/// Dereferences to [`NestedChangeset<P, C>`] so all changeset methods are
1255/// available directly on the form (`form.is_valid()`, `form.errors_for(…)`,
1256/// `form.rows()`, …).
1257impl<P, C> std::ops::Deref for NestedChangesetForm<P, C> {
1258    type Target = NestedChangeset<P, C>;
1259    fn deref(&self) -> &Self::Target {
1260        &self.changeset
1261    }
1262}
1263
1264impl<S, P, C> FromRequest<S> for NestedChangesetForm<P, C>
1265where
1266    S: Send + Sync,
1267    P: serde::de::DeserializeOwned + validator::Validate + Send,
1268    C: NestedChild,
1269{
1270    type Rejection = axum::response::Response;
1271
1272    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
1273        // Capture CSRF + submit-token context from request extensions exactly
1274        // as `ChangesetForm` does, before the body is consumed.
1275        let csrf_token = req
1276            .extensions()
1277            .get::<crate::security::CsrfToken>()
1278            .map(|t| t.token().to_string());
1279        let csrf_field = req
1280            .extensions()
1281            .get::<crate::security::csrf::CsrfFormField>()
1282            .map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
1283        let submit_token = req
1284            .extensions()
1285            .get::<crate::security::SubmitToken>()
1286            .map(|t| t.token().to_string());
1287        let submit_field = req
1288            .extensions()
1289            .get::<crate::security::SubmitFormField>()
1290            .map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
1291
1292        // Same content-type gate axum's own form extractors apply. Multipart
1293        // is out of scope for now (follow-up).
1294        let (req, nul_message) = crate::form::nul_field_message(req, state).await;
1295        let content_type = req
1296            .headers()
1297            .get(http::header::CONTENT_TYPE)
1298            .and_then(|v| v.to_str().ok())
1299            .unwrap_or_default()
1300            .to_string();
1301        if !crate::media_type::is_media_type(&content_type, "application/x-www-form-urlencoded") {
1302            return Err((
1303                axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1304                "Nested form requests must have `Content-Type: application/x-www-form-urlencoded`",
1305            )
1306                .into_response());
1307        }
1308
1309        // Buffer through axum's `Bytes` extractor so `DefaultBodyLimit` is
1310        // enforced (a bare `to_bytes(.., usize::MAX)` would defeat it).
1311        let (parts, body) = req.into_parts();
1312        let bytes_req = Request::from_parts(parts, body);
1313        let bytes = axum::body::Bytes::from_request(bytes_req, state)
1314            .await
1315            .map_err(IntoResponse::into_response)?;
1316
1317        let mut pairs: Vec<(String, String)> = url::form_urlencoded::parse(&bytes)
1318            .map(|(k, v)| (k.into_owned(), v.into_owned()))
1319            .collect();
1320
1321        // #2423: the CSRF token, the submit token and the method override are
1322        // transport plumbing, not form fields — no template renders them, so an
1323        // error keyed under one would make the form permanently invalid with
1324        // nothing on screen to explain why. Clean them here so
1325        // `decode_nested_urlencoded`'s sweep, which knows nothing about the two
1326        // configurable names, never sees a NUL in one. Each is still rejected on
1327        // its own terms when malformed.
1328        for (key, value) in &mut pairs {
1329            if *key == csrf_field || *key == submit_field || crate::form::is_plumbing_field(key) {
1330                *value = crate::normalize::strip_nul(value);
1331            }
1332        }
1333
1334        let changeset = decode_nested_with_nul_message::<P, C>(&pairs, &nul_message)
1335            .map_err(|e| (e.status(), e.to_string()).into_response())?;
1336
1337        Ok(Self {
1338            changeset,
1339            csrf_token,
1340            csrf_field,
1341            submit_token,
1342            submit_field,
1343        })
1344    }
1345}
1346
1347// ── Maud view helpers ──────────────────────────────────────────────
1348
1349/// A single row's rendering scope inside [`inputs_for`].
1350///
1351/// Each scope binds the child collection name, the row's 0-based `index`, and
1352/// (for existing/submitted rows) the underlying [`NestedRow`] so its raw values
1353/// pre-fill inputs and its per-subfield errors render inline on re-render after
1354/// a failed submission. A blank template row carries `row: None` — its inputs
1355/// render empty with no error blocks.
1356///
1357/// The row-scoped input builders mirror the standalone field helpers in
1358/// [`crate::form`] (`text_input`, `number_input`, …) but emit **nested** input
1359/// names (`items[{index}][{sub}]`) and per-row-unique element ids
1360/// (`items-{index}-{sub}`) so ids and `aria-describedby` links stay unique
1361/// across repeated rows.
1362#[cfg(feature = "maud")]
1363pub struct RowScope<'a> {
1364    collection: &'a str,
1365    index: usize,
1366    row: Option<&'a NestedRow>,
1367}
1368
1369#[cfg(feature = "maud")]
1370impl RowScope<'_> {
1371    /// This row's 0-based position in the collection.
1372    #[must_use]
1373    pub const fn index(&self) -> usize {
1374        self.index
1375    }
1376
1377    /// The nested input `name` for subfield `sub`, i.e.
1378    /// `"{collection}[{index}][{sub}]"`.
1379    #[must_use]
1380    pub fn field_name(&self, sub: &str) -> String {
1381        format!("{}[{}][{}]", self.collection, self.index, sub)
1382    }
1383
1384    /// The raw submitted value for subfield `sub`, or `None` for a blank row.
1385    #[must_use]
1386    pub fn value(&self, sub: &str) -> Option<&str> {
1387        self.row.and_then(|r| r.value(sub))
1388    }
1389
1390    /// Every raw submitted value for subfield `sub`, or an empty slice (always
1391    /// empty for a blank row). See [`NestedRow::values`].
1392    #[must_use]
1393    pub fn values(&self, sub: &str) -> &[String] {
1394        self.row.map_or(&[], |r| r.values(sub))
1395    }
1396
1397    /// Validation messages for subfield `sub`, or an empty slice (always empty
1398    /// for a blank row).
1399    #[must_use]
1400    pub fn errors_for(&self, sub: &str) -> &[String] {
1401        self.row.map_or(&[], |r| r.errors_for(sub))
1402    }
1403
1404    /// `true` when this (existing) row carried a truthy `_destroy` marker.
1405    #[must_use]
1406    pub fn is_destroyed(&self) -> bool {
1407        self.row.is_some_and(NestedRow::is_destroyed)
1408    }
1409
1410    /// Per-row-unique element id base for subfield `sub`
1411    /// (`"{collection}-{index}-{sub}"`), used for `id` / `aria-describedby`
1412    /// linkage so repeated rows never collide.
1413    fn element_id(&self, sub: &str) -> String {
1414        format!("{}-{}-{}", self.collection, self.index, sub)
1415    }
1416
1417    /// Render a labeled row-scoped `<input type="text">` for subfield `sub`.
1418    ///
1419    /// Mirrors [`crate::form::text_input`]: `autumn-field` wrapper, per-row
1420    /// pre-fill, `aria-invalid` / `aria-describedby`, and a `role="alert"`
1421    /// error block — but with the nested `name` and a per-row-unique `id`.
1422    #[must_use]
1423    pub fn text_input(&self, sub: &str, label: &str) -> maud::Markup {
1424        self.text_input_with(sub, label, &FieldOptions::new())
1425    }
1426
1427    /// Like [`RowScope::text_input`] but adds `required` + `aria-required="true"`
1428    /// — **only for real/submitted rows that are not marked for destruction**
1429    /// (`self.row.is_some() && !self.is_destroyed()`).
1430    ///
1431    /// A blank template row (`row: None`, the one [`inputs_for`] auto-appends
1432    /// for the no-JS "add a child" path) deliberately renders the *same* input
1433    /// **without** the client-side `required`/`aria-required` attributes so it
1434    /// stays leaveable-empty. Otherwise the browser's native constraint
1435    /// validation would block form submit on the empty trailing template row
1436    /// *before* the server's all-blank rejection can run — a user who filled one
1437    /// child row could not submit while a blank row sat beneath it. A submitted
1438    /// row the user ticked `_destroy` on is skipped for the same reason: the
1439    /// decoder drops destroyed rows before validation (and from `into_valid()`),
1440    /// so re-emitting client-side `required` on an empty destroyed field would
1441    /// let the browser block the next submit before the server can honor the
1442    /// removal. Required-ness is still enforced server-side for any row the user
1443    /// actually engages: a partially-filled, non-destroyed row is retained as a
1444    /// `Some` row on re-render and re-acquires `required`, and the decoder's
1445    /// all-blank rejection drops the untouched template row. This composes with
1446    /// that all-blank rejection so
1447    /// neither the client nor the server treats the template row as a real child.
1448    #[must_use]
1449    pub fn required_text_input(&self, sub: &str, label: &str) -> maud::Markup {
1450        self.text_input_with(sub, label, &FieldOptions::new().required())
1451    }
1452
1453    /// [`RowScope::text_input`] with a hint and typed attributes. See
1454    /// [`FieldOptions`]. A required option follows the template-row rule of
1455    /// [`RowScope::required_text_input`].
1456    #[must_use]
1457    pub fn text_input_with(
1458        &self,
1459        sub: &str,
1460        label: &str,
1461        options: &FieldOptions<'_>,
1462    ) -> maud::Markup {
1463        self.row_field(FieldKind::Text, sub, label, options)
1464    }
1465
1466    /// Render a labeled row-scoped `<input type="number">` for subfield `sub`.
1467    ///
1468    /// Mirrors [`crate::form::number_input`] (leaving the browser-default
1469    /// `step`), with the nested `name` and a per-row-unique `id`.
1470    #[must_use]
1471    pub fn number_input(&self, sub: &str, label: &str) -> maud::Markup {
1472        self.number_input_with(sub, label, &FieldOptions::new())
1473    }
1474
1475    /// [`RowScope::number_input`] for a required subfield. See
1476    /// [`RowScope::required_text_input`] for the template-row rule.
1477    #[must_use]
1478    pub fn required_number_input(&self, sub: &str, label: &str) -> maud::Markup {
1479        self.number_input_with(sub, label, &FieldOptions::new().required())
1480    }
1481
1482    /// [`RowScope::number_input`] with a hint and typed attributes. See
1483    /// [`FieldOptions`].
1484    #[must_use]
1485    pub fn number_input_with(
1486        &self,
1487        sub: &str,
1488        label: &str,
1489        options: &FieldOptions<'_>,
1490    ) -> maud::Markup {
1491        self.row_field(FieldKind::Number, sub, label, options)
1492    }
1493
1494    /// Render a labeled row-scoped `<textarea>` for subfield `sub`.
1495    ///
1496    /// Mirrors [`crate::form::textarea_input`]: the value is emitted as the
1497    /// element body, with the nested `name` and a per-row-unique `id`.
1498    #[must_use]
1499    pub fn textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
1500        self.textarea_input_with(sub, label, &FieldOptions::new())
1501    }
1502
1503    /// [`RowScope::textarea_input`] for a required subfield. See
1504    /// [`RowScope::required_text_input`] for the template-row rule.
1505    #[must_use]
1506    pub fn required_textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
1507        self.textarea_input_with(sub, label, &FieldOptions::new().required())
1508    }
1509
1510    /// [`RowScope::textarea_input`] with a hint and typed attributes. See
1511    /// [`FieldOptions`].
1512    #[must_use]
1513    pub fn textarea_input_with(
1514        &self,
1515        sub: &str,
1516        label: &str,
1517        options: &FieldOptions<'_>,
1518    ) -> maud::Markup {
1519        self.row_field(FieldKind::Textarea, sub, label, options)
1520    }
1521
1522    /// Shared body of the row-scoped field helpers.
1523    fn row_field(
1524        &self,
1525        kind: FieldKind,
1526        sub: &str,
1527        label: &str,
1528        options: &FieldOptions<'_>,
1529    ) -> maud::Markup {
1530        // Emit the client-side `required`/`aria-required` constraint only for a
1531        // real/submitted row that is *not* marked for destruction. A blank
1532        // template row (`row: None`) must stay leaveable-empty so the browser
1533        // does not block submit on the trailing auto-appended blank row before
1534        // the server's all-blank rejection runs; server-side validation still
1535        // enforces required-ness for any row the user actually fills (it is
1536        // retained as a `Some` row on re-render). A submitted row the user
1537        // ticked `_destroy` on is likewise skipped: the decoder drops destroyed
1538        // rows before validation, so re-emitting client-side `required` on an
1539        // empty destroyed field would let the browser's constraint validation
1540        // block the next submit before the server can honor `_destroy`.
1541        let unmasked = self.row.is_some() && !self.is_destroyed();
1542        let masked;
1543        let options = if options.is_required() && !unmasked {
1544            masked = options.clone().optional();
1545            &masked
1546        } else {
1547            options
1548        };
1549        let name = self.field_name(sub);
1550        let id = self.element_id(sub);
1551        let spec = FieldSpec {
1552            name: Some(&name),
1553            value: Some(self.value(sub).unwrap_or_default()),
1554            ..FieldSpec::new(kind, &id, label)
1555        };
1556        render_field(&spec, self.errors_for(sub), options)
1557    }
1558
1559    /// Render a row-scoped `<input type="hidden">` for subfield `sub`.
1560    ///
1561    /// Use this to carry an existing child's primary key (e.g. `id`) on an edit
1562    /// form so the decoder can match the submitted row back to a persisted record.
1563    #[must_use]
1564    pub fn hidden_input(&self, sub: &str, value: &str) -> maud::Markup {
1565        let name = self.field_name(sub);
1566        maud::html! {
1567            input type="hidden" name=(name) value=(value);
1568        }
1569    }
1570
1571    /// Render the durable no-JS removal control: a `_destroy` checkbox whose
1572    /// checked state is preserved on re-render (`checked` iff
1573    /// [`RowScope::is_destroyed`]).
1574    ///
1575    /// The decoder honours a truthy `_destroy` marker
1576    /// ([`decode_nested_urlencoded`]), so ticking this box and submitting the
1577    /// surrounding form removes the row with no JavaScript. htmx/JS row removal
1578    /// (swapping the `.nested-fields__row` node's `outerHTML`) is an optional
1579    /// progressive enhancement layered on top; this checkbox is the required
1580    /// mechanism.
1581    #[must_use]
1582    pub fn destroy_checkbox(&self, label: &str) -> maud::Markup {
1583        let checked = self.is_destroyed();
1584        let name = self.field_name("_destroy");
1585        let id = self.element_id("_destroy");
1586        maud::html! {
1587            div class="autumn-field autumn-field--destroy" {
1588                input
1589                    type="checkbox"
1590                    id=(id)
1591                    name=(name)
1592                    value="1"
1593                    checked[checked]
1594                    class="autumn-field__checkbox";
1595                label for=(id) class="autumn-field__label" { (label) }
1596            }
1597        }
1598    }
1599}
1600
1601/// Options for [`inputs_for`].
1602#[cfg(feature = "maud")]
1603pub struct InputsForOptions {
1604    /// Number of extra blank rows to pre-render after the existing rows. The
1605    /// no-JS fallback lets users fill and submit these without any JavaScript.
1606    /// Defaults to `1`; [`inputs_for`] always emits **at least one** blank
1607    /// template row even when this is `0`.
1608    pub blank_rows: usize,
1609    /// Optional htmx URL for the server "Add row" fragment endpoint (see
1610    /// [`nested_row_fragment`]). When `None`, no Add button is rendered and the
1611    /// no-JS path still works via the pre-rendered blank rows.
1612    pub add_row_url: Option<String>,
1613    /// Container element id. Defaults to `"{collection}-rows"`.
1614    ///
1615    /// This id is load-bearing when [`add_row_url`](Self::add_row_url) is set:
1616    /// the Add-row button's `hx-target` and its `hx-vals` index scan both select
1617    /// `#{container_id}`. HTML ids must be unique in a document, so **when two or
1618    /// more nested forms for the same child collection appear on one page, each
1619    /// MUST pass a distinct `container_id`.** The `{collection}-rows` default is
1620    /// correct for a single such form per page; leaving two same-collection forms
1621    /// on the default id produces duplicate ids (invalid HTML) and makes each
1622    /// Add-row button resolve against — and append rows / compute indices from —
1623    /// whichever container the shared id happens to match first, i.e. the wrong
1624    /// form. Distinct ids are the only requirement; any stable, unique string
1625    /// (e.g. `"shipping-items-rows"` vs `"billing-items-rows"`) works.
1626    pub container_id: Option<String>,
1627}
1628
1629#[cfg(feature = "maud")]
1630impl Default for InputsForOptions {
1631    fn default() -> Self {
1632        Self {
1633            blank_rows: 1,
1634            add_row_url: None,
1635            container_id: None,
1636        }
1637    }
1638}
1639
1640/// Render the repeating child field-group block for a nested (`has_many`) form.
1641///
1642/// Wraps the rows in `<div id="{container_id}" class="nested-fields">`. For each
1643/// existing/submitted row (from [`NestedChangeset::rows`], in order — **including**
1644/// rows re-submitted after a validation failure) it invokes `render_row` with a
1645/// [`RowScope`] carrying that row, so values and per-field errors pre-fill on
1646/// re-render. It then appends [`InputsForOptions::blank_rows`] blank rows (always
1647/// at least one) whose scopes carry `row: None`. Every row is wrapped in
1648/// `<div class="nested-fields__row" data-index="{i}">` so htmx/JS removal can
1649/// target the node's `outerHTML`.
1650///
1651/// The appended blank template row makes adding a child work with **no
1652/// JavaScript**, and it is safe even though the browser submits its empty
1653/// inputs: [`decode_nested_urlencoded`] applies Rails-style
1654/// `reject_if: :all_blank`, so a child row whose every non-`_destroy` subfield
1655/// is blank is ignored rather than decoded as a phantom child. An untouched
1656/// blank row therefore never blocks submission or persists an empty child.
1657///
1658/// So the no-JS path stays submittable, the appended blank template row's inputs
1659/// omit the client-side `required`/`aria-required` constraint by design (see
1660/// [`RowScope::required_text_input`]): a required-variant input renders empty and
1661/// *leaveable* on a `row: None` template row, so the browser's native validation
1662/// does not block submitting a form that still carries a trailing blank row.
1663/// This composes with the decoder's all-blank rejection — the untouched template
1664/// row is dropped server-side — while server-side validation still enforces
1665/// required-ness for any row the user actually fills.
1666///
1667/// When [`InputsForOptions::add_row_url`] is `Some`, an "Add row"
1668/// `<button type="button">` is emitted with `hx-get`, `hx-target="#{container_id}"`,
1669/// `hx-swap="beforeend"`, and — critically — `hx-params="index"` so **only**
1670/// the `index` param (supplied by the `hx-vals` below) is serialized into the
1671/// Add-row GET; every parent + line-item field, and `_csrf`/submit token, is
1672/// dropped, avoiding leaks into the request query string / proxy logs. `"none"`
1673/// can NOT be used: the vendored htmx 2.0.4 merges `hx-vals` into the request
1674/// `FormData` *before* the `hx-params` filter runs, so `"none"` (an empty
1675/// `FormData`) would strip the computed `index` too — naming `index` keeps it.
1676///
1677/// The button also carries an `hx-vals` (`js:` form) that computes a fresh
1678/// `index` for each request — `max(existing data-index) + 1` scanning only
1679/// `#{container_id} .nested-fields__row[data-index]` (scoped by the container
1680/// id so a form reads its own rows). The endpoint should read that `index`
1681/// param and pass it to [`nested_row_fragment`] so every appended row gets a
1682/// name unique within the form; a static request would reuse the same index and
1683/// emit duplicate control names. This is the JS-present enhancement path only —
1684/// the no-JS fallback (pre-rendered blank rows) needs no such counter and is
1685/// unaffected.
1686///
1687/// # Multiple same-collection forms on one page
1688///
1689/// Both the Add-row `hx-target` and its `hx-vals` index scan address the
1690/// container by its `#{container_id}`. That scoping is only distinct if the ids
1691/// are distinct, so **every same-collection nested form that also sets
1692/// `add_row_url` must be given its own [`InputsForOptions::container_id`]** when
1693/// more than one appears on a page. The default `{collection}-rows` id is right
1694/// for a single such form; two forms sharing it emit duplicate HTML ids and each
1695/// Add-row button then targets / scans whichever container the shared id resolves
1696/// to first (the wrong form). A relative, id-free `hx-vals` scan was evaluated
1697/// and rejected: the vendored htmx 2.0.4 evaluates a `js:` `hx-vals` expression
1698/// with `Function("return (" + expr + ")")()` — no receiver — so `this` is the
1699/// global object, not the triggering button, leaving no reliable handle to walk
1700/// to the button's own container. The unique-id contract above is therefore the
1701/// supported way to keep several same-collection forms independent.
1702///
1703/// # CSRF
1704///
1705/// This renders only the child block. The surrounding `<form>` — via
1706/// [`crate::form::form_tag`] / `ChangesetForm` — carries the CSRF and submit-token
1707/// fields exactly as today; do **not** duplicate them here.
1708///
1709/// # Example
1710///
1711/// ```rust,ignore
1712/// use autumn_web::form::{required_text_input, submit_button};
1713/// use autumn_web::nested_form::{inputs_for, InputsForOptions};
1714///
1715/// // `form` is a `NestedChangesetForm<NewOrder, NewLineItem>` re-rendered after
1716/// // a failed submit; it derefs to its inner `NestedChangeset`.
1717/// let opts = InputsForOptions {
1718///     add_row_url: Some("/orders/line-item-row".into()),
1719///     ..InputsForOptions::default()
1720/// };
1721/// // Prefer `form.form_tag` over the standalone `form_tag`: it emits the CSRF
1722/// // hidden field under the app-configured field name (and the submit token),
1723/// // so a customized `security.csrf.form_field` survives the re-render.
1724/// form.form_tag("/orders", "POST", maud::html! {
1725///     // Parent fields (CSRF + submit token are emitted by `form.form_tag`).
1726///     (required_text_input(&form.parent, "name", "Order name"))
1727///     // Repeating child rows.
1728///     (inputs_for(&form, &opts, |row| maud::html! {
1729///         (row.required_text_input("sku", "SKU"))
1730///         (row.number_input("quantity", "Quantity"))
1731///         (row.destroy_checkbox("Remove"))
1732///     }))
1733///     (submit_button("Create order"))
1734/// });
1735/// ```
1736#[cfg(feature = "maud")]
1737#[must_use]
1738pub fn inputs_for<P, C: NestedChild>(
1739    nested: &NestedChangeset<P, C>,
1740    opts: &InputsForOptions,
1741    render_row: impl Fn(&RowScope) -> maud::Markup,
1742) -> maud::Markup {
1743    let collection = C::COLLECTION;
1744    let container_id = opts
1745        .container_id
1746        .clone()
1747        .unwrap_or_else(|| format!("{collection}-rows"));
1748    let rows = nested.rows();
1749    // Always emit at least one blank template row so the no-JS path can add a
1750    // child even when the caller asked for zero.
1751    let blank_count = opts.blank_rows.max(1);
1752
1753    maud::html! {
1754        div id=(container_id) class="nested-fields" {
1755            @for (i, row) in rows.iter().enumerate() {
1756                @let scope = RowScope { collection, index: i, row: Some(row) };
1757                div class="nested-fields__row" data-index=(i) {
1758                    (render_row(&scope))
1759                }
1760            }
1761            @for k in 0..blank_count {
1762                @let index = rows.len().saturating_add(k);
1763                @let scope = RowScope { collection, index, row: None };
1764                div class="nested-fields__row" data-index=(index) {
1765                    (render_row(&scope))
1766                }
1767            }
1768            @if let Some(url) = &opts.add_row_url {
1769                // Send a *fresh* unique row index with every Add-row request.
1770                // `nested_row_fragment` requires the returned row's index to be
1771                // unique within this form; a static request would keep reusing
1772                // the same index and produce duplicate control names. Compute
1773                // `max(existing data-index) + 1` from this container's rows,
1774                // scoped by `#{container_id}`. That scoping is only distinct when
1775                // the id is distinct: callers rendering more than one
1776                // same-collection nested form on a page MUST give each a unique
1777                // `container_id` (see `InputsForOptions::container_id`), because
1778                // the vendored htmx 2.0.4 evaluates this `js:` `hx-vals` with no
1779                // `this`/`event` bound to the button, so an id-free relative walk
1780                // to the button's own container is not available. The decoder
1781                // tolerates gaps, so this stays collision-free even after
1782                // client-side row removals.
1783                //
1784                // `hx-params="index"` serializes ONLY the `index` param with
1785                // the Add-row GET: per htmx semantics a bare comma-list keeps
1786                // just the named params, so every parent + line-item field
1787                // (and `_csrf`/submit token) is dropped from the request query
1788                // string / proxy logs, avoiding leaks and URL-length limits.
1789                // We can NOT use `"none"`: in the vendored htmx 2.0.4 the
1790                // `hx-vals` values are merged into the request FormData BEFORE
1791                // the `hx-params` filter runs (see `htmx.min.js`: the request
1792                // builder does `const v=ln(j,V);let w=hn(v,r)` where `V` is the
1793                // resolved `hx-vals` and `hn` is `filterValues`), so `"none"`
1794                // (which returns an empty FormData) would strip the `index` we
1795                // compute here too. Naming `index` keeps exactly that one value.
1796                @let hx_vals = format!(
1797                    "js:{{\"index\": Math.max(-1, ...Array.from(document.querySelectorAll(\"#{container_id} .nested-fields__row[data-index]\")).map(function(e){{return parseInt(e.dataset.index,10);}})) + 1}}"
1798                );
1799                button
1800                    type="button"
1801                    class="nested-fields__add"
1802                    hx-get=(url)
1803                    hx-target=(format!("#{container_id}"))
1804                    hx-swap="beforeend"
1805                    hx-params="index"
1806                    hx-vals=(hx_vals)
1807                { "Add row" }
1808            }
1809        }
1810    }
1811}
1812
1813/// Render a single blank child row for the htmx "Add row" fragment endpoint.
1814///
1815/// Returns one `<div class="nested-fields__row" data-index="{index}">` produced
1816/// by `render_row` with a blank [`RowScope`]. Because the decoder tolerates
1817/// non-contiguous indices, `index` only needs to be **unique** within the form
1818/// (e.g. a monotonically increasing counter the client tracks), not contiguous.
1819///
1820/// The [`inputs_for`] Add-row button sends this unique value as an `index`
1821/// param (via `hx-vals`, computed as `max(existing data-index) + 1`). Because
1822/// the button sets `hx-params="index"`, the Add-row request carries ONLY the
1823/// `hx-vals` `index` — no parent/line-item form fields and no CSRF/submit
1824/// token are serialized — and `index` is the only value this endpoint needs.
1825/// The endpoint should read that param from the query/form and pass it straight
1826/// to `nested_row_fragment(index, ..)`. Uniqueness — not contiguity — is the
1827/// contract: the decoder tolerates gaps, so indices left behind by client-side
1828/// row removals never cause collisions.
1829#[cfg(feature = "maud")]
1830#[must_use]
1831pub fn nested_row_fragment<C: NestedChild>(
1832    index: usize,
1833    render_row: impl Fn(&RowScope) -> maud::Markup,
1834) -> maud::Markup {
1835    let scope = RowScope {
1836        collection: C::COLLECTION,
1837        index,
1838        row: None,
1839    };
1840    maud::html! {
1841        div class="nested-fields__row" data-index=(index) {
1842            (render_row(&scope))
1843        }
1844    }
1845}
1846
1847// ── Tests ──────────────────────────────────────────────────────────
1848
1849#[cfg(test)]
1850mod tests {
1851    use super::*;
1852    use crate::form::NUL_CHARACTER_FIELD_ERROR;
1853
1854    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1855    struct Order {
1856        #[validate(length(min = 1, message = "name required"))]
1857        name: String,
1858    }
1859
1860    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1861    struct LineItem {
1862        #[validate(length(min = 1, message = "sku required"))]
1863        sku: String,
1864        #[validate(range(min = 1, message = "quantity must be >= 1"))]
1865        quantity: i32,
1866    }
1867
1868    impl NestedChild for LineItem {
1869        const COLLECTION: &'static str = "items";
1870    }
1871
1872    /// A child with one **required** field (`name`) and one **optional** field
1873    /// (`nickname`), used to exercise the reject-all-blank rules.
1874    #[derive(serde::Deserialize, validator::Validate)]
1875    struct Contact {
1876        #[validate(length(min = 1, message = "name required"))]
1877        name: String,
1878        #[validate(length(min = 1, message = "nickname too short"))]
1879        nickname: Option<String>,
1880    }
1881
1882    impl NestedChild for Contact {
1883        const COLLECTION: &'static str = "contacts";
1884    }
1885
1886    async fn nested_ok(_f: NestedChangesetForm<Order, LineItem>) -> &'static str {
1887        "ok"
1888    }
1889
1890    #[tokio::test]
1891    async fn mixed_case_urlencoded_content_type_is_accepted() {
1892        use tower::ServiceExt;
1893        let req = Request::builder()
1894            .method("POST")
1895            .uri("/t")
1896            .header("Content-Type", "Application/X-WWW-Form-Urlencoded")
1897            .body(axum::body::Body::from("name=Order"))
1898            .unwrap();
1899        let resp = axum::Router::new()
1900            .route("/t", axum::routing::post(nested_ok))
1901            .oneshot(req)
1902            .await
1903            .unwrap();
1904        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1905    }
1906
1907    #[tokio::test]
1908    async fn wrong_content_type_is_rejected_with_415() {
1909        use tower::ServiceExt;
1910        let req = Request::builder()
1911            .method("POST")
1912            .uri("/t")
1913            .header("Content-Type", "text/plain")
1914            .body(axum::body::Body::from("name=Order"))
1915            .unwrap();
1916        let resp = axum::Router::new()
1917            .route("/t", axum::routing::post(nested_ok))
1918            .oneshot(req)
1919            .await
1920            .unwrap();
1921        assert_eq!(
1922            resp.status(),
1923            axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE
1924        );
1925    }
1926
1927    fn p(k: &str, v: &str) -> (String, String) {
1928        (k.to_owned(), v.to_owned())
1929    }
1930
1931    // ── #3394: repeated keys on the nested path ───────────────────────
1932
1933    #[derive(serde::Deserialize, validator::Validate)]
1934    struct TaggedOrder {
1935        #[serde(default)]
1936        tags: Vec<String>,
1937    }
1938
1939    #[test]
1940    fn repeated_parent_key_decodes_into_a_vec_field() {
1941        let pairs = vec![
1942            p("tags", "a"),
1943            p("tags", "b"),
1944            p("items[0][sku]", "A-1"),
1945            p("items[0][quantity]", "2"),
1946        ];
1947        let cs = decode_nested_urlencoded::<TaggedOrder, LineItem>(&pairs).expect("parent decodes");
1948        assert_eq!(cs.parent.data().tags, ["a", "b"]);
1949        assert!(cs.is_valid());
1950    }
1951
1952    #[test]
1953    fn repeated_child_scalar_is_a_row_error_on_that_subfield() {
1954        let pairs = vec![
1955            p("name", "Order 1"),
1956            p("items[0][sku]", "A-1"),
1957            p("items[0][sku]", "B-2"),
1958            p("items[0][quantity]", "2"),
1959        ];
1960        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1961        assert!(!cs.is_valid());
1962        let errors = cs.rows()[0].errors_for("sku");
1963        assert_eq!(errors.len(), 1, "{errors:?}");
1964        assert!(errors[0].contains("`sku`"), "{errors:?}");
1965    }
1966
1967    #[tokio::test]
1968    async fn repeated_parent_scalar_is_a_422_through_the_extractor() {
1969        use tower::ServiceExt;
1970        let req = Request::builder()
1971            .method("POST")
1972            .uri("/t")
1973            .header("Content-Type", "application/x-www-form-urlencoded")
1974            .body(axum::body::Body::from("name=a&name=b"))
1975            .unwrap();
1976        let resp = axum::Router::new()
1977            .route("/t", axum::routing::post(nested_ok))
1978            .oneshot(req)
1979            .await
1980            .unwrap();
1981        assert_eq!(resp.status(), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
1982    }
1983
1984    #[test]
1985    fn repeated_parent_scalar_fails_the_decode() {
1986        let pairs = vec![p("name", "a"), p("name", "b")];
1987        let err = decode_nested_urlencoded::<Order, LineItem>(&pairs)
1988            .err()
1989            .expect("a repeated parent scalar must not decode");
1990        assert!(err.contains("`name`"), "{err}");
1991    }
1992
1993    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1994    struct TaggedItem {
1995        #[validate(length(min = 1, message = "sku required"))]
1996        sku: String,
1997        #[serde(default)]
1998        tags: Vec<String>,
1999    }
2000
2001    impl NestedChild for TaggedItem {
2002        const COLLECTION: &'static str = "items";
2003    }
2004
2005    #[test]
2006    fn repeated_child_values_survive_a_validation_error_re_render() {
2007        // `sku` fails validation; the re-render must keep both tags, not only
2008        // the last one.
2009        let pairs = vec![
2010            p("name", "Order 1"),
2011            p("items[0][sku]", ""),
2012            p("items[0][tags]", "a"),
2013            p("items[0][tags]", "b"),
2014        ];
2015        let cs = decode_nested_urlencoded::<Order, TaggedItem>(&pairs).expect("parent decodes");
2016        assert!(!cs.is_valid());
2017        let row = &cs.rows()[0];
2018        assert!(!row.errors_for("sku").is_empty());
2019        assert_eq!(row.values("tags"), ["a", "b"]);
2020        assert_eq!(row.value("tags"), Some("b"));
2021        assert_eq!(row.values("sku"), [""]);
2022        assert!(row.values("missing").is_empty());
2023    }
2024
2025    #[test]
2026    fn seeded_child_vec_field_pre_fills_every_element() {
2027        let cs = NestedChangeset::seeded(
2028            Order {
2029                name: "Existing order".to_owned(),
2030            },
2031            vec![TaggedItem {
2032                sku: "A-1".to_owned(),
2033                tags: vec!["a".to_owned(), "b".to_owned()],
2034            }],
2035        );
2036        let row = &cs.rows()[0];
2037        assert_eq!(row.value("sku"), Some("A-1"));
2038        assert_eq!(row.values("tags"), ["a", "b"]);
2039    }
2040
2041    // ── #2423: NUL bytes on the nested path ───────────────────────────
2042
2043    /// The parent behaves exactly like a flat [`ChangesetForm`] field: the
2044    /// byte is stripped from the retained value and recorded as one error on
2045    /// the field that carried it.
2046    #[test]
2047    fn nul_byte_in_a_parent_field_is_a_parent_error() {
2048        let pairs = vec![
2049            p("name", "Order\u{0}1"),
2050            p("items[0][sku]", "A-1"),
2051            p("items[0][quantity]", "2"),
2052        ];
2053        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2054        assert!(!cs.is_valid());
2055        assert_eq!(cs.errors_for("name"), [NUL_CHARACTER_FIELD_ERROR]);
2056        assert_eq!(cs.parent.data().name, "Order1");
2057    }
2058
2059    /// A child subfield is keyed under that subfield on that row, so it
2060    /// renders next to the offending input rather than at the row root.
2061    #[test]
2062    fn nul_byte_in_a_child_subfield_is_a_row_error() {
2063        let pairs = vec![
2064            p("name", "Order 1"),
2065            p("items[0][sku]", "A\u{0}-1"),
2066            p("items[0][quantity]", "2"),
2067        ];
2068        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2069        assert!(!cs.is_valid());
2070        assert_eq!(cs.rows().len(), 1);
2071        assert_eq!(cs.rows()[0].errors_for("sku"), [NUL_CHARACTER_FIELD_ERROR]);
2072        // The retained value the row re-renders is the cleaned text.
2073        assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2074        assert!(
2075            cs.rows()[0].errors_for("quantity").is_empty(),
2076            "only the offending subfield is flagged"
2077        );
2078    }
2079
2080    /// A NUL anywhere in the submission blocks the whole write, so
2081    /// `into_valid` cannot hand back children built from unstorable text.
2082    #[test]
2083    fn nul_byte_in_a_child_subfield_blocks_into_valid() {
2084        let pairs = vec![
2085            p("name", "Order 1"),
2086            p("items[0][sku]", "A\u{0}-1"),
2087            p("items[0][quantity]", "2"),
2088        ];
2089        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2090        assert!(cs.into_valid().is_err());
2091    }
2092
2093    /// `_destroy` is a marker read only for truthiness, never a stored column,
2094    /// and `is_truthy` trims whitespace but not NUL. Cleaning it would rewrite
2095    /// the marker: `"1\u{0}"` reads as *false* today and would read as true
2096    /// after stripping, silently destroying a row the author submitted to keep.
2097    /// So the sweep leaves it alone entirely.
2098    #[test]
2099    fn a_nul_in_the_destroy_marker_does_not_flip_it_truthy() {
2100        let pairs = vec![
2101            p("name", "Order 1"),
2102            p("items[0][sku]", "A-1"),
2103            p("items[0][quantity]", "2"),
2104            p("items[0][_destroy]", "1\u{0}"),
2105        ];
2106        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2107        assert_eq!(cs.rows().len(), 1);
2108        assert!(
2109            !cs.rows()[0].is_destroyed(),
2110            "a NUL-mangled marker must stay falsy, not become a destroy"
2111        );
2112        assert!(cs.is_valid(), "and it is not an error either");
2113    }
2114
2115    /// A row whose every legible subfield was nothing but NUL bytes must not
2116    /// silently vanish the way a genuinely blank template row does — the author
2117    /// submitted something, it just could not be stored.
2118    #[test]
2119    fn a_row_of_only_nul_bytes_is_reported_rather_than_dropped() {
2120        let pairs = vec![
2121            p("name", "Order 1"),
2122            p("items[0][sku]", "\u{0}"),
2123            p("items[0][quantity]", ""),
2124        ];
2125        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2126        assert_eq!(cs.rows().len(), 1, "the row must survive for re-render");
2127        assert_eq!(cs.rows()[0].errors_for("sku"), [NUL_CHARACTER_FIELD_ERROR]);
2128        assert!(!cs.is_valid());
2129    }
2130
2131    /// A genuinely blank template row still drops, unchanged.
2132    #[test]
2133    fn a_blank_template_row_still_drops() {
2134        let pairs = vec![
2135            p("name", "Order 1"),
2136            p("items[0][sku]", ""),
2137            p("items[0][quantity]", ""),
2138        ];
2139        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2140        assert!(cs.rows().is_empty());
2141        assert!(cs.is_valid());
2142    }
2143
2144    /// `decode_nested_urlencoded` is public and takes the raw pairs — tokens
2145    /// and all — so it applies the default plumbing exemption itself. A direct
2146    /// caller must not get a parent error keyed `_csrf`, which no template
2147    /// renders.
2148    #[test]
2149    fn default_plumbing_fields_are_exempt_for_direct_callers() {
2150        for field in ["_csrf", "_submit_token", "_method"] {
2151            let pairs = vec![
2152                p("name", "Order 1"),
2153                p(field, "to%00ken".replace("%00", "\u{0}").as_str()),
2154                p("items[0][sku]", "A-1"),
2155                p("items[0][quantity]", "2"),
2156            ];
2157            let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2158            assert!(
2159                cs.is_valid(),
2160                "`{field}` must not produce an unrenderable error: {:?}",
2161                cs.errors_for(field)
2162            );
2163        }
2164    }
2165
2166    /// The extractor is where the *configured* token field names are known, so
2167    /// that is where they have to be exempted. `decode_nested_urlencoded`
2168    /// covers only the defaults.
2169    #[tokio::test]
2170    async fn nested_extractor_exempts_the_configured_token_field_names() {
2171        use axum::body::Body;
2172        use axum::extract::FromRequest as _;
2173
2174        let mut req = axum::http::Request::builder()
2175            .method("POST")
2176            .uri("/orders")
2177            .header("Content-Type", "application/x-www-form-urlencoded")
2178            .body(Body::from(
2179                "authenticity=to%00ken&stamp=st%00amp&name=Order+1\
2180                 &items%5B0%5D%5Bsku%5D=A-1&items%5B0%5D%5Bquantity%5D=2",
2181            ))
2182            .expect("build request");
2183        req.extensions_mut()
2184            .insert(crate::security::csrf::CsrfFormField(
2185                "authenticity".to_owned(),
2186            ));
2187        req.extensions_mut()
2188            .insert(crate::security::SubmitFormField("stamp".to_owned()));
2189
2190        let form = NestedChangesetForm::<Order, LineItem>::from_request(req, &())
2191            .await
2192            .expect("body decodes");
2193
2194        assert!(
2195            form.changeset.is_valid(),
2196            "renamed token fields must not produce unrenderable errors: {:?}",
2197            form.changeset.parent.errors()
2198        );
2199    }
2200
2201    /// A destroyed row is never written, so it must never be *rejected* —
2202    /// otherwise the author cannot delete a row whose only remaining content
2203    /// was an unstorable byte. The blank-and-NUL rejection must not run ahead
2204    /// of the destroy check.
2205    #[test]
2206    fn a_destroyed_row_of_only_nul_bytes_is_not_rejected() {
2207        let pairs = vec![
2208            p("name", "Order 1"),
2209            p("items[0][sku]", "\u{0}"),
2210            p("items[0][quantity]", ""),
2211            p("items[0][_destroy]", "1"),
2212        ];
2213        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2214        assert!(
2215            cs.is_valid(),
2216            "a row the author asked to destroy must not block the submission"
2217        );
2218        assert!(
2219            cs.into_valid().is_ok(),
2220            "the parent must still be bindable so the destroy can be applied"
2221        );
2222    }
2223
2224    /// A repeated subfield carrying NULs contributes one message, not one per
2225    /// occurrence — the same guarantee the flat sweep already makes. Without
2226    /// it a crafted body inflates a single row's error list without bound.
2227    #[test]
2228    fn a_repeated_nul_subfield_in_a_row_reports_once() {
2229        let pairs = vec![
2230            p("name", "Order 1"),
2231            p("items[0][sku]", "A-1"),
2232            p("items[0][quantity]", "2"),
2233            p("items[0][unknown]", "a\u{0}a"),
2234            p("items[0][unknown]", "b\u{0}b"),
2235            p("items[0][unknown]", "c\u{0}c"),
2236        ];
2237        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2238        assert_eq!(cs.rows().len(), 1);
2239        assert_eq!(
2240            cs.rows()[0].errors_for("unknown").len(),
2241            1,
2242            "one message per offending subfield, not one per submitted pair"
2243        );
2244    }
2245
2246    /// No false positives on the nested path either.
2247    #[test]
2248    fn clean_nested_submission_is_unaffected() {
2249        let pairs = vec![
2250            p("name", "Order 1"),
2251            p("items[0][sku]", "A-1"),
2252            p("items[0][quantity]", "2"),
2253        ];
2254        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2255        assert!(cs.is_valid());
2256    }
2257
2258    #[test]
2259    fn binds_parent_and_two_children_in_order() {
2260        let pairs = vec![
2261            p("name", "Order 1"),
2262            p("items[0][sku]", "A-1"),
2263            p("items[0][quantity]", "2"),
2264            p("items[1][sku]", "B-2"),
2265            p("items[1][quantity]", "3"),
2266        ];
2267        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2268        assert!(cs.is_valid());
2269        assert_eq!(cs.rows().len(), 2);
2270        assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2271        assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
2272        assert_eq!(cs.collection_name(), "items");
2273
2274        let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2275        assert_eq!(order.name, "Order 1");
2276        assert_eq!(items.len(), 2);
2277        assert_eq!(items[0].sku, "A-1");
2278        assert_eq!(items[0].quantity, 2);
2279        assert_eq!(items[1].quantity, 3);
2280    }
2281
2282    #[test]
2283    fn non_contiguous_indices_compact_preserving_order() {
2284        let pairs = vec![
2285            p("name", "Order"),
2286            p("items[0][sku]", "A"),
2287            p("items[0][quantity]", "1"),
2288            p("items[2][sku]", "C"),
2289            p("items[2][quantity]", "5"),
2290        ];
2291        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2292        // Gap at index 1 compacts: two rows in ascending order.
2293        assert_eq!(cs.rows().len(), 2);
2294        assert_eq!(cs.rows()[0].value("sku"), Some("A"));
2295        assert_eq!(cs.rows()[1].value("sku"), Some("C"));
2296        assert!(cs.is_valid());
2297
2298        let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2299        assert_eq!(items.len(), 2);
2300        assert_eq!(items[0].sku, "A");
2301        assert_eq!(items[1].sku, "C");
2302    }
2303
2304    #[test]
2305    fn destroy_marker_drops_row_from_children_but_retains_it() {
2306        let pairs = vec![
2307            p("name", "Order"),
2308            p("items[0][sku]", "A"),
2309            p("items[0][quantity]", "1"),
2310            p("items[1][sku]", "X"),
2311            p("items[1][quantity]", "9"),
2312            p("items[1][_destroy]", "1"),
2313        ];
2314        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2315        assert_eq!(cs.rows().len(), 2);
2316        assert!(!cs.rows()[0].is_destroyed());
2317        assert!(cs.rows()[1].is_destroyed());
2318        // Destroyed row is still retained with its raw values for re-render.
2319        assert_eq!(cs.rows()[1].value("sku"), Some("X"));
2320        assert!(cs.is_valid());
2321
2322        let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2323        // Only the non-destroyed row contributes.
2324        assert_eq!(items.len(), 1);
2325        assert_eq!(items[0].sku, "A");
2326    }
2327
2328    #[test]
2329    fn child_validation_failure_surfaces_combined_key_and_blocks_valid() {
2330        let pairs = vec![
2331            p("name", "Order"),
2332            p("items[0][sku]", "A"),
2333            p("items[0][quantity]", "2"),
2334            // quantity = 0 violates range(min = 1)
2335            p("items[1][sku]", "B"),
2336            p("items[1][quantity]", "0"),
2337        ];
2338        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2339        assert!(!cs.is_valid());
2340        assert!(!cs.errors_for("items[1].quantity").is_empty());
2341        // The valid sibling row has no error for that key.
2342        assert!(cs.errors_for("items[0].quantity").is_empty());
2343        assert!(cs.into_valid().is_err());
2344    }
2345
2346    #[test]
2347    fn all_valid_yields_ok_with_coerced_numeric_fields() {
2348        let pairs = vec![
2349            p("name", "Order"),
2350            p("items[0][sku]", "A"),
2351            p("items[0][quantity]", "5"),
2352        ];
2353        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2354        assert!(cs.is_valid());
2355        let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2356        assert_eq!(order.name, "Order");
2357        assert_eq!(items.len(), 1);
2358        // "5" coerced to i32.
2359        let q: i32 = items[0].quantity;
2360        assert_eq!(q, 5);
2361    }
2362
2363    #[test]
2364    fn parent_invalid_blocks_valid_children() {
2365        let pairs = vec![
2366            // Empty parent name violates length(min = 1).
2367            p("name", ""),
2368            p("items[0][sku]", "A"),
2369            p("items[0][quantity]", "1"),
2370        ];
2371        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2372        assert!(!cs.is_valid());
2373        assert!(!cs.errors_for("name").is_empty());
2374        assert!(cs.into_valid().is_err());
2375    }
2376
2377    #[test]
2378    fn child_parse_failure_keys_error_to_offending_field() {
2379        let pairs = vec![
2380            p("name", "Order"),
2381            // `sku` is filled, so the row is NOT all-blank (it is kept, not
2382            // dropped) — contrast `all_blank_template_row_is_ignored`, where an
2383            // empty row is dropped entirely with no error.
2384            p("items[0][sku]", "A"),
2385            // Non-numeric quantity: hard parse failure for i32, before
2386            // validation. `serde_path_to_error` attributes it to `quantity`.
2387            p("items[0][quantity]", "not-a-number"),
2388        ];
2389        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2390        assert!(!cs.is_valid());
2391        assert_eq!(cs.rows().len(), 1);
2392        // Raw values retained for re-render.
2393        assert_eq!(cs.rows()[0].value("sku"), Some("A"));
2394        assert_eq!(cs.rows()[0].value("quantity"), Some("not-a-number"));
2395        // The parse error is keyed to the offending subfield, so it renders next
2396        // to the bad input via `errors_for("quantity")` on that row.
2397        assert!(!cs.errors_for("items[0].quantity").is_empty());
2398        assert!(!cs.rows()[0].errors_for("quantity").is_empty());
2399        // It is NOT stored under the row-level "" key anymore.
2400        assert!(cs.rows()[0].errors_for("").is_empty());
2401        assert!(cs.errors_for("items[0]").is_empty());
2402        assert!(cs.into_valid().is_err());
2403    }
2404
2405    #[test]
2406    fn child_parse_failure_on_blank_typed_field_keys_error_to_field() {
2407        // The Codex case: a row with `sku` filled but the required TYPED
2408        // `quantity` present-but-blank. The blank-optional retry inside
2409        // `decode_urlencoded_dropping_blank_optional_fields` DROPS the blank
2410        // `quantity=` pair, so serde reports `missing field \`quantity\`` at the
2411        // ROW ROOT (empty path). The field-recovery re-decode over the original
2412        // (blank-retained) pairs must still key the error to `quantity` so it
2413        // renders next to the blank input rather than the row-level "" key.
2414        let pairs = vec![
2415            p("name", "Order"),
2416            p("items[0][sku]", "A"),
2417            p("items[0][quantity]", ""),
2418        ];
2419        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2420        assert!(!cs.is_valid());
2421        assert_eq!(cs.rows().len(), 1);
2422        // Keyed to the offending subfield, so it renders next to the blank input.
2423        assert!(!cs.errors_for("items[0].quantity").is_empty());
2424        assert!(!cs.rows()[0].errors_for("quantity").is_empty());
2425        // NOT stored under the row-level "" / `items[0]` key.
2426        assert!(cs.rows()[0].errors_for("").is_empty());
2427        assert!(cs.errors_for("items[0]").is_empty());
2428        assert!(cs.into_valid().is_err());
2429    }
2430
2431    #[test]
2432    fn child_row_with_blank_optional_typed_field_still_decodes_valid() {
2433        // Regression guard for the fix: a row with the required field filled and
2434        // an OPTIONAL TYPED field present-but-blank (`weight=`) must still decode
2435        // to a VALID row. The primary blank-dropping decode drops the blank
2436        // `weight=` pair so it resolves to `None` and the decode SUCCEEDS — the
2437        // field-recovery re-decode (which runs only on the failed path) is never
2438        // reached, and the row must stay valid.
2439        #[derive(serde::Deserialize, validator::Validate)]
2440        struct Widget {
2441            #[validate(length(min = 1, message = "label required"))]
2442            label: String,
2443            // Typed optional: a blank submission is dropped to `None` by the
2444            // blank-optional decode (an empty string is not a valid `i32`).
2445            weight: Option<i32>,
2446        }
2447        impl NestedChild for Widget {
2448            const COLLECTION: &'static str = "widgets";
2449        }
2450
2451        let pairs = vec![
2452            p("name", "Order"),
2453            p("widgets[0][label]", "Bolt"),
2454            p("widgets[0][weight]", ""),
2455        ];
2456        let cs = decode_nested_urlencoded::<Order, Widget>(&pairs).expect("parent decodes");
2457        assert!(cs.is_valid());
2458        assert_eq!(cs.rows().len(), 1);
2459        assert!(cs.errors_for("widgets[0].weight").is_empty());
2460        assert!(cs.rows()[0].errors_for("weight").is_empty());
2461        let (_order, widgets) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2462        assert_eq!(widgets.len(), 1);
2463        assert_eq!(widgets[0].label, "Bolt");
2464        assert_eq!(widgets[0].weight, None);
2465    }
2466
2467    #[test]
2468    fn child_optional_blank_before_required_blank_keys_error_to_required_field() {
2469        // Codex P2 follow-on: a child whose OPTIONAL typed field (`weight:
2470        // Option<i32>`) is declared BEFORE a REQUIRED typed field (`quantity:
2471        // i32`), with BOTH submitted blank. The blank-dropping decode drops both
2472        // blank pairs and ends at `missing field \`quantity\`` — the required
2473        // one. A raw non-dropping re-decode, by contrast, trips FIRST on the
2474        // harmless optional `weight=` (parsing `""` as an `i32` at `.weight`), so
2475        // it would mis-key the error to `weight`. The field-recovery therefore
2476        // prefers the PRIMARY error's missing-field message over the raw
2477        // re-decode, so the inline error lands on `quantity`, not `weight`.
2478        #[derive(serde::Deserialize, validator::Validate)]
2479        struct Part {
2480            #[validate(length(min = 1, message = "sku required"))]
2481            sku: String,
2482            // Optional, declared FIRST — a blank submission is harmless (→ None).
2483            // Never read on this invalid-row path (the row fails to fully decode),
2484            // so silence the dead-field lint.
2485            #[allow(dead_code)]
2486            weight: Option<i32>,
2487            // Required, declared AFTER — a blank submission is the real error.
2488            #[validate(range(min = 1, message = "quantity must be >= 1"))]
2489            quantity: i32,
2490        }
2491        impl NestedChild for Part {
2492            const COLLECTION: &'static str = "items";
2493        }
2494
2495        let pairs = vec![
2496            p("name", "Order"),
2497            // `sku` filled so the row is retained (not all-blank).
2498            p("items[0][sku]", "Widget"),
2499            p("items[0][weight]", ""),
2500            p("items[0][quantity]", ""),
2501        ];
2502        let cs = decode_nested_urlencoded::<Order, Part>(&pairs).expect("parent decodes");
2503        assert!(!cs.is_valid());
2504        assert_eq!(cs.rows().len(), 1);
2505        // The error is keyed to the truly-missing REQUIRED field…
2506        assert!(!cs.errors_for("items[0].quantity").is_empty());
2507        assert!(!cs.rows()[0].errors_for("quantity").is_empty());
2508        // …and NOT to the harmless OPTIONAL blank the raw re-decode trips on.
2509        assert!(cs.errors_for("items[0].weight").is_empty());
2510        assert!(cs.rows()[0].errors_for("weight").is_empty());
2511        // Not stored under the row-level "" / `items[0]` key either.
2512        assert!(cs.rows()[0].errors_for("").is_empty());
2513        assert!(cs.errors_for("items[0]").is_empty());
2514        assert!(cs.into_valid().is_err());
2515    }
2516
2517    #[test]
2518    fn parent_hard_parse_failure_is_err() {
2519        #[derive(serde::Deserialize, validator::Validate)]
2520        struct NumericParent {
2521            #[validate(range(min = 0))]
2522            count: i32,
2523        }
2524        #[derive(serde::Deserialize, validator::Validate)]
2525        struct Child {
2526            #[validate(length(min = 1))]
2527            name: String,
2528        }
2529        impl NestedChild for Child {
2530            const COLLECTION: &'static str = "kids";
2531        }
2532
2533        let pairs = vec![p("count", "not-a-number")];
2534        let result = decode_nested_urlencoded::<NumericParent, Child>(&pairs);
2535        assert!(result.is_err());
2536    }
2537
2538    // ── reject_if: :all_blank ──────────────────────────────────────
2539
2540    /// The core regression test for the phantom-blank-row P1: `inputs_for`
2541    /// always emits a trailing all-blank template row, and with no JS the
2542    /// browser submits its empty inputs. That row must be ignored, not decoded
2543    /// as a real (empty) child.
2544    #[test]
2545    fn all_blank_template_row_is_ignored() {
2546        let pairs = vec![
2547            p("name", "Order"),
2548            // One filled, valid child row.
2549            p("contacts[0][name]", "Alice"),
2550            p("contacts[0][nickname]", "Al"),
2551            // The auto-rendered blank template row: every subfield empty.
2552            p("contacts[1][name]", ""),
2553            p("contacts[1][nickname]", ""),
2554        ];
2555        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2556        assert!(cs.is_valid());
2557        // The blank template row was dropped entirely — only the real row remains.
2558        assert_eq!(cs.rows().len(), 1);
2559        assert_eq!(cs.rows()[0].value("name"), Some("Alice"));
2560
2561        let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2562        assert_eq!(contacts.len(), 1);
2563        assert_eq!(contacts[0].name, "Alice");
2564    }
2565
2566    /// Blank detection trims: a row whose fields are whitespace-only (or empty)
2567    /// is treated as blank and dropped.
2568    #[test]
2569    fn whitespace_only_row_is_ignored() {
2570        let pairs = vec![
2571            p("name", "Order"),
2572            p("contacts[0][name]", "Bob"),
2573            // Whitespace-only + empty: still all-blank after trimming.
2574            p("contacts[1][name]", "   "),
2575            p("contacts[1][nickname]", ""),
2576        ];
2577        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2578        assert!(cs.is_valid());
2579        assert_eq!(cs.rows().len(), 1);
2580        assert_eq!(cs.rows()[0].value("name"), Some("Bob"));
2581
2582        let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2583        assert_eq!(contacts.len(), 1);
2584    }
2585
2586    /// A row with at least one non-blank value is a real child the user is
2587    /// adding: it is kept and validated, so a missing **required** field still
2588    /// surfaces a per-row error. We must not over-aggressively drop it.
2589    #[test]
2590    fn partially_filled_row_with_missing_required_still_errors() {
2591        let pairs = vec![
2592            p("name", "Order"),
2593            // Required `name` left blank, but the optional `nickname` is filled,
2594            // so the row is NOT all-blank: it is kept and must error.
2595            p("contacts[0][name]", ""),
2596            p("contacts[0][nickname]", "Ally"),
2597        ];
2598        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2599        assert!(!cs.is_valid());
2600        assert_eq!(cs.rows().len(), 1);
2601        // The kept row surfaces the required-field error under its combined key.
2602        assert!(!cs.errors_for("contacts[0].name").is_empty());
2603        // Raw values retained for re-render.
2604        assert_eq!(cs.rows()[0].value("nickname"), Some("Ally"));
2605        assert!(cs.into_valid().is_err());
2606    }
2607
2608    /// A fully filled row is still bound — reject-all-blank does not regress the
2609    /// happy path when every submitted row carries real values.
2610    #[test]
2611    fn fully_filled_row_is_still_bound() {
2612        let pairs = vec![
2613            p("name", "Order"),
2614            p("contacts[0][name]", "Carol"),
2615            p("contacts[0][nickname]", "Caz"),
2616        ];
2617        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2618        assert!(cs.is_valid());
2619        assert_eq!(cs.rows().len(), 1);
2620
2621        let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2622        assert_eq!(contacts.len(), 1);
2623        assert_eq!(contacts[0].name, "Carol");
2624        assert_eq!(contacts[0].nickname.as_deref(), Some("Caz"));
2625    }
2626
2627    #[test]
2628    fn parse_child_key_matches_and_rejects() {
2629        assert_eq!(parse_child_key("items[0][sku]", "items"), Some((0, "sku")));
2630        assert_eq!(
2631            parse_child_key("items[12][quantity]", "items"),
2632            Some((12, "quantity"))
2633        );
2634        // Wrong collection.
2635        assert_eq!(parse_child_key("other[0][sku]", "items"), None);
2636        // Not a child subfield (parent key).
2637        assert_eq!(parse_child_key("name", "items"), None);
2638        // Trailing junk after the closing bracket.
2639        assert_eq!(parse_child_key("items[0][sku]x", "items"), None);
2640        // Non-numeric index.
2641        assert_eq!(parse_child_key("items[a][sku]", "items"), None);
2642    }
2643
2644    // ── blank (non-validating) constructor ─────────────────────────
2645
2646    #[test]
2647    fn blank_changeset_is_valid_with_no_rows_or_errors() {
2648        // The initial `new`-page path: the required parent `name` is still
2649        // empty, but `blank` does NOT validate, so nothing errors before the
2650        // user types (unlike `decode_nested_urlencoded`).
2651        let cs = NestedChangeset::<Order, LineItem>::blank(Order {
2652            name: String::new(),
2653        });
2654        assert!(cs.is_valid());
2655        assert!(cs.errors_for("name").is_empty());
2656        assert!(cs.rows().is_empty());
2657
2658        let (order, items) = cs
2659            .into_valid()
2660            .unwrap_or_else(|_| panic!("blank changeset is valid"));
2661        assert_eq!(order.name, "");
2662        assert!(items.is_empty());
2663    }
2664
2665    #[test]
2666    fn decoded_empty_parent_errors_unlike_blank() {
2667        // Contrast the POST decode path: it DOES validate, so the same empty
2668        // required parent surfaces an error — exactly what `blank` avoids on the
2669        // initial GET render.
2670        let cs =
2671            decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
2672        assert!(!cs.is_valid());
2673        assert!(!cs.errors_for("name").is_empty());
2674    }
2675
2676    // ── seeded (edit-render) constructor ───────────────────────────
2677
2678    #[test]
2679    fn seeded_changeset_pre_populates_one_row_per_child() {
2680        // The initial `edit`-page path: seed the changeset with the two existing
2681        // children so an edit form can display/preserve them before first submit.
2682        let cs = NestedChangeset::seeded(
2683            Order {
2684                name: "Existing order".to_owned(),
2685            },
2686            vec![
2687                LineItem {
2688                    sku: "A-1".to_owned(),
2689                    quantity: 2,
2690                },
2691                LineItem {
2692                    sku: "B-2".to_owned(),
2693                    quantity: 3,
2694                },
2695            ],
2696        );
2697
2698        // One row per seeded child, each pre-filled with its serialized values —
2699        // the same string representation the decoder populates.
2700        assert_eq!(cs.rows().len(), 2);
2701        assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2702        assert_eq!(cs.rows()[0].value("quantity"), Some("2"));
2703        assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
2704        assert_eq!(cs.rows()[1].value("quantity"), Some("3"));
2705
2706        // Non-validating and clean: no errors, no destroyed rows.
2707        assert!(cs.is_valid());
2708        assert!(cs.errors_for("name").is_empty());
2709        assert!(cs.errors_for("items[0].sku").is_empty());
2710        assert!(cs.errors_for("items[1].quantity").is_empty());
2711        assert!(!cs.rows()[0].is_destroyed());
2712        assert!(!cs.rows()[1].is_destroyed());
2713
2714        // `into_valid` returns the seeded parent + children unchanged.
2715        let (order, items) = cs
2716            .into_valid()
2717            .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2718        assert_eq!(order.name, "Existing order");
2719        assert_eq!(items.len(), 2);
2720        assert_eq!(items[0].sku, "A-1");
2721        assert_eq!(items[0].quantity, 2);
2722        assert_eq!(items[1].sku, "B-2");
2723        assert_eq!(items[1].quantity, 3);
2724    }
2725
2726    #[test]
2727    fn seeded_changeset_with_no_children_is_valid_and_empty() {
2728        // Seeding zero children behaves like `blank` — a valid changeset with no
2729        // rows — so an edit form for a parent that currently has no children is
2730        // handled uniformly.
2731        let cs = NestedChangeset::<Order, LineItem>::seeded(
2732            Order {
2733                name: "Empty".to_owned(),
2734            },
2735            Vec::new(),
2736        );
2737        assert!(cs.is_valid());
2738        assert!(cs.rows().is_empty());
2739        let (_order, items) = cs
2740            .into_valid()
2741            .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2742        assert!(items.is_empty());
2743    }
2744}
2745
2746#[cfg(all(test, feature = "maud"))]
2747mod maud_tests {
2748    use super::*;
2749
2750    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2751    struct Order {
2752        #[validate(length(min = 1, message = "name required"))]
2753        name: String,
2754    }
2755
2756    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2757    struct LineItem {
2758        #[validate(length(min = 1, message = "sku required"))]
2759        sku: String,
2760        #[validate(range(min = 1, message = "quantity must be >= 1"))]
2761        quantity: i32,
2762    }
2763
2764    impl NestedChild for LineItem {
2765        const COLLECTION: &'static str = "items";
2766    }
2767
2768    fn p(k: &str, v: &str) -> (String, String) {
2769        (k.to_owned(), v.to_owned())
2770    }
2771
2772    /// A changeset with two submitted rows, the second failing child validation
2773    /// (`quantity = 0`), so both rows are retained for re-render.
2774    fn two_row_changeset() -> NestedChangeset<Order, LineItem> {
2775        let pairs = vec![
2776            p("name", "Order 1"),
2777            p("items[0][sku]", "A-1"),
2778            p("items[0][quantity]", "2"),
2779            p("items[1][sku]", "B-2"),
2780            // quantity = 0 violates range(min = 1): row 1 is retained with an error.
2781            p("items[1][quantity]", "0"),
2782        ];
2783        decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes")
2784    }
2785
2786    fn render_row(row: &RowScope) -> maud::Markup {
2787        maud::html! {
2788            (row.required_text_input("sku", "SKU"))
2789            (row.number_input("quantity", "Quantity"))
2790            (row.destroy_checkbox("Remove"))
2791        }
2792    }
2793
2794    #[test]
2795    fn existing_rows_render_indexed_names_and_prefilled_values() {
2796        let cs = two_row_changeset();
2797        let opts = InputsForOptions::default();
2798        let html = inputs_for(&cs, &opts, render_row).into_string();
2799
2800        assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
2801        assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
2802        // Pre-filled values from the re-rendered changeset.
2803        assert!(html.contains(r#"value="A-1""#), "{html}");
2804        assert!(html.contains(r#"value="B-2""#), "{html}");
2805        // Container defaults to "{collection}-rows".
2806        assert!(html.contains(r#"id="items-rows""#), "{html}");
2807    }
2808
2809    #[test]
2810    fn per_row_error_renders_scoped_alert_block() {
2811        let cs = two_row_changeset();
2812        let opts = InputsForOptions::default();
2813        let html = inputs_for(&cs, &opts, render_row).into_string();
2814
2815        // The failing row's quantity error is scoped to that row's unique id.
2816        assert!(html.contains(r#"id="items-1-quantity-error""#), "{html}");
2817        assert!(html.contains(r#"role="alert""#), "{html}");
2818        assert!(html.contains("quantity must be &gt;= 1"), "{html}");
2819        // The valid sibling row's quantity has no error block.
2820        assert!(!html.contains(r#"id="items-0-quantity-error""#), "{html}");
2821    }
2822
2823    #[test]
2824    fn appends_blank_template_row_with_next_index() {
2825        let cs = two_row_changeset();
2826        let opts = InputsForOptions::default();
2827        let html = inputs_for(&cs, &opts, render_row).into_string();
2828
2829        // Two existing rows (indices 0,1) plus one blank row at index 2.
2830        assert!(html.contains(r#"data-index="0""#), "{html}");
2831        assert!(html.contains(r#"data-index="1""#), "{html}");
2832        assert!(html.contains(r#"data-index="2""#), "{html}");
2833        assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
2834    }
2835
2836    /// Extract the full `<...>` tag containing the given `name="…"` attribute,
2837    /// so a per-input attribute assertion isn't fooled by a sibling row's tag.
2838    fn tag_with_name(html: &str, name: &str) -> String {
2839        let needle = format!(r#"name="{name}""#);
2840        let at = html
2841            .find(&needle)
2842            .unwrap_or_else(|| panic!("missing {name} in {html}"));
2843        let open = html[..at].rfind('<').expect("tag has an opening '<'");
2844        let close = html[at..].find('>').expect("tag has a closing '>'") + at;
2845        html[open..=close].to_string()
2846    }
2847
2848    /// Fix 1: a required-variant input on a submitted row carries the client-side
2849    /// `required`/`aria-required` constraint, but the auto-appended blank template
2850    /// row (`row: None`) omits it so the form stays submittable with a trailing
2851    /// blank row present (server-side validation still enforces required-ness).
2852    #[test]
2853    fn blank_template_row_omits_client_required_but_submitted_row_keeps_it() {
2854        let cs = two_row_changeset();
2855        let opts = InputsForOptions::default();
2856        let html = inputs_for(&cs, &opts, render_row).into_string();
2857
2858        // Submitted rows (indices 0, 1) keep the client-side constraint.
2859        let submitted = tag_with_name(&html, "items[0][sku]");
2860        assert!(submitted.contains(" required"), "{submitted}");
2861        assert!(submitted.contains(r#"aria-required="true""#), "{submitted}");
2862
2863        // The appended blank template row (index 2) omits both, so it is
2864        // leaveable-empty and does not block submitting the form.
2865        let blank = tag_with_name(&html, "items[2][sku]");
2866        assert!(!blank.contains("required"), "{blank}");
2867        assert!(!blank.contains("aria-required"), "{blank}");
2868    }
2869
2870    /// Fix (Codex P2, near the `required` gate): a submitted row the user ticked
2871    /// `_destroy` on must ALSO omit the client-side `required`/`aria-required`,
2872    /// exactly like a blank template row. Otherwise, in a 422 re-render where the
2873    /// user checked Remove on a row with an empty required field, the browser's
2874    /// constraint validation would block the next submit before the server can
2875    /// honor `_destroy`. A non-destroyed submitted row in the same render keeps
2876    /// the constraint.
2877    #[test]
2878    fn destroyed_row_omits_client_required_while_submitted_row_keeps_it() {
2879        // Row 0: an ordinary submitted row. Row 1: the user ticked Remove on a
2880        // row whose required `sku` is empty (it is retained because `quantity`
2881        // is non-blank, so the all-blank rejection does not drop it).
2882        let pairs = vec![
2883            p("name", "Order 1"),
2884            p("items[0][sku]", "A-1"),
2885            p("items[0][quantity]", "2"),
2886            p("items[1][sku]", ""),
2887            p("items[1][quantity]", "9"),
2888            p("items[1][_destroy]", "1"),
2889        ];
2890        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2891        assert!(cs.rows()[1].is_destroyed());
2892        let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2893
2894        // The non-destroyed submitted row keeps the client-side constraint.
2895        let kept = tag_with_name(&html, "items[0][sku]");
2896        assert!(kept.contains(" required"), "{kept}");
2897        assert!(kept.contains(r#"aria-required="true""#), "{kept}");
2898
2899        // The destroyed row omits both, exactly like a blank template row, so a
2900        // form carrying an empty required field on a to-be-removed row still
2901        // submits and the server can honor `_destroy`.
2902        let destroyed = tag_with_name(&html, "items[1][sku]");
2903        assert!(!destroyed.contains("required"), "{destroyed}");
2904        assert!(!destroyed.contains("aria-required"), "{destroyed}");
2905    }
2906
2907    #[test]
2908    fn always_emits_a_blank_row_even_when_blank_rows_zero() {
2909        let cs = two_row_changeset();
2910        let opts = InputsForOptions {
2911            blank_rows: 0,
2912            ..InputsForOptions::default()
2913        };
2914        let html = inputs_for(&cs, &opts, render_row).into_string();
2915        // Still emits the blank template row at index 2.
2916        assert!(html.contains(r#"data-index="2""#), "{html}");
2917    }
2918
2919    #[test]
2920    fn destroy_checkbox_emits_indexed_marker() {
2921        let cs = two_row_changeset();
2922        let opts = InputsForOptions::default();
2923        let html = inputs_for(&cs, &opts, render_row).into_string();
2924
2925        assert!(html.contains(r#"name="items[0][_destroy]""#), "{html}");
2926        assert!(html.contains(r#"name="items[1][_destroy]""#), "{html}");
2927        assert!(html.contains(r#"type="checkbox""#), "{html}");
2928    }
2929
2930    #[test]
2931    fn add_button_renders_only_with_url_and_carries_htmx_attrs() {
2932        let cs = two_row_changeset();
2933
2934        // No URL: no Add button.
2935        let none = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2936        assert!(!none.contains("Add row"), "{none}");
2937
2938        // With URL: button serializes only the `index` param (`hx-params="index"`)
2939        // and uses a beforeend swap.
2940        let opts = InputsForOptions {
2941            add_row_url: Some("/orders/line-item-row".into()),
2942            ..InputsForOptions::default()
2943        };
2944        let html = inputs_for(&cs, &opts, render_row).into_string();
2945        assert!(html.contains("Add row"), "{html}");
2946        assert!(html.contains(r#"hx-params="index""#), "{html}");
2947        assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2948        assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2949        assert!(html.contains(r##"hx-target="#items-rows""##), "{html}");
2950    }
2951
2952    /// Fix (Codex P2, near the Add-row button): the Add-row button sends a fresh
2953    /// unique `index` with each htmx request via `hx-vals` (`js:` form) computed
2954    /// from THIS container's rows (`max(existing data-index) + 1`), so repeated
2955    /// clicks never reuse an index and emit duplicate control names. The selector
2956    /// is scoped to the form's own container id; keeping several same-collection
2957    /// forms independent requires each to pass a distinct `container_id` (see
2958    /// `add_button_targets_and_scans_custom_container_id`). The pre-existing htmx
2959    /// attrs stay intact.
2960    #[test]
2961    fn add_button_hx_vals_sends_fresh_container_scoped_index() {
2962        let cs = two_row_changeset();
2963        let opts = InputsForOptions {
2964            add_row_url: Some("/orders/line-item-row".into()),
2965            ..InputsForOptions::default()
2966        };
2967        let html = inputs_for(&cs, &opts, render_row).into_string();
2968
2969        // Pre-existing htmx wiring is preserved.
2970        assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2971        assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2972        assert!(html.contains(r#"hx-params="index""#), "{html}");
2973
2974        // A JS-computed `hx-vals` that sends a fresh `index`…
2975        assert!(html.contains("hx-vals="), "{html}");
2976        assert!(html.contains("Math.max(-1"), "{html}");
2977        // …referencing the `index` param and reading each row's `data-index`…
2978        assert!(html.contains("parseInt(e.dataset.index,10)"), "{html}");
2979        // …via a selector scoped to THIS form's container id.
2980        assert!(
2981            html.contains("#items-rows .nested-fields__row[data-index]"),
2982            "{html}"
2983        );
2984    }
2985
2986    /// Fix (Codex P2, container-id collisions): a caller rendering two nested
2987    /// forms for the same child collection on one page must give each a distinct
2988    /// `container_id` so their Add-row buttons stay independent. Assert that an
2989    /// explicit `container_id` flows through to BOTH the container `id` and the
2990    /// Add-row button's `hx-target` + `hx-vals` index scan — proving per-form
2991    /// scoping works once ids are distinct — and that the default id is absent.
2992    #[test]
2993    fn add_button_targets_and_scans_custom_container_id() {
2994        let cs = two_row_changeset();
2995        let opts = InputsForOptions {
2996            add_row_url: Some("/orders/line-item-row".into()),
2997            // A second same-collection form on the page would pass its own id;
2998            // this one deliberately avoids containing the `items-rows` default so
2999            // the negative assertions below are exact substring checks.
3000            container_id: Some("shipping-lines".into()),
3001            ..InputsForOptions::default()
3002        };
3003        let html = inputs_for(&cs, &opts, render_row).into_string();
3004
3005        // The container carries the custom id, not the `{collection}-rows` default.
3006        assert!(
3007            html.contains(r#"<div id="shipping-lines" class="nested-fields">"#),
3008            "{html}"
3009        );
3010        // The Add-row button targets the custom container…
3011        assert!(html.contains(r##"hx-target="#shipping-lines""##), "{html}");
3012        // …and its index scan reads that same custom container's rows…
3013        assert!(
3014            html.contains("#shipping-lines .nested-fields__row[data-index]"),
3015            "{html}"
3016        );
3017        // …with no leftover reference to the default id.
3018        assert!(!html.contains("items-rows .nested-fields__row"), "{html}");
3019        assert!(!html.contains(r##"hx-target="#items-rows""##), "{html}");
3020    }
3021
3022    #[test]
3023    fn destroy_checkbox_checked_reflects_destroyed_row() {
3024        let pairs = vec![
3025            p("name", "Order"),
3026            p("items[0][sku]", "A"),
3027            p("items[0][quantity]", "1"),
3028            p("items[0][_destroy]", "1"),
3029        ];
3030        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
3031        let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
3032        // The destroyed row's checkbox is checked.
3033        assert!(
3034            html.contains(r#"name="items[0][_destroy]" value="1" checked"#),
3035            "{html}"
3036        );
3037    }
3038
3039    #[test]
3040    fn nested_row_fragment_renders_single_row_at_index() {
3041        let html = nested_row_fragment::<LineItem>(7, render_row).into_string();
3042        assert!(html.contains(r#"data-index="7""#), "{html}");
3043        assert!(html.contains(r#"name="items[7][sku]""#), "{html}");
3044        // Exactly one row wrapper.
3045        assert_eq!(html.matches("nested-fields__row").count(), 1, "{html}");
3046    }
3047
3048    // ── Fix 1: form_tag preserves the captured CSRF/submit-token fields ─
3049
3050    /// `NestedChangesetForm::form_tag` must emit the CSRF hidden input under the
3051    /// **captured/configured** field name — NOT the standalone `form_tag`'s
3052    /// hardcoded `_csrf` — so an app with a custom `security.csrf.form_field`
3053    /// keeps CSRF parity across a nested re-render.
3054    #[test]
3055    fn form_tag_emits_custom_csrf_field_name_not_default() {
3056        let form = NestedChangesetForm::<Order, LineItem>::blank(
3057            Order {
3058                name: String::new(),
3059            },
3060            Some("tok-123".to_owned()),
3061        )
3062        .with_csrf_field("authenticity_token");
3063
3064        let html = form
3065            .form_tag("/orders", "post", maud::html! {})
3066            .into_string();
3067
3068        // The captured custom field name carries the token…
3069        assert!(
3070            html.contains(r#"name="authenticity_token" value="tok-123""#),
3071            "{html}"
3072        );
3073        // …and the hardcoded default the standalone `form_tag` would emit does not.
3074        assert!(!html.contains(r#"name="_csrf""#), "{html}");
3075        assert!(html.contains(r#"action="/orders""#), "{html}");
3076        assert!(html.contains(r#"method="post""#), "{html}");
3077    }
3078
3079    /// When a submit token is captured, `form_tag` also emits it under its
3080    /// captured field name (alongside the custom CSRF field).
3081    #[test]
3082    fn form_tag_emits_captured_submit_token_field() {
3083        let form = NestedChangesetForm::<Order, LineItem> {
3084            changeset: NestedChangeset::blank(Order {
3085                name: String::new(),
3086            }),
3087            csrf_token: Some("tok".to_owned()),
3088            csrf_field: "authenticity_token".to_owned(),
3089            submit_token: Some("stok-9".to_owned()),
3090            submit_field: "_submit_token".to_owned(),
3091        };
3092
3093        let html = form
3094            .form_tag("/orders", "post", maud::html! {})
3095            .into_string();
3096
3097        assert!(
3098            html.contains(r#"name="authenticity_token" value="tok""#),
3099            "{html}"
3100        );
3101        assert!(
3102            html.contains(r#"name="_submit_token" value="stok-9""#),
3103            "{html}"
3104        );
3105        assert!(!html.contains(r#"name="_csrf""#), "{html}");
3106    }
3107
3108    // ── Fix 2: blank render shows no premature parent errors ────────────
3109
3110    /// The initial `new`-page render from `blank`: the required parent field is
3111    /// empty but un-validated, so no error message or `aria-invalid="true"`
3112    /// appears before the user types.
3113    #[test]
3114    fn blank_form_render_shows_no_parent_validation_error() {
3115        let form = NestedChangesetForm::<Order, LineItem>::blank(
3116            Order {
3117                name: String::new(),
3118            },
3119            Some("tok".to_owned()),
3120        );
3121
3122        let html = form
3123            .form_tag(
3124                "/orders",
3125                "post",
3126                maud::html! {
3127                    (crate::form::required_text_input(&form.parent, "name", "Order name"))
3128                    (inputs_for(&*form, &InputsForOptions::default(), render_row))
3129                },
3130            )
3131            .into_string();
3132
3133        assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
3134        assert!(!html.contains("name required"), "{html}");
3135    }
3136
3137    /// Contrast: a re-render from a failed decode DOES surface the parent error
3138    /// and marks the field invalid — the exact behaviour `blank` suppresses on
3139    /// the initial GET render.
3140    #[test]
3141    fn decoded_invalid_parent_render_shows_error_unlike_blank() {
3142        let cs =
3143            decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
3144        let form = NestedChangesetForm::from_changeset(cs);
3145
3146        let html = form
3147            .form_tag(
3148                "/orders",
3149                "post",
3150                maud::html! {
3151                    (crate::form::required_text_input(&form.parent, "name", "Order name"))
3152                },
3153            )
3154            .into_string();
3155
3156        assert!(html.contains(r#"aria-invalid="true""#), "{html}");
3157        assert!(html.contains("name required"), "{html}");
3158    }
3159
3160    // ── Fix 3: blank forms can carry the current submit token ───────────
3161
3162    /// A `blank` form given a submit token (and a custom submit field name) emits
3163    /// the hidden submit-token input on the initial GET render, so the FIRST
3164    /// submission is protected against double-submit — not just later 422
3165    /// re-renders.
3166    #[test]
3167    fn blank_form_with_submit_token_emits_hidden_submit_field() {
3168        let form = NestedChangesetForm::<Order, LineItem>::blank(
3169            Order {
3170                name: String::new(),
3171            },
3172            Some("csrf".to_owned()),
3173        )
3174        .with_submit_field("authenticity_submit")
3175        .with_submit_token(Some("tok".to_owned()));
3176
3177        let html = form
3178            .form_tag("/orders", "post", maud::html! {})
3179            .into_string();
3180
3181        assert!(
3182            html.contains(r#"name="authenticity_submit" value="tok""#),
3183            "{html}"
3184        );
3185    }
3186
3187    /// Without supplying a submit token, a `blank` form emits NO submit-token
3188    /// hidden input — documenting the default and that the caller must supply the
3189    /// minted token on the initial GET to protect the first submit.
3190    #[test]
3191    fn blank_form_without_submit_token_omits_submit_field() {
3192        let form = NestedChangesetForm::<Order, LineItem>::blank(
3193            Order {
3194                name: String::new(),
3195            },
3196            Some("csrf".to_owned()),
3197        );
3198
3199        let html = form
3200            .form_tag("/orders", "post", maud::html! {})
3201            .into_string();
3202
3203        assert!(!html.contains("_submit_token"), "{html}");
3204        assert!(!html.contains(r#"name="_submit_token""#), "{html}");
3205    }
3206
3207    // ── seeded (edit-render) rendering ──────────────────────────────
3208
3209    /// A `seeded` changeset pre-renders its existing children through
3210    /// `inputs_for`: each seeded row's inputs carry the child's serialized values
3211    /// so an edit form shows the current line items, and a trailing blank template
3212    /// row is still appended for adding another child with no JS.
3213    #[test]
3214    fn seeded_rows_render_with_prefilled_values() {
3215        let cs = NestedChangeset::seeded(
3216            Order {
3217                name: "Existing order".to_owned(),
3218            },
3219            vec![
3220                LineItem {
3221                    sku: "A-1".to_owned(),
3222                    quantity: 2,
3223                },
3224                LineItem {
3225                    sku: "B-2".to_owned(),
3226                    quantity: 3,
3227                },
3228            ],
3229        );
3230        let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
3231
3232        // Both seeded rows render their indexed inputs with pre-filled values.
3233        assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
3234        assert!(html.contains(r#"value="A-1""#), "{html}");
3235        assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
3236        assert!(html.contains(r#"value="B-2""#), "{html}");
3237        // The trailing blank template row is still appended (index 2).
3238        assert!(html.contains(r#"data-index="2""#), "{html}");
3239        assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
3240    }
3241}