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    )
187)]
188
189use std::collections::{BTreeMap, HashMap};
190
191use axum::extract::{FromRequest, Request};
192use axum::response::IntoResponse;
193
194use crate::form::{
195    Changeset, IntoChangeset, decode_urlencoded_dropping_blank_optional_fields,
196    validation_errors_to_map,
197};
198
199// ── NestedChild ────────────────────────────────────────────────────
200
201/// A child row type bound as part of a nested (`has_many`) form.
202///
203/// The [`COLLECTION`](NestedChild::COLLECTION) constant names the field
204/// group used in input names (`items[i][field]`) and in combined error
205/// keys (`items[1].quantity`).
206pub trait NestedChild: serde::de::DeserializeOwned + validator::Validate + Send {
207    /// Field-group name used in input names (`items[i][field]`) and error
208    /// keys (`items[1].quantity`). e.g. `"items"`.
209    const COLLECTION: &'static str;
210}
211
212// ── NestedRow ──────────────────────────────────────────────────────
213
214/// One submitted child row, retained for re-rendering regardless of whether
215/// it parsed or validated.
216#[derive(Debug, Clone)]
217pub struct NestedRow {
218    /// Raw submitted subfield values (`sku` → `"A-1"`), used to pre-fill
219    /// inputs when re-rendering after a failed submission.
220    values: HashMap<String, String>,
221    /// Per-subfield validation (or parse) errors. A row that failed to parse
222    /// records its error under the offending subfield's name (via
223    /// `serde_path_to_error`), falling back to the empty-string (row-level) key
224    /// only when no field can be identified.
225    errors: HashMap<String, Vec<String>>,
226    /// `true` when the row carried a truthy `_destroy` marker.
227    destroyed: bool,
228}
229
230impl NestedRow {
231    /// The raw submitted value for subfield `sub`, if present.
232    #[must_use]
233    pub fn value(&self, sub: &str) -> Option<&str> {
234        self.values.get(sub).map(String::as_str)
235    }
236
237    /// Validation messages for subfield `sub`, or an empty slice.
238    ///
239    /// A row-level parse failure is stored under the empty-string key, so
240    /// `row.errors_for("")` returns any whole-row decode error.
241    #[must_use]
242    pub fn errors_for(&self, sub: &str) -> &[String] {
243        self.errors.get(sub).map_or(&[], Vec::as_slice)
244    }
245
246    /// `true` when the row was marked for removal via a truthy `_destroy`.
247    #[must_use]
248    pub const fn is_destroyed(&self) -> bool {
249        self.destroyed
250    }
251
252    /// Every error keyed by subfield name (empty key = row-level parse error).
253    #[must_use]
254    pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
255        &self.errors
256    }
257}
258
259// ── NestedChangeset ────────────────────────────────────────────────
260
261/// A parent [`Changeset<P>`] plus its bound child collection.
262///
263/// Obtain one from [`decode_nested_urlencoded`] or (preferred) the
264/// [`NestedChangesetForm`] axum extractor.
265#[derive(Debug)]
266pub struct NestedChangeset<P, C> {
267    /// The parent changeset (values + per-field errors), reusing the existing
268    /// [`Changeset`] pipeline.
269    pub parent: Changeset<P>,
270    /// The submitted child rows in compacted order, retained for re-render.
271    rows: Vec<NestedRow>,
272    /// `Some(children)` iff the parent is valid **and** every non-destroyed
273    /// row both parsed and validated; `None` otherwise.
274    valid_children: Option<Vec<C>>,
275}
276
277impl<P, C: NestedChild> NestedChangeset<P, C> {
278    /// Build a blank, **non-validating** nested changeset for the initial
279    /// `new` (create) render, before the user has submitted anything.
280    ///
281    /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
282    /// wraps `parent` in a valid [`Changeset`] (via [`Changeset::new`], which
283    /// records **no** errors — it does **not** run [`validator::Validate`])
284    /// with **zero** child rows. So `is_valid()` returns `true`,
285    /// `errors_for(..)` is empty, `rows()` is empty, and `into_valid()` returns
286    /// `Ok((parent, vec![]))` even when a required parent field is still empty.
287    ///
288    /// Use this for the initial GET render of an **empty** (create) form so the
289    /// blank page does not show a premature "field is required" error or
290    /// `aria-invalid="true"` before the user has typed. For an **edit** render
291    /// that must display existing child rows (with their persisted ids), use
292    /// [`seeded`](NestedChangeset::seeded) instead — `blank` produces zero child
293    /// rows and cannot pre-fill or preserve existing line items. Use
294    /// [`decode_nested_urlencoded`] (or the [`NestedChangesetForm`] extractor)
295    /// for the POST decode that validates a real submission and re-renders with
296    /// inline errors.
297    #[must_use]
298    pub fn blank(parent: P) -> Self {
299        Self {
300            parent: Changeset::new(parent),
301            rows: Vec::new(),
302            valid_children: Some(Vec::new()),
303        }
304    }
305
306    /// `true` when the parent is valid, every non-destroyed row has no
307    /// errors, and the children all parsed and validated.
308    #[must_use]
309    pub fn is_valid(&self) -> bool {
310        self.parent.is_valid()
311            && self.valid_children.is_some()
312            && self.rows.iter().all(|r| r.destroyed || r.errors.is_empty())
313    }
314
315    /// Consume the changeset, returning `Ok((parent, children))` when valid or
316    /// `Err(self)` (with all rows retained for re-render) when not.
317    ///
318    /// The returned child vector contains only non-destroyed rows, in order.
319    ///
320    /// # Errors
321    ///
322    /// Returns `Err(self)` when the parent or any non-destroyed child row has
323    /// validation errors, or any child failed to parse.
324    #[allow(
325        clippy::result_large_err,
326        reason = "the Err variant intentionally returns the whole changeset (rows + raw \
327                  values) so the handler can re-render the form inline with errors"
328    )]
329    pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
330        if !self.is_valid() {
331            return Err(self);
332        }
333        let Self {
334            parent,
335            rows,
336            valid_children,
337        } = self;
338        // `is_valid()` guarantees both arms below take the happy path; the
339        // reconstruction branches keep the code panic-free without relying on
340        // that invariant.
341        match valid_children {
342            Some(children) => match parent.into_valid() {
343                Ok(p) => Ok((p, children)),
344                Err(parent) => Err(Self {
345                    parent,
346                    rows,
347                    valid_children: None,
348                }),
349            },
350            None => Err(Self {
351                parent,
352                rows,
353                valid_children: None,
354            }),
355        }
356    }
357
358    /// Validation messages for `key`, or an empty slice.
359    ///
360    /// Supports both parent field keys (delegated to
361    /// [`Changeset::errors_for`]) and combined child keys of the form
362    /// `"{COLLECTION}[{i}].{sub}"` (e.g. `"items[1].quantity"`). A bare
363    /// `"{COLLECTION}[{i}]"` returns that row's row-level (parse) errors.
364    #[must_use]
365    pub fn errors_for(&self, key: &str) -> &[String] {
366        if let Some((idx, sub)) = parse_combined_child_key(key, C::COLLECTION) {
367            return self.rows.get(idx).map_or(&[], |r| r.errors_for(sub));
368        }
369        self.parent.errors_for(key)
370    }
371
372    /// The submitted child rows in compacted (ascending-index) order.
373    #[must_use]
374    pub fn rows(&self) -> &[NestedRow] {
375        &self.rows
376    }
377
378    /// The child collection name, i.e. [`NestedChild::COLLECTION`].
379    #[must_use]
380    pub const fn collection_name(&self) -> &'static str {
381        C::COLLECTION
382    }
383}
384
385/// Edit-render seeding — pre-populate a non-validating changeset with existing
386/// persisted children.
387///
388/// The extra [`serde::Serialize`] bound lives on this `impl` block alone, so the
389/// core [`NestedChild`] trait stays serialize-free; only callers that seed an
390/// edit form need their child type to be serializable.
391impl<P, C: NestedChild + serde::Serialize> NestedChangeset<P, C> {
392    /// Build a **non-validating**, valid nested changeset that pre-renders one
393    /// row per existing `child`, for the initial `edit` render of a form that
394    /// already has persisted children.
395    ///
396    /// Where [`blank`](NestedChangeset::blank) produces **zero** child rows (for
397    /// the create/new page), `seeded` produces exactly one [`NestedRow`] per
398    /// element of `children`, so an edit form can display and preserve existing
399    /// line items — including each child's `id` as a hidden input (via
400    /// [`RowScope::hidden_input`]) — before the first submit. That hidden `id`
401    /// also makes the in-scope `_destroy`-on-existing-children flow usable: the
402    /// row round-trips its identity so ticking Remove on a persisted child
403    /// submits a real, identifiable row for the handler to delete.
404    ///
405    /// Each row's raw `values` are produced by serializing the child with
406    /// [`serde_urlencoded::to_string`] and parsing the result back into the
407    /// per-subfield map — the **same** string representation
408    /// [`decode_nested_urlencoded`] populates from a real submission, so
409    /// [`RowScope::value`] pre-fills a seeded row's inputs identically to a
410    /// re-rendered submitted row.
411    ///
412    /// Like `blank`, this does **not** run [`validator::Validate`]: it wraps
413    /// `parent` in a valid [`Changeset`] (via [`Changeset::new`]) and keeps
414    /// `valid_children = Some(children)`, so `is_valid()` returns `true`,
415    /// `errors_for(..)` is empty, every seeded row reports no errors and is not
416    /// destroyed, and `into_valid()` returns `Ok((parent, children))`. Use the
417    /// POST decode path ([`decode_nested_urlencoded`] / the
418    /// [`NestedChangesetForm`] extractor) to validate the actual edit
419    /// submission.
420    ///
421    /// Deeper reconciliation of persisted children — reordering, or deep-diffing
422    /// submitted rows against the seeded set to compute per-row inserts /
423    /// updates / deletes — is intentionally **out of scope** here (see issue
424    /// #1346's out-of-scope list) and remains a follow-up; `seeded` only
425    /// pre-renders the existing rows so the no-JS edit + `_destroy` flow works.
426    #[must_use]
427    pub fn seeded(parent: P, children: Vec<C>) -> Self {
428        let rows = children
429            .iter()
430            .map(|child| {
431                let mut values: HashMap<String, String> = HashMap::new();
432                // Serialize the child to the same `field=value` wire form the
433                // decoder reads, then parse it back so `RowScope::value` pre-fills
434                // seeded rows identically to a re-rendered submitted row. A
435                // serialization failure (never expected for a plain struct) simply
436                // yields an empty value map rather than panicking on the request path.
437                if let Ok(encoded) = serde_urlencoded::to_string(child) {
438                    for (k, v) in url::form_urlencoded::parse(encoded.as_bytes()) {
439                        values.insert(k.into_owned(), v.into_owned());
440                    }
441                }
442                NestedRow {
443                    values,
444                    errors: HashMap::new(),
445                    destroyed: false,
446                }
447            })
448            .collect();
449        Self {
450            parent: Changeset::new(parent),
451            rows,
452            valid_children: Some(children),
453        }
454    }
455}
456
457// ── Decoder ────────────────────────────────────────────────────────
458
459/// Decode URL-encoded `pairs` into a [`NestedChangeset<P, C>`].
460///
461/// Pairs whose key matches `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` are child
462/// subfields (captured index + subfield name); everything else is a parent
463/// pair. Child pairs are grouped by numeric index into ascending order and
464/// **compacted** so non-contiguous indices (gaps from client-side removal)
465/// still yield sequential rows preserving submission order. Subfield order
466/// within a row is preserved.
467///
468/// A `_destroy` subfield with a truthy value (`"1"`, `"true"`, `"on"`) marks
469/// its row destroyed; destroyed rows are retained (for re-render) but never
470/// contribute to `valid_children`.
471///
472/// Following Rails' `reject_if: :all_blank`, a child row whose every
473/// non-`_destroy` subfield value is blank (empty or whitespace-only after
474/// trimming) is **ignored**: it is not decoded, not validated, not retained in
475/// `rows`, and never counts toward `valid_children` — exactly as if that index
476/// had never been submitted. This is what makes the blank template row
477/// [`inputs_for`] auto-renders safe with no JavaScript. A row with at least one
478/// non-blank non-`_destroy` value is kept and validated as usual, so a
479/// partially filled row that omits a required field still surfaces per-field
480/// errors.
481///
482/// Both the parent and each non-destroyed child are decoded through the same
483/// blank-optional-dropping `serde_urlencoded` path
484/// [`ChangesetForm`](crate::form::ChangesetForm) uses, so string→typed
485/// coercion (`quantity=5` → `i32`) comes for free.
486///
487/// # Errors
488///
489/// Returns `Err(message)` when the **parent** fails to decode (a malformed,
490/// non-blank typed value) — mirroring the hard-400 contract of
491/// [`ChangesetForm`](crate::form::ChangesetForm). A **child** row that fails
492/// to parse is not a hard error: it is retained with a row-level error and
493/// marks the changeset invalid.
494pub fn decode_nested_urlencoded<P, C>(
495    pairs: &[(String, String)],
496) -> Result<NestedChangeset<P, C>, String>
497where
498    P: serde::de::DeserializeOwned + validator::Validate,
499    C: NestedChild,
500{
501    let collection = C::COLLECTION;
502
503    // Split parent pairs from grouped child subfields. `BTreeMap` keeps the
504    // rows in ascending index order; enumerating it later compacts gaps.
505    let mut parent_pairs: Vec<(String, String)> = Vec::new();
506    let mut child_groups: BTreeMap<usize, Vec<(String, String)>> = BTreeMap::new();
507    for (key, value) in pairs {
508        if let Some((idx, sub)) = parse_child_key(key, collection) {
509            child_groups
510                .entry(idx)
511                .or_default()
512                .push((sub.to_string(), value.clone()));
513        } else {
514            parent_pairs.push((key.clone(), value.clone()));
515        }
516    }
517
518    // Decode the parent through the shared blank-optional-dropping path.
519    let parent_encoded = encode_pairs(&parent_pairs);
520    let parent_data: P =
521        decode_urlencoded_dropping_blank_optional_fields::<P>(parent_encoded.as_bytes())
522            .map_err(|e| e.to_string())?;
523    let parent = parent_data.into_changeset();
524
525    let mut rows: Vec<NestedRow> = Vec::new();
526    let mut children: Vec<C> = Vec::new();
527    let mut all_children_ok = true;
528
529    for subfields in child_groups.into_values() {
530        let mut values: HashMap<String, String> = HashMap::new();
531        let mut destroyed = false;
532        // Subfields decoded into `C`, excluding the `_destroy` marker (not a
533        // field of `C`).
534        let mut decode_pairs: Vec<(String, String)> = Vec::new();
535        for (sub, val) in &subfields {
536            values.insert(sub.clone(), val.clone());
537            if sub == "_destroy" {
538                if is_truthy(val) {
539                    destroyed = true;
540                }
541            } else {
542                decode_pairs.push((sub.clone(), val.clone()));
543            }
544        }
545
546        // Rails `reject_if: :all_blank`: if every non-`_destroy` subfield value
547        // is blank (empty or whitespace-only after trimming), drop the row
548        // entirely. This is the auto-rendered blank template row `inputs_for`
549        // emits for the no-JS "add a child" path, not a real submitted child —
550        // treat it as if the index was never submitted: do not decode, do not
551        // validate, do not retain it, and do not count it toward the children.
552        // A row with at least one non-blank non-`_destroy` value is kept and
553        // validated as usual, so a partially filled row still surfaces its
554        // per-field errors. (`decode_pairs` already excludes `_destroy`, so an
555        // all-blank row that also carries `_destroy` is dropped here too — the
556        // same outcome as the destroy path.)
557        let all_blank = decode_pairs.iter().all(|(_, val)| val.trim().is_empty());
558        if all_blank {
559            continue;
560        }
561
562        let mut errors: HashMap<String, Vec<String>> = HashMap::new();
563
564        if destroyed {
565            rows.push(NestedRow {
566                values,
567                errors,
568                destroyed,
569            });
570            continue;
571        }
572
573        let encoded = encode_pairs(&decode_pairs);
574        match decode_urlencoded_dropping_blank_optional_fields::<C>(encoded.as_bytes()) {
575            Ok(child) => match validator::Validate::validate(&child) {
576                Ok(()) => children.push(child),
577                Err(ve) => {
578                    errors = validation_errors_to_map(&ve);
579                    all_children_ok = false;
580                }
581            },
582            Err(e) => {
583                // Row parse failure (deserialization failed before validation,
584                // e.g. `sku` filled but the required numeric `quantity` is
585                // malformed or present-but-blank). Key the message under the
586                // offending subfield so it surfaces as `items[i].{field}`,
587                // rendered by `errors_for("{field}")` next to the offending
588                // input rather than only under the row-level "" key.
589                //
590                // The blank-optional retry inside
591                // `decode_urlencoded_dropping_blank_optional_fields` can DROP a
592                // present-but-blank typed field (`quantity=`) before serde sees
593                // it, so the primary error is then `missing field \`quantity\``
594                // reported at the ROW ROOT with an empty path — losing the field
595                // name the row helpers need. `recover_child_error_field` recovers
596                // it by preferring the primary error's `missing field \`X\``
597                // message (with a raw non-dropping re-decode only as a last
598                // resort); see that helper. Only if all layers fail does it fall
599                // back to the row-level "" key, so the error is never silently
600                // dropped.
601                let field = recover_child_error_field::<C>(&e, &decode_pairs);
602                errors.entry(field).or_default().push(e.to_string());
603                all_children_ok = false;
604            }
605        }
606
607        rows.push(NestedRow {
608            values,
609            errors,
610            destroyed,
611        });
612    }
613
614    let valid_children = if parent.is_valid() && all_children_ok {
615        Some(children)
616    } else {
617        None
618    };
619
620    Ok(NestedChangeset {
621        parent,
622        rows,
623        valid_children,
624    })
625}
626
627/// Parse `key` as a child subfield reference `COLLECTION[<idx>][<sub>]`,
628/// returning `(idx, sub)`. Hand-parses the bracket pattern (no `regex`
629/// dependency), matching `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` with
630/// `COLLECTION` treated literally.
631fn parse_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
632    let rest = key.strip_prefix(collection)?;
633    let rest = rest.strip_prefix('[')?;
634    let close = rest.find(']')?;
635    let (idx_str, after) = rest.split_at(close);
636    let idx: usize = idx_str.parse().ok()?;
637    // `after` begins with the `]` that `close` pointed at.
638    let after = after.strip_prefix(']')?;
639    let after = after.strip_prefix('[')?;
640    let close2 = after.find(']')?;
641    let (sub, tail) = after.split_at(close2);
642    // The subfield must be the final segment: exactly a trailing `]`.
643    if tail != "]" || sub.is_empty() {
644        return None;
645    }
646    Some((idx, sub))
647}
648
649/// Parse a combined error key `COLLECTION[<idx>]` optionally followed by
650/// `.<sub>`, returning `(idx, sub)` where `sub` is `""` for the bare form.
651/// Returns `None` when the key is not a child key (so it falls through to the
652/// parent).
653fn parse_combined_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
654    let rest = key.strip_prefix(collection)?;
655    let rest = rest.strip_prefix('[')?;
656    let close = rest.find(']')?;
657    let (idx_str, after) = rest.split_at(close);
658    let idx: usize = idx_str.parse().ok()?;
659    let after = after.strip_prefix(']')?;
660    if after.is_empty() {
661        return Some((idx, ""));
662    }
663    let sub = after.strip_prefix('.')?;
664    if sub.is_empty() {
665        return None;
666    }
667    Some((idx, sub))
668}
669
670/// Re-encode `pairs` as an `application/x-www-form-urlencoded` string so the
671/// shared `serde_urlencoded` decode path can re-parse them.
672fn encode_pairs(pairs: &[(String, String)]) -> String {
673    url::form_urlencoded::Serializer::new(String::new())
674        .extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
675        .finish()
676}
677
678/// Last `Map`/`Enum` key in a `serde_path_to_error` path, if any is non-empty.
679///
680/// This is the subfield the decode failure is attributed to (e.g. `quantity`
681/// for `items[i][quantity]`). `Seq`/`Unknown` segments carry no field name.
682fn last_path_field(path: &serde_path_to_error::Path) -> Option<String> {
683    path.iter()
684        .filter_map(|seg| match seg {
685            serde_path_to_error::Segment::Map { key }
686            | serde_path_to_error::Segment::Enum { variant: key } => Some(key.clone()),
687            serde_path_to_error::Segment::Seq { .. } | serde_path_to_error::Segment::Unknown => {
688                None
689            }
690        })
691        .next_back()
692        .filter(|f| !f.is_empty())
693}
694
695/// Extract the field name from serde's stable "missing field" message — the
696/// backtick-quoted identifier in [`serde::de::Error::missing_field`]'s
697/// `Display` format — if present.
698fn missing_field_name(msg: &str) -> Option<String> {
699    let after = msg.split_once("missing field `")?.1;
700    let name = after.split_once('`')?.0;
701    (!name.is_empty()).then(|| name.to_owned())
702}
703
704/// Recover the offending subfield name for a child-row parse failure so the
705/// error can be keyed to `items[i].{field}` (rendered next to the input) rather
706/// than only under the row-level "" key.
707///
708/// Layered because the blank-optional retry inside
709/// [`decode_urlencoded_dropping_blank_optional_fields`] can DROP a
710/// present-but-blank typed field (`quantity=`) before serde ever sees it, so
711/// the primary decode reports a root-level missing-field error for `quantity`
712/// with an empty path — losing the field name the row helpers need:
713///
714/// 1. The primary decode's `serde_path_to_error` path (last `Map`/`Enum` key).
715///    For a malformed non-blank value (`quantity=abc`) this already yields the
716///    field, since that pair is never dropped.
717/// 2. If empty, the backtick-quoted identifier parsed from the PRIMARY error's
718///    missing-field message. The blank-dropping decode stops on the first typed
719///    field it can't satisfy, so this names the truly-required field even when
720///    an earlier OPTIONAL blank precedes it — e.g. a child with an optional
721///    `weight` before a required `quantity`, both submitted blank, reports the
722///    required `quantity` as missing, not `weight`. It also covers the
723///    present-but-blank required field (dropped → missing) and the field truly
724///    never submitted. This is preferred over the raw re-decode below precisely
725///    so the error lands on the required field, not a harmless optional blank a
726///    non-dropping pass would trip on first.
727/// 3. Only if BOTH are empty (a rare root-level error carrying neither a path
728///    nor a missing-field message), a raw re-decode over the ORIGINAL row pairs
729///    with blanks RETAINED (no dropping), mirroring the shared helper's
730///    deserializer minus the drop loop, and its last `Map`/`Enum` key.
731/// 4. Otherwise `""` (row-level key) so the error is never silently dropped.
732///
733/// This runs ONLY on the already-failed path to LOCATE the field; it does not
734/// affect the success path. Legitimately-blank optional fields still decode to
735/// `None` via the primary blank-dropping decode, which succeeds for them and
736/// never reaches here.
737fn recover_child_error_field<C: serde::de::DeserializeOwned>(
738    primary: &serde_path_to_error::Error<serde_urlencoded::de::Error>,
739    decode_pairs: &[(String, String)],
740) -> String {
741    // 1. Primary decode path (present-but-malformed value at a real path).
742    if let Some(field) = last_path_field(primary.path()) {
743        return field;
744    }
745
746    // 2. The `missing field \`X\`` identifier from the PRIMARY error — names
747    //    the required field the blank-dropping pass actually stopped on.
748    if let Some(field) = missing_field_name(&primary.inner().to_string()) {
749        return field;
750    }
751
752    // 3. Fallback: raw (non-dropping) re-decode over the original row pairs,
753    //    for the rare root-level error with neither a path nor a missing-field
754    //    message.
755    let encoded = encode_pairs(decode_pairs);
756    let deserializer =
757        serde_urlencoded::Deserializer::new(url::form_urlencoded::parse(encoded.as_bytes()));
758    if let Err(raw_err) = serde_path_to_error::deserialize::<_, C>(deserializer)
759        && let Some(field) = last_path_field(raw_err.path())
760    {
761        return field;
762    }
763
764    // 4. Row-level key.
765    String::new()
766}
767
768/// Whether a `_destroy` marker value counts as "destroy this row".
769fn is_truthy(value: &str) -> bool {
770    let trimmed = value.trim();
771    trimmed == "1" || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("on")
772}
773
774// ── NestedChangesetForm extractor ──────────────────────────────────
775
776/// Axum extractor that decodes a nested (`has_many`) form body, validates the
777/// parent and every non-destroyed child row, and captures the CSRF and
778/// submit-token context for re-rendering.
779///
780/// Mirrors [`ChangesetForm`](crate::form::ChangesetForm): errors live in the
781/// [`NestedChangeset`] rather than rejecting with 422; the handler decides how
782/// to respond. Only `application/x-www-form-urlencoded` bodies are accepted
783/// (multipart is a follow-up).
784pub struct NestedChangesetForm<P, C> {
785    /// The validated (or invalid) nested changeset.
786    pub changeset: NestedChangeset<P, C>,
787    csrf_token: Option<String>,
788    csrf_field: String,
789    submit_token: Option<String>,
790    submit_field: String,
791}
792
793impl<P, C: NestedChild> NestedChangesetForm<P, C> {
794    /// Build a blank nested-form context for the initial `new` (create) GET
795    /// render, before any submission.
796    ///
797    /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
798    /// wraps `parent` in a **non-validating** [`NestedChangeset::blank`] (no
799    /// child rows, no errors) so the initial page renders clean — no premature
800    /// "field is required" message or `aria-invalid="true"` before the user has
801    /// typed. For an **edit** render that must show existing children, use
802    /// [`seeded`](Self::seeded) instead (`blank` renders zero child rows).
803    /// Contrast the POST path (the [`NestedChangesetForm`] extractor /
804    /// [`decode_nested_urlencoded`]), which validates the submission and
805    /// re-renders inline errors.
806    ///
807    /// `csrf_token` is the token from a `CsrfToken` extractor, or `None` when
808    /// CSRF middleware is not active. The CSRF and submit-token field names
809    /// default to `_csrf` / `_submit_token` (exactly what the extractor falls
810    /// back to when the corresponding config extensions are absent); when the
811    /// app customizes `security.csrf.form_field`, set it with
812    /// [`with_csrf_field`](Self::with_csrf_field) so
813    /// [`form_tag`](Self::form_tag) emits the right hidden field name.
814    ///
815    /// The submit token starts `None`, so a bare `blank(..).form_tag(..)` emits
816    /// **no** submit-token hidden input and the first submission is not protected
817    /// against double-submit ([`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
818    /// passes tokenless mutating requests through). Supply the minted token on the
819    /// initial GET with [`with_submit_token`](Self::with_submit_token) (and
820    /// [`with_submit_field`](Self::with_submit_field) if the field name is
821    /// customized) so the **first** submit carries a token too:
822    ///
823    /// ```rust,ignore
824    /// #[get("/orders/new")]
825    /// async fn new_order(csrf: CsrfToken, submit: SubmitToken) -> Markup {
826    ///     let form = NestedChangesetForm::<NewOrder, NewLineItem>::blank(
827    ///         NewOrder::default(),
828    ///         Some(csrf.token().to_owned()),
829    ///     )
830    ///     .with_submit_token(Some(submit.token().to_owned()));
831    ///     form.form_tag("/orders", "post", /* … */)
832    /// }
833    /// ```
834    #[must_use]
835    pub fn blank(parent: P, csrf_token: Option<String>) -> Self {
836        Self {
837            changeset: NestedChangeset::blank(parent),
838            csrf_token,
839            csrf_field: "_csrf".to_owned(),
840            submit_token: None,
841            submit_field: "_submit_token".to_owned(),
842        }
843    }
844
845    /// Wrap a pre-built [`NestedChangeset`] (which may already carry validation
846    /// errors) in a form for rendering, with no CSRF/submit token.
847    ///
848    /// Mirrors [`ChangesetForm::from_changeset`](crate::form::ChangesetForm::from_changeset):
849    /// useful in tests and cases where a `NestedChangeset` was produced
850    /// externally (e.g. via [`decode_nested_urlencoded`]) before constructing a
851    /// form for re-render. The CSRF/submit-token field names default to
852    /// `_csrf` / `_submit_token`; add a token with
853    /// [`with_csrf_field`](Self::with_csrf_field) as needed.
854    #[must_use]
855    pub fn from_changeset(changeset: NestedChangeset<P, C>) -> Self {
856        Self {
857            changeset,
858            csrf_token: None,
859            csrf_field: "_csrf".to_owned(),
860            submit_token: None,
861            submit_field: "_submit_token".to_owned(),
862        }
863    }
864
865    /// Override the CSRF form-field name used by [`form_tag`](Self::form_tag).
866    ///
867    /// Mirrors
868    /// [`ChangesetForm::with_csrf_field`](crate::form::ChangesetForm::with_csrf_field):
869    /// call this on a [`blank`](Self::blank) GET-handler form when
870    /// `security.csrf.form_field` is customized (e.g. `"authenticity_token"`).
871    /// The extractor captures the configured name automatically on the POST
872    /// path.
873    #[must_use]
874    pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
875        self.csrf_field = field.into();
876        self
877    }
878
879    /// Supply the one-time submit token to a [`blank`](Self::blank) (or
880    /// [`from_changeset`](Self::from_changeset)) GET-handler form so
881    /// [`form_tag`](Self::form_tag) emits the hidden submit-token input on the
882    /// **initial** render — protecting the very first submission against
883    /// double-submit, not just later 422 re-renders.
884    ///
885    /// [`blank`](Self::blank) leaves this `None` (the initial page renders no
886    /// submit-token field otherwise), and [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
887    /// passes tokenless mutating requests through unchanged — so without calling
888    /// this the first create-form submit is unprotected. Source the token from a
889    /// [`SubmitToken`](crate::security::SubmitToken) extractor on the GET handler.
890    /// The extractor captures it automatically on the POST re-render path.
891    ///
892    /// When the app customizes `security.submit_token.field_name`, pair this with
893    /// [`with_submit_field`](Self::with_submit_field) so the hidden input carries
894    /// the right name.
895    #[must_use]
896    pub fn with_submit_token(mut self, token: Option<String>) -> Self {
897        self.submit_token = token;
898        self
899    }
900
901    /// Override the submit-token form-field name used by
902    /// [`form_tag`](Self::form_tag).
903    ///
904    /// Mirrors [`with_csrf_field`](Self::with_csrf_field): call this on a
905    /// [`blank`](Self::blank) GET-handler form when
906    /// `security.submit_token.field_name` is customized (the default is
907    /// `_submit_token`). The extractor captures the configured name automatically
908    /// on the POST path.
909    #[must_use]
910    pub fn with_submit_field(mut self, field: impl Into<String>) -> Self {
911        self.submit_field = field.into();
912        self
913    }
914
915    /// The CSRF token captured from the request, if the CSRF middleware is active.
916    #[must_use]
917    pub fn csrf_token(&self) -> Option<&str> {
918        self.csrf_token.as_deref()
919    }
920
921    /// The CSRF form-field name (honours `security.csrf.form_field`).
922    #[must_use]
923    pub fn csrf_field(&self) -> &str {
924        &self.csrf_field
925    }
926
927    /// The one-time submit token captured from the request, if the
928    /// submit-token middleware is active.
929    #[must_use]
930    pub fn submit_token(&self) -> Option<&str> {
931        self.submit_token.as_deref()
932    }
933
934    /// The submit-token form-field name (honours
935    /// `security.submit_token.field_name`).
936    #[must_use]
937    pub fn submit_field(&self) -> &str {
938        &self.submit_field
939    }
940
941    /// Consume and return only the inner [`NestedChangeset`].
942    pub fn into_changeset(self) -> NestedChangeset<P, C> {
943        self.changeset
944    }
945
946    /// Return `Ok((parent, children))` when valid, `Err(self)` when not.
947    ///
948    /// The `Err` branch retains the CSRF/submit context so the handler can
949    /// immediately re-render the form with inline errors.
950    ///
951    /// # Errors
952    ///
953    /// Returns `Err(self)` when the inner changeset has validation errors.
954    #[allow(
955        clippy::result_large_err,
956        reason = "the Err variant intentionally returns the whole form (changeset + CSRF/submit \
957                  context) so the handler can re-render inline with errors"
958    )]
959    pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
960        let Self {
961            changeset,
962            csrf_token,
963            csrf_field,
964            submit_token,
965            submit_field,
966        } = self;
967        match changeset.into_valid() {
968            Ok(pair) => Ok(pair),
969            Err(changeset) => Err(Self {
970                changeset,
971                csrf_token,
972                csrf_field,
973                submit_token,
974                submit_field,
975            }),
976        }
977    }
978}
979
980/// Edit-render seeding for the form context — mirrors
981/// [`NestedChangeset::seeded`], carrying the extra [`serde::Serialize`] bound on
982/// this `impl` block alone.
983impl<P, C: NestedChild + serde::Serialize> NestedChangesetForm<P, C> {
984    /// Build a nested-form context for the initial `edit` GET render,
985    /// pre-populated with the existing persisted `children`.
986    ///
987    /// Mirrors [`blank`](Self::blank)'s CSRF handling (the CSRF/submit-token
988    /// field names default to `_csrf` / `_submit_token`, and the submit token
989    /// starts `None`), but wraps [`NestedChangeset::seeded`] instead of
990    /// [`NestedChangeset::blank`]: the changeset pre-renders one row per existing
991    /// child so the edit page shows and preserves current line items — with their
992    /// `id`s carried as hidden inputs (via [`RowScope::hidden_input`]) — and the
993    /// no-JS `_destroy` removal of an existing child works before the first
994    /// submit.
995    ///
996    /// The same builder methods used with [`blank`](Self::blank) apply here:
997    /// [`with_csrf_field`](Self::with_csrf_field) for a customized
998    /// `security.csrf.form_field`, and [`with_submit_token`](Self::with_submit_token)
999    /// / [`with_submit_field`](Self::with_submit_field) to emit the one-time
1000    /// submit-token hidden input on the initial edit render. `csrf_token` is the
1001    /// token from a `CsrfToken` extractor, or `None` when CSRF middleware is not
1002    /// active.
1003    #[must_use]
1004    pub fn seeded(parent: P, children: Vec<C>, csrf_token: Option<String>) -> Self {
1005        Self {
1006            changeset: NestedChangeset::seeded(parent, children),
1007            csrf_token,
1008            csrf_field: "_csrf".to_owned(),
1009            submit_token: None,
1010            submit_field: "_submit_token".to_owned(),
1011        }
1012    }
1013}
1014
1015/// Maud rendering — emit the `<form>` open tag with the **captured** CSRF (and
1016/// submit-token) hidden fields injected.
1017#[cfg(feature = "maud")]
1018impl<P, C: NestedChild> NestedChangesetForm<P, C> {
1019    /// Render a `<form>` element wrapping `content`, injecting the CSRF hidden
1020    /// input under the **captured** field name (honouring
1021    /// `security.csrf.form_field`) and — when present — the one-time
1022    /// submit-token hidden input under its captured field name.
1023    ///
1024    /// Mirrors [`ChangesetForm::form_tag`](crate::form::ChangesetForm::form_tag),
1025    /// including the `PUT`/`PATCH`/`DELETE` → hidden `_method` override. **Prefer
1026    /// this over the standalone [`crate::form::form_tag`]** for a nested-form
1027    /// re-render: the standalone helper hardcodes the default `_csrf` field name
1028    /// and would emit the wrong hidden field for an app that customized
1029    /// `security.csrf.form_field`, so the next submit's CSRF check would reject
1030    /// the form. This method uses the field name the extractor captured (or the
1031    /// one set via [`with_csrf_field`](Self::with_csrf_field) on a
1032    /// [`blank`](Self::blank) form), keeping CSRF parity across the re-render.
1033    ///
1034    /// The submit-token hidden input is emitted only when a submit token is
1035    /// present. The POST re-render path captures it automatically; on the initial
1036    /// GET render from [`blank`](Self::blank), supply it with
1037    /// [`with_submit_token`](Self::with_submit_token) so the **first** submit is
1038    /// protected against double-submit too — otherwise a bare
1039    /// `blank(..).form_tag(..)` create form carries no submit token and its first
1040    /// submission passes through [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1041    /// unprotected.
1042    #[must_use]
1043    #[allow(clippy::needless_pass_by_value)]
1044    pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
1045        crate::form::form_tag_inner(
1046            action,
1047            method,
1048            &self.csrf_field,
1049            self.csrf_token.as_deref(),
1050            None,
1051            maud::html! {
1052                @if let Some(token) = self.submit_token.as_deref() {
1053                    input type="hidden" name=(self.submit_field) value=(token);
1054                }
1055                (content)
1056            },
1057        )
1058    }
1059}
1060
1061/// Dereferences to [`NestedChangeset<P, C>`] so all changeset methods are
1062/// available directly on the form (`form.is_valid()`, `form.errors_for(…)`,
1063/// `form.rows()`, …).
1064impl<P, C> std::ops::Deref for NestedChangesetForm<P, C> {
1065    type Target = NestedChangeset<P, C>;
1066    fn deref(&self) -> &Self::Target {
1067        &self.changeset
1068    }
1069}
1070
1071impl<S, P, C> FromRequest<S> for NestedChangesetForm<P, C>
1072where
1073    S: Send + Sync,
1074    P: serde::de::DeserializeOwned + validator::Validate + Send,
1075    C: NestedChild,
1076{
1077    type Rejection = axum::response::Response;
1078
1079    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
1080        // Capture CSRF + submit-token context from request extensions exactly
1081        // as `ChangesetForm` does, before the body is consumed.
1082        let csrf_token = req
1083            .extensions()
1084            .get::<crate::security::CsrfToken>()
1085            .map(|t| t.token().to_string());
1086        let csrf_field = req
1087            .extensions()
1088            .get::<crate::security::csrf::CsrfFormField>()
1089            .map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
1090        let submit_token = req
1091            .extensions()
1092            .get::<crate::security::SubmitToken>()
1093            .map(|t| t.token().to_string());
1094        let submit_field = req
1095            .extensions()
1096            .get::<crate::security::SubmitFormField>()
1097            .map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
1098
1099        // Same content-type gate axum's own form extractors apply. Multipart
1100        // is out of scope for now (follow-up).
1101        let content_type = req
1102            .headers()
1103            .get(http::header::CONTENT_TYPE)
1104            .and_then(|v| v.to_str().ok())
1105            .unwrap_or_default()
1106            .to_string();
1107        if !content_type.starts_with("application/x-www-form-urlencoded") {
1108            return Err((
1109                axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1110                "Nested form requests must have `Content-Type: application/x-www-form-urlencoded`",
1111            )
1112                .into_response());
1113        }
1114
1115        // Buffer through axum's `Bytes` extractor so `DefaultBodyLimit` is
1116        // enforced (a bare `to_bytes(.., usize::MAX)` would defeat it).
1117        let (parts, body) = req.into_parts();
1118        let bytes_req = Request::from_parts(parts, body);
1119        let bytes = axum::body::Bytes::from_request(bytes_req, state)
1120            .await
1121            .map_err(IntoResponse::into_response)?;
1122
1123        let pairs: Vec<(String, String)> = url::form_urlencoded::parse(&bytes)
1124            .map(|(k, v)| (k.into_owned(), v.into_owned()))
1125            .collect();
1126
1127        let changeset = decode_nested_urlencoded::<P, C>(&pairs)
1128            .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e).into_response())?;
1129
1130        Ok(Self {
1131            changeset,
1132            csrf_token,
1133            csrf_field,
1134            submit_token,
1135            submit_field,
1136        })
1137    }
1138}
1139
1140// ── Maud view helpers ──────────────────────────────────────────────
1141
1142/// A single row's rendering scope inside [`inputs_for`].
1143///
1144/// Each scope binds the child collection name, the row's 0-based `index`, and
1145/// (for existing/submitted rows) the underlying [`NestedRow`] so its raw values
1146/// pre-fill inputs and its per-subfield errors render inline on re-render after
1147/// a failed submission. A blank template row carries `row: None` — its inputs
1148/// render empty with no error blocks.
1149///
1150/// The row-scoped input builders mirror the standalone field helpers in
1151/// [`crate::form`] (`text_input`, `number_input`, …) but emit **nested** input
1152/// names (`items[{index}][{sub}]`) and per-row-unique element ids
1153/// (`items-{index}-{sub}`) so ids and `aria-describedby` links stay unique
1154/// across repeated rows.
1155#[cfg(feature = "maud")]
1156pub struct RowScope<'a> {
1157    collection: &'a str,
1158    index: usize,
1159    row: Option<&'a NestedRow>,
1160}
1161
1162#[cfg(feature = "maud")]
1163impl RowScope<'_> {
1164    /// This row's 0-based position in the collection.
1165    #[must_use]
1166    pub const fn index(&self) -> usize {
1167        self.index
1168    }
1169
1170    /// The nested input `name` for subfield `sub`, i.e.
1171    /// `"{collection}[{index}][{sub}]"`.
1172    #[must_use]
1173    pub fn field_name(&self, sub: &str) -> String {
1174        format!("{}[{}][{}]", self.collection, self.index, sub)
1175    }
1176
1177    /// The raw submitted value for subfield `sub`, or `None` for a blank row.
1178    #[must_use]
1179    pub fn value(&self, sub: &str) -> Option<&str> {
1180        self.row.and_then(|r| r.value(sub))
1181    }
1182
1183    /// Validation messages for subfield `sub`, or an empty slice (always empty
1184    /// for a blank row).
1185    #[must_use]
1186    pub fn errors_for(&self, sub: &str) -> &[String] {
1187        self.row.map_or(&[], |r| r.errors_for(sub))
1188    }
1189
1190    /// `true` when this (existing) row carried a truthy `_destroy` marker.
1191    #[must_use]
1192    pub fn is_destroyed(&self) -> bool {
1193        self.row.is_some_and(NestedRow::is_destroyed)
1194    }
1195
1196    /// Per-row-unique element id base for subfield `sub`
1197    /// (`"{collection}-{index}-{sub}"`), used for `id` / `aria-describedby`
1198    /// linkage so repeated rows never collide.
1199    fn element_id(&self, sub: &str) -> String {
1200        format!("{}-{}-{}", self.collection, self.index, sub)
1201    }
1202
1203    /// Render a labeled row-scoped `<input type="text">` for subfield `sub`.
1204    ///
1205    /// Mirrors [`crate::form::text_input`]: `autumn-field` wrapper, per-row
1206    /// pre-fill, `aria-invalid` / `aria-describedby`, and a `role="alert"`
1207    /// error block — but with the nested `name` and a per-row-unique `id`.
1208    #[must_use]
1209    pub fn text_input(&self, sub: &str, label: &str) -> maud::Markup {
1210        self.text_like_input(sub, label, false)
1211    }
1212
1213    /// Like [`RowScope::text_input`] but adds `required` + `aria-required="true"`
1214    /// — **only for real/submitted rows that are not marked for destruction**
1215    /// (`self.row.is_some() && !self.is_destroyed()`).
1216    ///
1217    /// A blank template row (`row: None`, the one [`inputs_for`] auto-appends
1218    /// for the no-JS "add a child" path) deliberately renders the *same* input
1219    /// **without** the client-side `required`/`aria-required` attributes so it
1220    /// stays leaveable-empty. Otherwise the browser's native constraint
1221    /// validation would block form submit on the empty trailing template row
1222    /// *before* the server's all-blank rejection can run — a user who filled one
1223    /// child row could not submit while a blank row sat beneath it. A submitted
1224    /// row the user ticked `_destroy` on is skipped for the same reason: the
1225    /// decoder drops destroyed rows before validation (and from `into_valid()`),
1226    /// so re-emitting client-side `required` on an empty destroyed field would
1227    /// let the browser block the next submit before the server can honor the
1228    /// removal. Required-ness is still enforced server-side for any row the user
1229    /// actually engages: a partially-filled, non-destroyed row is retained as a
1230    /// `Some` row on re-render and re-acquires `required`, and the decoder's
1231    /// all-blank rejection drops the untouched template row. This composes with
1232    /// that all-blank rejection so
1233    /// neither the client nor the server treats the template row as a real child.
1234    #[must_use]
1235    pub fn required_text_input(&self, sub: &str, label: &str) -> maud::Markup {
1236        self.text_like_input(sub, label, true)
1237    }
1238
1239    /// Shared body for the text-like inputs.
1240    fn text_like_input(&self, sub: &str, label: &str, required: bool) -> maud::Markup {
1241        // Emit the client-side `required`/`aria-required` constraint only for a
1242        // real/submitted row that is *not* marked for destruction. A blank
1243        // template row (`row: None`) must stay leaveable-empty so the browser
1244        // does not block submit on the trailing auto-appended blank row before
1245        // the server's all-blank rejection runs; server-side validation still
1246        // enforces required-ness for any row the user actually fills (it is
1247        // retained as a `Some` row on re-render). A submitted row the user
1248        // ticked `_destroy` on is likewise skipped: the decoder drops destroyed
1249        // rows before validation, so re-emitting client-side `required` on an
1250        // empty destroyed field would let the browser's constraint validation
1251        // block the next submit before the server can honor `_destroy`.
1252        let required = required && self.row.is_some() && !self.is_destroyed();
1253        let errors = self.errors_for(sub);
1254        let has_errors = !errors.is_empty();
1255        let value = self.value(sub).unwrap_or_default();
1256        let name = self.field_name(sub);
1257        let id = self.element_id(sub);
1258        let error_id = format!("{id}-error");
1259        let wrapper_id = format!("{id}-field");
1260
1261        maud::html! {
1262            div id=(wrapper_id) class="autumn-field" {
1263                label for=(id) class="autumn-field__label" { (label) }
1264                input
1265                    type="text"
1266                    id=(id)
1267                    name=(name)
1268                    value=(value)
1269                    required[required]
1270                    aria-required=[required.then_some("true")]
1271                    class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
1272                    aria-invalid=(if has_errors { "true" } else { "false" })
1273                    aria-describedby=(if has_errors { error_id.as_str() } else { "" });
1274                @if has_errors {
1275                    div id=(error_id) role="alert" class="autumn-field__errors" {
1276                        @for error in errors {
1277                            p class="autumn-field__error" { (error) }
1278                        }
1279                    }
1280                }
1281            }
1282        }
1283    }
1284
1285    /// Render a labeled row-scoped `<input type="number">` for subfield `sub`.
1286    ///
1287    /// Mirrors [`crate::form::number_input`] (leaving the browser-default
1288    /// `step`), with the nested `name` and a per-row-unique `id`.
1289    #[must_use]
1290    pub fn number_input(&self, sub: &str, label: &str) -> maud::Markup {
1291        let errors = self.errors_for(sub);
1292        let has_errors = !errors.is_empty();
1293        let value = self.value(sub).unwrap_or_default();
1294        let name = self.field_name(sub);
1295        let id = self.element_id(sub);
1296        let error_id = format!("{id}-error");
1297        let wrapper_id = format!("{id}-field");
1298
1299        maud::html! {
1300            div id=(wrapper_id) class="autumn-field" {
1301                label for=(id) class="autumn-field__label" { (label) }
1302                input
1303                    type="number"
1304                    id=(id)
1305                    name=(name)
1306                    value=(value)
1307                    class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
1308                    aria-invalid=(if has_errors { "true" } else { "false" })
1309                    aria-describedby=(if has_errors { error_id.as_str() } else { "" });
1310                @if has_errors {
1311                    div id=(error_id) role="alert" class="autumn-field__errors" {
1312                        @for error in errors {
1313                            p class="autumn-field__error" { (error) }
1314                        }
1315                    }
1316                }
1317            }
1318        }
1319    }
1320
1321    /// Render a labeled row-scoped `<textarea>` for subfield `sub`.
1322    ///
1323    /// Mirrors [`crate::form::textarea_input`]: the value is emitted as the
1324    /// element body, with the nested `name` and a per-row-unique `id`.
1325    #[must_use]
1326    pub fn textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
1327        let errors = self.errors_for(sub);
1328        let has_errors = !errors.is_empty();
1329        let value = self.value(sub).unwrap_or_default();
1330        let name = self.field_name(sub);
1331        let id = self.element_id(sub);
1332        let error_id = format!("{id}-error");
1333        let wrapper_id = format!("{id}-field");
1334
1335        maud::html! {
1336            div id=(wrapper_id) class="autumn-field" {
1337                label for=(id) class="autumn-field__label" { (label) }
1338                textarea
1339                    id=(id)
1340                    name=(name)
1341                    class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
1342                    aria-invalid=(if has_errors { "true" } else { "false" })
1343                    aria-describedby=(if has_errors { error_id.as_str() } else { "" })
1344                    { (value) }
1345                @if has_errors {
1346                    div id=(error_id) role="alert" class="autumn-field__errors" {
1347                        @for error in errors {
1348                            p class="autumn-field__error" { (error) }
1349                        }
1350                    }
1351                }
1352            }
1353        }
1354    }
1355
1356    /// Render a row-scoped `<input type="hidden">` for subfield `sub`.
1357    ///
1358    /// Use this to carry an existing child's primary key (e.g. `id`) on an edit
1359    /// form so the decoder can match the submitted row back to a persisted record.
1360    #[must_use]
1361    pub fn hidden_input(&self, sub: &str, value: &str) -> maud::Markup {
1362        let name = self.field_name(sub);
1363        maud::html! {
1364            input type="hidden" name=(name) value=(value);
1365        }
1366    }
1367
1368    /// Render the durable no-JS removal control: a `_destroy` checkbox whose
1369    /// checked state is preserved on re-render (`checked` iff
1370    /// [`RowScope::is_destroyed`]).
1371    ///
1372    /// The decoder honours a truthy `_destroy` marker
1373    /// ([`decode_nested_urlencoded`]), so ticking this box and submitting the
1374    /// surrounding form removes the row with no JavaScript. htmx/JS row removal
1375    /// (swapping the `.nested-fields__row` node's `outerHTML`) is an optional
1376    /// progressive enhancement layered on top; this checkbox is the required
1377    /// mechanism.
1378    #[must_use]
1379    pub fn destroy_checkbox(&self, label: &str) -> maud::Markup {
1380        let checked = self.is_destroyed();
1381        let name = self.field_name("_destroy");
1382        let id = self.element_id("_destroy");
1383        maud::html! {
1384            div class="autumn-field autumn-field--destroy" {
1385                input
1386                    type="checkbox"
1387                    id=(id)
1388                    name=(name)
1389                    value="1"
1390                    checked[checked]
1391                    class="autumn-field__checkbox";
1392                label for=(id) class="autumn-field__label" { (label) }
1393            }
1394        }
1395    }
1396}
1397
1398/// Options for [`inputs_for`].
1399#[cfg(feature = "maud")]
1400pub struct InputsForOptions {
1401    /// Number of extra blank rows to pre-render after the existing rows. The
1402    /// no-JS fallback lets users fill and submit these without any JavaScript.
1403    /// Defaults to `1`; [`inputs_for`] always emits **at least one** blank
1404    /// template row even when this is `0`.
1405    pub blank_rows: usize,
1406    /// Optional htmx URL for the server "Add row" fragment endpoint (see
1407    /// [`nested_row_fragment`]). When `None`, no Add button is rendered and the
1408    /// no-JS path still works via the pre-rendered blank rows.
1409    pub add_row_url: Option<String>,
1410    /// Container element id. Defaults to `"{collection}-rows"`.
1411    ///
1412    /// This id is load-bearing when [`add_row_url`](Self::add_row_url) is set:
1413    /// the Add-row button's `hx-target` and its `hx-vals` index scan both select
1414    /// `#{container_id}`. HTML ids must be unique in a document, so **when two or
1415    /// more nested forms for the same child collection appear on one page, each
1416    /// MUST pass a distinct `container_id`.** The `{collection}-rows` default is
1417    /// correct for a single such form per page; leaving two same-collection forms
1418    /// on the default id produces duplicate ids (invalid HTML) and makes each
1419    /// Add-row button resolve against — and append rows / compute indices from —
1420    /// whichever container the shared id happens to match first, i.e. the wrong
1421    /// form. Distinct ids are the only requirement; any stable, unique string
1422    /// (e.g. `"shipping-items-rows"` vs `"billing-items-rows"`) works.
1423    pub container_id: Option<String>,
1424}
1425
1426#[cfg(feature = "maud")]
1427impl Default for InputsForOptions {
1428    fn default() -> Self {
1429        Self {
1430            blank_rows: 1,
1431            add_row_url: None,
1432            container_id: None,
1433        }
1434    }
1435}
1436
1437/// Render the repeating child field-group block for a nested (`has_many`) form.
1438///
1439/// Wraps the rows in `<div id="{container_id}" class="nested-fields">`. For each
1440/// existing/submitted row (from [`NestedChangeset::rows`], in order — **including**
1441/// rows re-submitted after a validation failure) it invokes `render_row` with a
1442/// [`RowScope`] carrying that row, so values and per-field errors pre-fill on
1443/// re-render. It then appends [`InputsForOptions::blank_rows`] blank rows (always
1444/// at least one) whose scopes carry `row: None`. Every row is wrapped in
1445/// `<div class="nested-fields__row" data-index="{i}">` so htmx/JS removal can
1446/// target the node's `outerHTML`.
1447///
1448/// The appended blank template row makes adding a child work with **no
1449/// JavaScript**, and it is safe even though the browser submits its empty
1450/// inputs: [`decode_nested_urlencoded`] applies Rails-style
1451/// `reject_if: :all_blank`, so a child row whose every non-`_destroy` subfield
1452/// is blank is ignored rather than decoded as a phantom child. An untouched
1453/// blank row therefore never blocks submission or persists an empty child.
1454///
1455/// So the no-JS path stays submittable, the appended blank template row's inputs
1456/// omit the client-side `required`/`aria-required` constraint by design (see
1457/// [`RowScope::required_text_input`]): a required-variant input renders empty and
1458/// *leaveable* on a `row: None` template row, so the browser's native validation
1459/// does not block submitting a form that still carries a trailing blank row.
1460/// This composes with the decoder's all-blank rejection — the untouched template
1461/// row is dropped server-side — while server-side validation still enforces
1462/// required-ness for any row the user actually fills.
1463///
1464/// When [`InputsForOptions::add_row_url`] is `Some`, an "Add row"
1465/// `<button type="button">` is emitted with `hx-get`, `hx-target="#{container_id}"`,
1466/// `hx-swap="beforeend"`, and — critically — `hx-params="index"` so **only**
1467/// the `index` param (supplied by the `hx-vals` below) is serialized into the
1468/// Add-row GET; every parent + line-item field, and `_csrf`/submit token, is
1469/// dropped, avoiding leaks into the request query string / proxy logs. `"none"`
1470/// can NOT be used: the vendored htmx 2.0.4 merges `hx-vals` into the request
1471/// `FormData` *before* the `hx-params` filter runs, so `"none"` (an empty
1472/// `FormData`) would strip the computed `index` too — naming `index` keeps it.
1473///
1474/// The button also carries an `hx-vals` (`js:` form) that computes a fresh
1475/// `index` for each request — `max(existing data-index) + 1` scanning only
1476/// `#{container_id} .nested-fields__row[data-index]` (scoped by the container
1477/// id so a form reads its own rows). The endpoint should read that `index`
1478/// param and pass it to [`nested_row_fragment`] so every appended row gets a
1479/// name unique within the form; a static request would reuse the same index and
1480/// emit duplicate control names. This is the JS-present enhancement path only —
1481/// the no-JS fallback (pre-rendered blank rows) needs no such counter and is
1482/// unaffected.
1483///
1484/// # Multiple same-collection forms on one page
1485///
1486/// Both the Add-row `hx-target` and its `hx-vals` index scan address the
1487/// container by its `#{container_id}`. That scoping is only distinct if the ids
1488/// are distinct, so **every same-collection nested form that also sets
1489/// `add_row_url` must be given its own [`InputsForOptions::container_id`]** when
1490/// more than one appears on a page. The default `{collection}-rows` id is right
1491/// for a single such form; two forms sharing it emit duplicate HTML ids and each
1492/// Add-row button then targets / scans whichever container the shared id resolves
1493/// to first (the wrong form). A relative, id-free `hx-vals` scan was evaluated
1494/// and rejected: the vendored htmx 2.0.4 evaluates a `js:` `hx-vals` expression
1495/// with `Function("return (" + expr + ")")()` — no receiver — so `this` is the
1496/// global object, not the triggering button, leaving no reliable handle to walk
1497/// to the button's own container. The unique-id contract above is therefore the
1498/// supported way to keep several same-collection forms independent.
1499///
1500/// # CSRF
1501///
1502/// This renders only the child block. The surrounding `<form>` — via
1503/// [`crate::form::form_tag`] / `ChangesetForm` — carries the CSRF and submit-token
1504/// fields exactly as today; do **not** duplicate them here.
1505///
1506/// # Example
1507///
1508/// ```rust,ignore
1509/// use autumn_web::form::{required_text_input, submit_button};
1510/// use autumn_web::nested_form::{inputs_for, InputsForOptions};
1511///
1512/// // `form` is a `NestedChangesetForm<NewOrder, NewLineItem>` re-rendered after
1513/// // a failed submit; it derefs to its inner `NestedChangeset`.
1514/// let opts = InputsForOptions {
1515///     add_row_url: Some("/orders/line-item-row".into()),
1516///     ..InputsForOptions::default()
1517/// };
1518/// // Prefer `form.form_tag` over the standalone `form_tag`: it emits the CSRF
1519/// // hidden field under the app-configured field name (and the submit token),
1520/// // so a customized `security.csrf.form_field` survives the re-render.
1521/// form.form_tag("/orders", "POST", maud::html! {
1522///     // Parent fields (CSRF + submit token are emitted by `form.form_tag`).
1523///     (required_text_input(&form.parent, "name", "Order name"))
1524///     // Repeating child rows.
1525///     (inputs_for(&form, &opts, |row| maud::html! {
1526///         (row.required_text_input("sku", "SKU"))
1527///         (row.number_input("quantity", "Quantity"))
1528///         (row.destroy_checkbox("Remove"))
1529///     }))
1530///     (submit_button("Create order"))
1531/// });
1532/// ```
1533#[cfg(feature = "maud")]
1534#[must_use]
1535pub fn inputs_for<P, C: NestedChild>(
1536    nested: &NestedChangeset<P, C>,
1537    opts: &InputsForOptions,
1538    render_row: impl Fn(&RowScope) -> maud::Markup,
1539) -> maud::Markup {
1540    let collection = C::COLLECTION;
1541    let container_id = opts
1542        .container_id
1543        .clone()
1544        .unwrap_or_else(|| format!("{collection}-rows"));
1545    let rows = nested.rows();
1546    // Always emit at least one blank template row so the no-JS path can add a
1547    // child even when the caller asked for zero.
1548    let blank_count = opts.blank_rows.max(1);
1549
1550    maud::html! {
1551        div id=(container_id) class="nested-fields" {
1552            @for (i, row) in rows.iter().enumerate() {
1553                @let scope = RowScope { collection, index: i, row: Some(row) };
1554                div class="nested-fields__row" data-index=(i) {
1555                    (render_row(&scope))
1556                }
1557            }
1558            @for k in 0..blank_count {
1559                @let index = rows.len() + k;
1560                @let scope = RowScope { collection, index, row: None };
1561                div class="nested-fields__row" data-index=(index) {
1562                    (render_row(&scope))
1563                }
1564            }
1565            @if let Some(url) = &opts.add_row_url {
1566                // Send a *fresh* unique row index with every Add-row request.
1567                // `nested_row_fragment` requires the returned row's index to be
1568                // unique within this form; a static request would keep reusing
1569                // the same index and produce duplicate control names. Compute
1570                // `max(existing data-index) + 1` from this container's rows,
1571                // scoped by `#{container_id}`. That scoping is only distinct when
1572                // the id is distinct: callers rendering more than one
1573                // same-collection nested form on a page MUST give each a unique
1574                // `container_id` (see `InputsForOptions::container_id`), because
1575                // the vendored htmx 2.0.4 evaluates this `js:` `hx-vals` with no
1576                // `this`/`event` bound to the button, so an id-free relative walk
1577                // to the button's own container is not available. The decoder
1578                // tolerates gaps, so this stays collision-free even after
1579                // client-side row removals.
1580                //
1581                // `hx-params="index"` serializes ONLY the `index` param with
1582                // the Add-row GET: per htmx semantics a bare comma-list keeps
1583                // just the named params, so every parent + line-item field
1584                // (and `_csrf`/submit token) is dropped from the request query
1585                // string / proxy logs, avoiding leaks and URL-length limits.
1586                // We can NOT use `"none"`: in the vendored htmx 2.0.4 the
1587                // `hx-vals` values are merged into the request FormData BEFORE
1588                // the `hx-params` filter runs (see `htmx.min.js`: the request
1589                // builder does `const v=ln(j,V);let w=hn(v,r)` where `V` is the
1590                // resolved `hx-vals` and `hn` is `filterValues`), so `"none"`
1591                // (which returns an empty FormData) would strip the `index` we
1592                // compute here too. Naming `index` keeps exactly that one value.
1593                @let hx_vals = format!(
1594                    "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}}"
1595                );
1596                button
1597                    type="button"
1598                    class="nested-fields__add"
1599                    hx-get=(url)
1600                    hx-target=(format!("#{container_id}"))
1601                    hx-swap="beforeend"
1602                    hx-params="index"
1603                    hx-vals=(hx_vals)
1604                { "Add row" }
1605            }
1606        }
1607    }
1608}
1609
1610/// Render a single blank child row for the htmx "Add row" fragment endpoint.
1611///
1612/// Returns one `<div class="nested-fields__row" data-index="{index}">` produced
1613/// by `render_row` with a blank [`RowScope`]. Because the decoder tolerates
1614/// non-contiguous indices, `index` only needs to be **unique** within the form
1615/// (e.g. a monotonically increasing counter the client tracks), not contiguous.
1616///
1617/// The [`inputs_for`] Add-row button sends this unique value as an `index`
1618/// param (via `hx-vals`, computed as `max(existing data-index) + 1`). Because
1619/// the button sets `hx-params="index"`, the Add-row request carries ONLY the
1620/// `hx-vals` `index` — no parent/line-item form fields and no CSRF/submit
1621/// token are serialized — and `index` is the only value this endpoint needs.
1622/// The endpoint should read that param from the query/form and pass it straight
1623/// to `nested_row_fragment(index, ..)`. Uniqueness — not contiguity — is the
1624/// contract: the decoder tolerates gaps, so indices left behind by client-side
1625/// row removals never cause collisions.
1626#[cfg(feature = "maud")]
1627#[must_use]
1628pub fn nested_row_fragment<C: NestedChild>(
1629    index: usize,
1630    render_row: impl Fn(&RowScope) -> maud::Markup,
1631) -> maud::Markup {
1632    let scope = RowScope {
1633        collection: C::COLLECTION,
1634        index,
1635        row: None,
1636    };
1637    maud::html! {
1638        div class="nested-fields__row" data-index=(index) {
1639            (render_row(&scope))
1640        }
1641    }
1642}
1643
1644// ── Tests ──────────────────────────────────────────────────────────
1645
1646#[cfg(test)]
1647mod tests {
1648    use super::*;
1649
1650    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1651    struct Order {
1652        #[validate(length(min = 1, message = "name required"))]
1653        name: String,
1654    }
1655
1656    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1657    struct LineItem {
1658        #[validate(length(min = 1, message = "sku required"))]
1659        sku: String,
1660        #[validate(range(min = 1, message = "quantity must be >= 1"))]
1661        quantity: i32,
1662    }
1663
1664    impl NestedChild for LineItem {
1665        const COLLECTION: &'static str = "items";
1666    }
1667
1668    /// A child with one **required** field (`name`) and one **optional** field
1669    /// (`nickname`), used to exercise the reject-all-blank rules.
1670    #[derive(serde::Deserialize, validator::Validate)]
1671    struct Contact {
1672        #[validate(length(min = 1, message = "name required"))]
1673        name: String,
1674        #[validate(length(min = 1, message = "nickname too short"))]
1675        nickname: Option<String>,
1676    }
1677
1678    impl NestedChild for Contact {
1679        const COLLECTION: &'static str = "contacts";
1680    }
1681
1682    fn p(k: &str, v: &str) -> (String, String) {
1683        (k.to_owned(), v.to_owned())
1684    }
1685
1686    #[test]
1687    fn binds_parent_and_two_children_in_order() {
1688        let pairs = vec![
1689            p("name", "Order 1"),
1690            p("items[0][sku]", "A-1"),
1691            p("items[0][quantity]", "2"),
1692            p("items[1][sku]", "B-2"),
1693            p("items[1][quantity]", "3"),
1694        ];
1695        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1696        assert!(cs.is_valid());
1697        assert_eq!(cs.rows().len(), 2);
1698        assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
1699        assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
1700        assert_eq!(cs.collection_name(), "items");
1701
1702        let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1703        assert_eq!(order.name, "Order 1");
1704        assert_eq!(items.len(), 2);
1705        assert_eq!(items[0].sku, "A-1");
1706        assert_eq!(items[0].quantity, 2);
1707        assert_eq!(items[1].quantity, 3);
1708    }
1709
1710    #[test]
1711    fn non_contiguous_indices_compact_preserving_order() {
1712        let pairs = vec![
1713            p("name", "Order"),
1714            p("items[0][sku]", "A"),
1715            p("items[0][quantity]", "1"),
1716            p("items[2][sku]", "C"),
1717            p("items[2][quantity]", "5"),
1718        ];
1719        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1720        // Gap at index 1 compacts: two rows in ascending order.
1721        assert_eq!(cs.rows().len(), 2);
1722        assert_eq!(cs.rows()[0].value("sku"), Some("A"));
1723        assert_eq!(cs.rows()[1].value("sku"), Some("C"));
1724        assert!(cs.is_valid());
1725
1726        let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1727        assert_eq!(items.len(), 2);
1728        assert_eq!(items[0].sku, "A");
1729        assert_eq!(items[1].sku, "C");
1730    }
1731
1732    #[test]
1733    fn destroy_marker_drops_row_from_children_but_retains_it() {
1734        let pairs = vec![
1735            p("name", "Order"),
1736            p("items[0][sku]", "A"),
1737            p("items[0][quantity]", "1"),
1738            p("items[1][sku]", "X"),
1739            p("items[1][quantity]", "9"),
1740            p("items[1][_destroy]", "1"),
1741        ];
1742        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1743        assert_eq!(cs.rows().len(), 2);
1744        assert!(!cs.rows()[0].is_destroyed());
1745        assert!(cs.rows()[1].is_destroyed());
1746        // Destroyed row is still retained with its raw values for re-render.
1747        assert_eq!(cs.rows()[1].value("sku"), Some("X"));
1748        assert!(cs.is_valid());
1749
1750        let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1751        // Only the non-destroyed row contributes.
1752        assert_eq!(items.len(), 1);
1753        assert_eq!(items[0].sku, "A");
1754    }
1755
1756    #[test]
1757    fn child_validation_failure_surfaces_combined_key_and_blocks_valid() {
1758        let pairs = vec![
1759            p("name", "Order"),
1760            p("items[0][sku]", "A"),
1761            p("items[0][quantity]", "2"),
1762            // quantity = 0 violates range(min = 1)
1763            p("items[1][sku]", "B"),
1764            p("items[1][quantity]", "0"),
1765        ];
1766        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1767        assert!(!cs.is_valid());
1768        assert!(!cs.errors_for("items[1].quantity").is_empty());
1769        // The valid sibling row has no error for that key.
1770        assert!(cs.errors_for("items[0].quantity").is_empty());
1771        assert!(cs.into_valid().is_err());
1772    }
1773
1774    #[test]
1775    fn all_valid_yields_ok_with_coerced_numeric_fields() {
1776        let pairs = vec![
1777            p("name", "Order"),
1778            p("items[0][sku]", "A"),
1779            p("items[0][quantity]", "5"),
1780        ];
1781        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1782        assert!(cs.is_valid());
1783        let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1784        assert_eq!(order.name, "Order");
1785        assert_eq!(items.len(), 1);
1786        // "5" coerced to i32.
1787        let q: i32 = items[0].quantity;
1788        assert_eq!(q, 5);
1789    }
1790
1791    #[test]
1792    fn parent_invalid_blocks_valid_children() {
1793        let pairs = vec![
1794            // Empty parent name violates length(min = 1).
1795            p("name", ""),
1796            p("items[0][sku]", "A"),
1797            p("items[0][quantity]", "1"),
1798        ];
1799        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1800        assert!(!cs.is_valid());
1801        assert!(!cs.errors_for("name").is_empty());
1802        assert!(cs.into_valid().is_err());
1803    }
1804
1805    #[test]
1806    fn child_parse_failure_keys_error_to_offending_field() {
1807        let pairs = vec![
1808            p("name", "Order"),
1809            // `sku` is filled, so the row is NOT all-blank (it is kept, not
1810            // dropped) — contrast `all_blank_template_row_is_ignored`, where an
1811            // empty row is dropped entirely with no error.
1812            p("items[0][sku]", "A"),
1813            // Non-numeric quantity: hard parse failure for i32, before
1814            // validation. `serde_path_to_error` attributes it to `quantity`.
1815            p("items[0][quantity]", "not-a-number"),
1816        ];
1817        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1818        assert!(!cs.is_valid());
1819        assert_eq!(cs.rows().len(), 1);
1820        // Raw values retained for re-render.
1821        assert_eq!(cs.rows()[0].value("sku"), Some("A"));
1822        assert_eq!(cs.rows()[0].value("quantity"), Some("not-a-number"));
1823        // The parse error is keyed to the offending subfield, so it renders next
1824        // to the bad input via `errors_for("quantity")` on that row.
1825        assert!(!cs.errors_for("items[0].quantity").is_empty());
1826        assert!(!cs.rows()[0].errors_for("quantity").is_empty());
1827        // It is NOT stored under the row-level "" key anymore.
1828        assert!(cs.rows()[0].errors_for("").is_empty());
1829        assert!(cs.errors_for("items[0]").is_empty());
1830        assert!(cs.into_valid().is_err());
1831    }
1832
1833    #[test]
1834    fn child_parse_failure_on_blank_typed_field_keys_error_to_field() {
1835        // The Codex case: a row with `sku` filled but the required TYPED
1836        // `quantity` present-but-blank. The blank-optional retry inside
1837        // `decode_urlencoded_dropping_blank_optional_fields` DROPS the blank
1838        // `quantity=` pair, so serde reports `missing field \`quantity\`` at the
1839        // ROW ROOT (empty path). The field-recovery re-decode over the original
1840        // (blank-retained) pairs must still key the error to `quantity` so it
1841        // renders next to the blank input rather than the row-level "" key.
1842        let pairs = vec![
1843            p("name", "Order"),
1844            p("items[0][sku]", "A"),
1845            p("items[0][quantity]", ""),
1846        ];
1847        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1848        assert!(!cs.is_valid());
1849        assert_eq!(cs.rows().len(), 1);
1850        // Keyed to the offending subfield, so it renders next to the blank input.
1851        assert!(!cs.errors_for("items[0].quantity").is_empty());
1852        assert!(!cs.rows()[0].errors_for("quantity").is_empty());
1853        // NOT stored under the row-level "" / `items[0]` key.
1854        assert!(cs.rows()[0].errors_for("").is_empty());
1855        assert!(cs.errors_for("items[0]").is_empty());
1856        assert!(cs.into_valid().is_err());
1857    }
1858
1859    #[test]
1860    fn child_row_with_blank_optional_typed_field_still_decodes_valid() {
1861        // Regression guard for the fix: a row with the required field filled and
1862        // an OPTIONAL TYPED field present-but-blank (`weight=`) must still decode
1863        // to a VALID row. The primary blank-dropping decode drops the blank
1864        // `weight=` pair so it resolves to `None` and the decode SUCCEEDS — the
1865        // field-recovery re-decode (which runs only on the failed path) is never
1866        // reached, and the row must stay valid.
1867        #[derive(serde::Deserialize, validator::Validate)]
1868        struct Widget {
1869            #[validate(length(min = 1, message = "label required"))]
1870            label: String,
1871            // Typed optional: a blank submission is dropped to `None` by the
1872            // blank-optional decode (an empty string is not a valid `i32`).
1873            weight: Option<i32>,
1874        }
1875        impl NestedChild for Widget {
1876            const COLLECTION: &'static str = "widgets";
1877        }
1878
1879        let pairs = vec![
1880            p("name", "Order"),
1881            p("widgets[0][label]", "Bolt"),
1882            p("widgets[0][weight]", ""),
1883        ];
1884        let cs = decode_nested_urlencoded::<Order, Widget>(&pairs).expect("parent decodes");
1885        assert!(cs.is_valid());
1886        assert_eq!(cs.rows().len(), 1);
1887        assert!(cs.errors_for("widgets[0].weight").is_empty());
1888        assert!(cs.rows()[0].errors_for("weight").is_empty());
1889        let (_order, widgets) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1890        assert_eq!(widgets.len(), 1);
1891        assert_eq!(widgets[0].label, "Bolt");
1892        assert_eq!(widgets[0].weight, None);
1893    }
1894
1895    #[test]
1896    fn child_optional_blank_before_required_blank_keys_error_to_required_field() {
1897        // Codex P2 follow-on: a child whose OPTIONAL typed field (`weight:
1898        // Option<i32>`) is declared BEFORE a REQUIRED typed field (`quantity:
1899        // i32`), with BOTH submitted blank. The blank-dropping decode drops both
1900        // blank pairs and ends at `missing field \`quantity\`` — the required
1901        // one. A raw non-dropping re-decode, by contrast, trips FIRST on the
1902        // harmless optional `weight=` (parsing `""` as an `i32` at `.weight`), so
1903        // it would mis-key the error to `weight`. The field-recovery therefore
1904        // prefers the PRIMARY error's missing-field message over the raw
1905        // re-decode, so the inline error lands on `quantity`, not `weight`.
1906        #[derive(serde::Deserialize, validator::Validate)]
1907        struct Part {
1908            #[validate(length(min = 1, message = "sku required"))]
1909            sku: String,
1910            // Optional, declared FIRST — a blank submission is harmless (→ None).
1911            // Never read on this invalid-row path (the row fails to fully decode),
1912            // so silence the dead-field lint.
1913            #[allow(dead_code)]
1914            weight: Option<i32>,
1915            // Required, declared AFTER — a blank submission is the real error.
1916            #[validate(range(min = 1, message = "quantity must be >= 1"))]
1917            quantity: i32,
1918        }
1919        impl NestedChild for Part {
1920            const COLLECTION: &'static str = "items";
1921        }
1922
1923        let pairs = vec![
1924            p("name", "Order"),
1925            // `sku` filled so the row is retained (not all-blank).
1926            p("items[0][sku]", "Widget"),
1927            p("items[0][weight]", ""),
1928            p("items[0][quantity]", ""),
1929        ];
1930        let cs = decode_nested_urlencoded::<Order, Part>(&pairs).expect("parent decodes");
1931        assert!(!cs.is_valid());
1932        assert_eq!(cs.rows().len(), 1);
1933        // The error is keyed to the truly-missing REQUIRED field…
1934        assert!(!cs.errors_for("items[0].quantity").is_empty());
1935        assert!(!cs.rows()[0].errors_for("quantity").is_empty());
1936        // …and NOT to the harmless OPTIONAL blank the raw re-decode trips on.
1937        assert!(cs.errors_for("items[0].weight").is_empty());
1938        assert!(cs.rows()[0].errors_for("weight").is_empty());
1939        // Not stored under the row-level "" / `items[0]` key either.
1940        assert!(cs.rows()[0].errors_for("").is_empty());
1941        assert!(cs.errors_for("items[0]").is_empty());
1942        assert!(cs.into_valid().is_err());
1943    }
1944
1945    #[test]
1946    fn parent_hard_parse_failure_is_err() {
1947        #[derive(serde::Deserialize, validator::Validate)]
1948        struct NumericParent {
1949            #[validate(range(min = 0))]
1950            count: i32,
1951        }
1952        #[derive(serde::Deserialize, validator::Validate)]
1953        struct Child {
1954            #[validate(length(min = 1))]
1955            name: String,
1956        }
1957        impl NestedChild for Child {
1958            const COLLECTION: &'static str = "kids";
1959        }
1960
1961        let pairs = vec![p("count", "not-a-number")];
1962        let result = decode_nested_urlencoded::<NumericParent, Child>(&pairs);
1963        assert!(result.is_err());
1964    }
1965
1966    // ── reject_if: :all_blank ──────────────────────────────────────
1967
1968    /// The core regression test for the phantom-blank-row P1: `inputs_for`
1969    /// always emits a trailing all-blank template row, and with no JS the
1970    /// browser submits its empty inputs. That row must be ignored, not decoded
1971    /// as a real (empty) child.
1972    #[test]
1973    fn all_blank_template_row_is_ignored() {
1974        let pairs = vec![
1975            p("name", "Order"),
1976            // One filled, valid child row.
1977            p("contacts[0][name]", "Alice"),
1978            p("contacts[0][nickname]", "Al"),
1979            // The auto-rendered blank template row: every subfield empty.
1980            p("contacts[1][name]", ""),
1981            p("contacts[1][nickname]", ""),
1982        ];
1983        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
1984        assert!(cs.is_valid());
1985        // The blank template row was dropped entirely — only the real row remains.
1986        assert_eq!(cs.rows().len(), 1);
1987        assert_eq!(cs.rows()[0].value("name"), Some("Alice"));
1988
1989        let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1990        assert_eq!(contacts.len(), 1);
1991        assert_eq!(contacts[0].name, "Alice");
1992    }
1993
1994    /// Blank detection trims: a row whose fields are whitespace-only (or empty)
1995    /// is treated as blank and dropped.
1996    #[test]
1997    fn whitespace_only_row_is_ignored() {
1998        let pairs = vec![
1999            p("name", "Order"),
2000            p("contacts[0][name]", "Bob"),
2001            // Whitespace-only + empty: still all-blank after trimming.
2002            p("contacts[1][name]", "   "),
2003            p("contacts[1][nickname]", ""),
2004        ];
2005        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2006        assert!(cs.is_valid());
2007        assert_eq!(cs.rows().len(), 1);
2008        assert_eq!(cs.rows()[0].value("name"), Some("Bob"));
2009
2010        let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2011        assert_eq!(contacts.len(), 1);
2012    }
2013
2014    /// A row with at least one non-blank value is a real child the user is
2015    /// adding: it is kept and validated, so a missing **required** field still
2016    /// surfaces a per-row error. We must not over-aggressively drop it.
2017    #[test]
2018    fn partially_filled_row_with_missing_required_still_errors() {
2019        let pairs = vec![
2020            p("name", "Order"),
2021            // Required `name` left blank, but the optional `nickname` is filled,
2022            // so the row is NOT all-blank: it is kept and must error.
2023            p("contacts[0][name]", ""),
2024            p("contacts[0][nickname]", "Ally"),
2025        ];
2026        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2027        assert!(!cs.is_valid());
2028        assert_eq!(cs.rows().len(), 1);
2029        // The kept row surfaces the required-field error under its combined key.
2030        assert!(!cs.errors_for("contacts[0].name").is_empty());
2031        // Raw values retained for re-render.
2032        assert_eq!(cs.rows()[0].value("nickname"), Some("Ally"));
2033        assert!(cs.into_valid().is_err());
2034    }
2035
2036    /// A fully filled row is still bound — reject-all-blank does not regress the
2037    /// happy path when every submitted row carries real values.
2038    #[test]
2039    fn fully_filled_row_is_still_bound() {
2040        let pairs = vec![
2041            p("name", "Order"),
2042            p("contacts[0][name]", "Carol"),
2043            p("contacts[0][nickname]", "Caz"),
2044        ];
2045        let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2046        assert!(cs.is_valid());
2047        assert_eq!(cs.rows().len(), 1);
2048
2049        let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2050        assert_eq!(contacts.len(), 1);
2051        assert_eq!(contacts[0].name, "Carol");
2052        assert_eq!(contacts[0].nickname.as_deref(), Some("Caz"));
2053    }
2054
2055    #[test]
2056    fn parse_child_key_matches_and_rejects() {
2057        assert_eq!(parse_child_key("items[0][sku]", "items"), Some((0, "sku")));
2058        assert_eq!(
2059            parse_child_key("items[12][quantity]", "items"),
2060            Some((12, "quantity"))
2061        );
2062        // Wrong collection.
2063        assert_eq!(parse_child_key("other[0][sku]", "items"), None);
2064        // Not a child subfield (parent key).
2065        assert_eq!(parse_child_key("name", "items"), None);
2066        // Trailing junk after the closing bracket.
2067        assert_eq!(parse_child_key("items[0][sku]x", "items"), None);
2068        // Non-numeric index.
2069        assert_eq!(parse_child_key("items[a][sku]", "items"), None);
2070    }
2071
2072    // ── blank (non-validating) constructor ─────────────────────────
2073
2074    #[test]
2075    fn blank_changeset_is_valid_with_no_rows_or_errors() {
2076        // The initial `new`-page path: the required parent `name` is still
2077        // empty, but `blank` does NOT validate, so nothing errors before the
2078        // user types (unlike `decode_nested_urlencoded`).
2079        let cs = NestedChangeset::<Order, LineItem>::blank(Order {
2080            name: String::new(),
2081        });
2082        assert!(cs.is_valid());
2083        assert!(cs.errors_for("name").is_empty());
2084        assert!(cs.rows().is_empty());
2085
2086        let (order, items) = cs
2087            .into_valid()
2088            .unwrap_or_else(|_| panic!("blank changeset is valid"));
2089        assert_eq!(order.name, "");
2090        assert!(items.is_empty());
2091    }
2092
2093    #[test]
2094    fn decoded_empty_parent_errors_unlike_blank() {
2095        // Contrast the POST decode path: it DOES validate, so the same empty
2096        // required parent surfaces an error — exactly what `blank` avoids on the
2097        // initial GET render.
2098        let cs =
2099            decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
2100        assert!(!cs.is_valid());
2101        assert!(!cs.errors_for("name").is_empty());
2102    }
2103
2104    // ── seeded (edit-render) constructor ───────────────────────────
2105
2106    #[test]
2107    fn seeded_changeset_pre_populates_one_row_per_child() {
2108        // The initial `edit`-page path: seed the changeset with the two existing
2109        // children so an edit form can display/preserve them before first submit.
2110        let cs = NestedChangeset::seeded(
2111            Order {
2112                name: "Existing order".to_owned(),
2113            },
2114            vec![
2115                LineItem {
2116                    sku: "A-1".to_owned(),
2117                    quantity: 2,
2118                },
2119                LineItem {
2120                    sku: "B-2".to_owned(),
2121                    quantity: 3,
2122                },
2123            ],
2124        );
2125
2126        // One row per seeded child, each pre-filled with its serialized values —
2127        // the same string representation the decoder populates.
2128        assert_eq!(cs.rows().len(), 2);
2129        assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2130        assert_eq!(cs.rows()[0].value("quantity"), Some("2"));
2131        assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
2132        assert_eq!(cs.rows()[1].value("quantity"), Some("3"));
2133
2134        // Non-validating and clean: no errors, no destroyed rows.
2135        assert!(cs.is_valid());
2136        assert!(cs.errors_for("name").is_empty());
2137        assert!(cs.errors_for("items[0].sku").is_empty());
2138        assert!(cs.errors_for("items[1].quantity").is_empty());
2139        assert!(!cs.rows()[0].is_destroyed());
2140        assert!(!cs.rows()[1].is_destroyed());
2141
2142        // `into_valid` returns the seeded parent + children unchanged.
2143        let (order, items) = cs
2144            .into_valid()
2145            .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2146        assert_eq!(order.name, "Existing order");
2147        assert_eq!(items.len(), 2);
2148        assert_eq!(items[0].sku, "A-1");
2149        assert_eq!(items[0].quantity, 2);
2150        assert_eq!(items[1].sku, "B-2");
2151        assert_eq!(items[1].quantity, 3);
2152    }
2153
2154    #[test]
2155    fn seeded_changeset_with_no_children_is_valid_and_empty() {
2156        // Seeding zero children behaves like `blank` — a valid changeset with no
2157        // rows — so an edit form for a parent that currently has no children is
2158        // handled uniformly.
2159        let cs = NestedChangeset::<Order, LineItem>::seeded(
2160            Order {
2161                name: "Empty".to_owned(),
2162            },
2163            Vec::new(),
2164        );
2165        assert!(cs.is_valid());
2166        assert!(cs.rows().is_empty());
2167        let (_order, items) = cs
2168            .into_valid()
2169            .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2170        assert!(items.is_empty());
2171    }
2172}
2173
2174#[cfg(all(test, feature = "maud"))]
2175mod maud_tests {
2176    use super::*;
2177
2178    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2179    struct Order {
2180        #[validate(length(min = 1, message = "name required"))]
2181        name: String,
2182    }
2183
2184    #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2185    struct LineItem {
2186        #[validate(length(min = 1, message = "sku required"))]
2187        sku: String,
2188        #[validate(range(min = 1, message = "quantity must be >= 1"))]
2189        quantity: i32,
2190    }
2191
2192    impl NestedChild for LineItem {
2193        const COLLECTION: &'static str = "items";
2194    }
2195
2196    fn p(k: &str, v: &str) -> (String, String) {
2197        (k.to_owned(), v.to_owned())
2198    }
2199
2200    /// A changeset with two submitted rows, the second failing child validation
2201    /// (`quantity = 0`), so both rows are retained for re-render.
2202    fn two_row_changeset() -> NestedChangeset<Order, LineItem> {
2203        let pairs = vec![
2204            p("name", "Order 1"),
2205            p("items[0][sku]", "A-1"),
2206            p("items[0][quantity]", "2"),
2207            p("items[1][sku]", "B-2"),
2208            // quantity = 0 violates range(min = 1): row 1 is retained with an error.
2209            p("items[1][quantity]", "0"),
2210        ];
2211        decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes")
2212    }
2213
2214    fn render_row(row: &RowScope) -> maud::Markup {
2215        maud::html! {
2216            (row.required_text_input("sku", "SKU"))
2217            (row.number_input("quantity", "Quantity"))
2218            (row.destroy_checkbox("Remove"))
2219        }
2220    }
2221
2222    #[test]
2223    fn existing_rows_render_indexed_names_and_prefilled_values() {
2224        let cs = two_row_changeset();
2225        let opts = InputsForOptions::default();
2226        let html = inputs_for(&cs, &opts, render_row).into_string();
2227
2228        assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
2229        assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
2230        // Pre-filled values from the re-rendered changeset.
2231        assert!(html.contains(r#"value="A-1""#), "{html}");
2232        assert!(html.contains(r#"value="B-2""#), "{html}");
2233        // Container defaults to "{collection}-rows".
2234        assert!(html.contains(r#"id="items-rows""#), "{html}");
2235    }
2236
2237    #[test]
2238    fn per_row_error_renders_scoped_alert_block() {
2239        let cs = two_row_changeset();
2240        let opts = InputsForOptions::default();
2241        let html = inputs_for(&cs, &opts, render_row).into_string();
2242
2243        // The failing row's quantity error is scoped to that row's unique id.
2244        assert!(html.contains(r#"id="items-1-quantity-error""#), "{html}");
2245        assert!(html.contains(r#"role="alert""#), "{html}");
2246        assert!(html.contains("quantity must be &gt;= 1"), "{html}");
2247        // The valid sibling row's quantity has no error block.
2248        assert!(!html.contains(r#"id="items-0-quantity-error""#), "{html}");
2249    }
2250
2251    #[test]
2252    fn appends_blank_template_row_with_next_index() {
2253        let cs = two_row_changeset();
2254        let opts = InputsForOptions::default();
2255        let html = inputs_for(&cs, &opts, render_row).into_string();
2256
2257        // Two existing rows (indices 0,1) plus one blank row at index 2.
2258        assert!(html.contains(r#"data-index="0""#), "{html}");
2259        assert!(html.contains(r#"data-index="1""#), "{html}");
2260        assert!(html.contains(r#"data-index="2""#), "{html}");
2261        assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
2262    }
2263
2264    /// Extract the full `<...>` tag containing the given `name="…"` attribute,
2265    /// so a per-input attribute assertion isn't fooled by a sibling row's tag.
2266    fn tag_with_name(html: &str, name: &str) -> String {
2267        let needle = format!(r#"name="{name}""#);
2268        let at = html
2269            .find(&needle)
2270            .unwrap_or_else(|| panic!("missing {name} in {html}"));
2271        let open = html[..at].rfind('<').expect("tag has an opening '<'");
2272        let close = html[at..].find('>').expect("tag has a closing '>'") + at;
2273        html[open..=close].to_string()
2274    }
2275
2276    /// Fix 1: a required-variant input on a submitted row carries the client-side
2277    /// `required`/`aria-required` constraint, but the auto-appended blank template
2278    /// row (`row: None`) omits it so the form stays submittable with a trailing
2279    /// blank row present (server-side validation still enforces required-ness).
2280    #[test]
2281    fn blank_template_row_omits_client_required_but_submitted_row_keeps_it() {
2282        let cs = two_row_changeset();
2283        let opts = InputsForOptions::default();
2284        let html = inputs_for(&cs, &opts, render_row).into_string();
2285
2286        // Submitted rows (indices 0, 1) keep the client-side constraint.
2287        let submitted = tag_with_name(&html, "items[0][sku]");
2288        assert!(submitted.contains(" required"), "{submitted}");
2289        assert!(submitted.contains(r#"aria-required="true""#), "{submitted}");
2290
2291        // The appended blank template row (index 2) omits both, so it is
2292        // leaveable-empty and does not block submitting the form.
2293        let blank = tag_with_name(&html, "items[2][sku]");
2294        assert!(!blank.contains("required"), "{blank}");
2295        assert!(!blank.contains("aria-required"), "{blank}");
2296    }
2297
2298    /// Fix (Codex P2, near the `required` gate): a submitted row the user ticked
2299    /// `_destroy` on must ALSO omit the client-side `required`/`aria-required`,
2300    /// exactly like a blank template row. Otherwise, in a 422 re-render where the
2301    /// user checked Remove on a row with an empty required field, the browser's
2302    /// constraint validation would block the next submit before the server can
2303    /// honor `_destroy`. A non-destroyed submitted row in the same render keeps
2304    /// the constraint.
2305    #[test]
2306    fn destroyed_row_omits_client_required_while_submitted_row_keeps_it() {
2307        // Row 0: an ordinary submitted row. Row 1: the user ticked Remove on a
2308        // row whose required `sku` is empty (it is retained because `quantity`
2309        // is non-blank, so the all-blank rejection does not drop it).
2310        let pairs = vec![
2311            p("name", "Order 1"),
2312            p("items[0][sku]", "A-1"),
2313            p("items[0][quantity]", "2"),
2314            p("items[1][sku]", ""),
2315            p("items[1][quantity]", "9"),
2316            p("items[1][_destroy]", "1"),
2317        ];
2318        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2319        assert!(cs.rows()[1].is_destroyed());
2320        let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2321
2322        // The non-destroyed submitted row keeps the client-side constraint.
2323        let kept = tag_with_name(&html, "items[0][sku]");
2324        assert!(kept.contains(" required"), "{kept}");
2325        assert!(kept.contains(r#"aria-required="true""#), "{kept}");
2326
2327        // The destroyed row omits both, exactly like a blank template row, so a
2328        // form carrying an empty required field on a to-be-removed row still
2329        // submits and the server can honor `_destroy`.
2330        let destroyed = tag_with_name(&html, "items[1][sku]");
2331        assert!(!destroyed.contains("required"), "{destroyed}");
2332        assert!(!destroyed.contains("aria-required"), "{destroyed}");
2333    }
2334
2335    #[test]
2336    fn always_emits_a_blank_row_even_when_blank_rows_zero() {
2337        let cs = two_row_changeset();
2338        let opts = InputsForOptions {
2339            blank_rows: 0,
2340            ..InputsForOptions::default()
2341        };
2342        let html = inputs_for(&cs, &opts, render_row).into_string();
2343        // Still emits the blank template row at index 2.
2344        assert!(html.contains(r#"data-index="2""#), "{html}");
2345    }
2346
2347    #[test]
2348    fn destroy_checkbox_emits_indexed_marker() {
2349        let cs = two_row_changeset();
2350        let opts = InputsForOptions::default();
2351        let html = inputs_for(&cs, &opts, render_row).into_string();
2352
2353        assert!(html.contains(r#"name="items[0][_destroy]""#), "{html}");
2354        assert!(html.contains(r#"name="items[1][_destroy]""#), "{html}");
2355        assert!(html.contains(r#"type="checkbox""#), "{html}");
2356    }
2357
2358    #[test]
2359    fn add_button_renders_only_with_url_and_carries_htmx_attrs() {
2360        let cs = two_row_changeset();
2361
2362        // No URL: no Add button.
2363        let none = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2364        assert!(!none.contains("Add row"), "{none}");
2365
2366        // With URL: button serializes only the `index` param (`hx-params="index"`)
2367        // and uses a beforeend swap.
2368        let opts = InputsForOptions {
2369            add_row_url: Some("/orders/line-item-row".into()),
2370            ..InputsForOptions::default()
2371        };
2372        let html = inputs_for(&cs, &opts, render_row).into_string();
2373        assert!(html.contains("Add row"), "{html}");
2374        assert!(html.contains(r#"hx-params="index""#), "{html}");
2375        assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2376        assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2377        assert!(html.contains(r##"hx-target="#items-rows""##), "{html}");
2378    }
2379
2380    /// Fix (Codex P2, near the Add-row button): the Add-row button sends a fresh
2381    /// unique `index` with each htmx request via `hx-vals` (`js:` form) computed
2382    /// from THIS container's rows (`max(existing data-index) + 1`), so repeated
2383    /// clicks never reuse an index and emit duplicate control names. The selector
2384    /// is scoped to the form's own container id; keeping several same-collection
2385    /// forms independent requires each to pass a distinct `container_id` (see
2386    /// `add_button_targets_and_scans_custom_container_id`). The pre-existing htmx
2387    /// attrs stay intact.
2388    #[test]
2389    fn add_button_hx_vals_sends_fresh_container_scoped_index() {
2390        let cs = two_row_changeset();
2391        let opts = InputsForOptions {
2392            add_row_url: Some("/orders/line-item-row".into()),
2393            ..InputsForOptions::default()
2394        };
2395        let html = inputs_for(&cs, &opts, render_row).into_string();
2396
2397        // Pre-existing htmx wiring is preserved.
2398        assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2399        assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2400        assert!(html.contains(r#"hx-params="index""#), "{html}");
2401
2402        // A JS-computed `hx-vals` that sends a fresh `index`…
2403        assert!(html.contains("hx-vals="), "{html}");
2404        assert!(html.contains("Math.max(-1"), "{html}");
2405        // …referencing the `index` param and reading each row's `data-index`…
2406        assert!(html.contains("parseInt(e.dataset.index,10)"), "{html}");
2407        // …via a selector scoped to THIS form's container id.
2408        assert!(
2409            html.contains("#items-rows .nested-fields__row[data-index]"),
2410            "{html}"
2411        );
2412    }
2413
2414    /// Fix (Codex P2, container-id collisions): a caller rendering two nested
2415    /// forms for the same child collection on one page must give each a distinct
2416    /// `container_id` so their Add-row buttons stay independent. Assert that an
2417    /// explicit `container_id` flows through to BOTH the container `id` and the
2418    /// Add-row button's `hx-target` + `hx-vals` index scan — proving per-form
2419    /// scoping works once ids are distinct — and that the default id is absent.
2420    #[test]
2421    fn add_button_targets_and_scans_custom_container_id() {
2422        let cs = two_row_changeset();
2423        let opts = InputsForOptions {
2424            add_row_url: Some("/orders/line-item-row".into()),
2425            // A second same-collection form on the page would pass its own id;
2426            // this one deliberately avoids containing the `items-rows` default so
2427            // the negative assertions below are exact substring checks.
2428            container_id: Some("shipping-lines".into()),
2429            ..InputsForOptions::default()
2430        };
2431        let html = inputs_for(&cs, &opts, render_row).into_string();
2432
2433        // The container carries the custom id, not the `{collection}-rows` default.
2434        assert!(
2435            html.contains(r#"<div id="shipping-lines" class="nested-fields">"#),
2436            "{html}"
2437        );
2438        // The Add-row button targets the custom container…
2439        assert!(html.contains(r##"hx-target="#shipping-lines""##), "{html}");
2440        // …and its index scan reads that same custom container's rows…
2441        assert!(
2442            html.contains("#shipping-lines .nested-fields__row[data-index]"),
2443            "{html}"
2444        );
2445        // …with no leftover reference to the default id.
2446        assert!(!html.contains("items-rows .nested-fields__row"), "{html}");
2447        assert!(!html.contains(r##"hx-target="#items-rows""##), "{html}");
2448    }
2449
2450    #[test]
2451    fn destroy_checkbox_checked_reflects_destroyed_row() {
2452        let pairs = vec![
2453            p("name", "Order"),
2454            p("items[0][sku]", "A"),
2455            p("items[0][quantity]", "1"),
2456            p("items[0][_destroy]", "1"),
2457        ];
2458        let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2459        let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2460        // The destroyed row's checkbox is checked.
2461        assert!(
2462            html.contains(r#"name="items[0][_destroy]" value="1" checked"#),
2463            "{html}"
2464        );
2465    }
2466
2467    #[test]
2468    fn nested_row_fragment_renders_single_row_at_index() {
2469        let html = nested_row_fragment::<LineItem>(7, render_row).into_string();
2470        assert!(html.contains(r#"data-index="7""#), "{html}");
2471        assert!(html.contains(r#"name="items[7][sku]""#), "{html}");
2472        // Exactly one row wrapper.
2473        assert_eq!(html.matches("nested-fields__row").count(), 1, "{html}");
2474    }
2475
2476    // ── Fix 1: form_tag preserves the captured CSRF/submit-token fields ─
2477
2478    /// `NestedChangesetForm::form_tag` must emit the CSRF hidden input under the
2479    /// **captured/configured** field name — NOT the standalone `form_tag`'s
2480    /// hardcoded `_csrf` — so an app with a custom `security.csrf.form_field`
2481    /// keeps CSRF parity across a nested re-render.
2482    #[test]
2483    fn form_tag_emits_custom_csrf_field_name_not_default() {
2484        let form = NestedChangesetForm::<Order, LineItem>::blank(
2485            Order {
2486                name: String::new(),
2487            },
2488            Some("tok-123".to_owned()),
2489        )
2490        .with_csrf_field("authenticity_token");
2491
2492        let html = form
2493            .form_tag("/orders", "post", maud::html! {})
2494            .into_string();
2495
2496        // The captured custom field name carries the token…
2497        assert!(
2498            html.contains(r#"name="authenticity_token" value="tok-123""#),
2499            "{html}"
2500        );
2501        // …and the hardcoded default the standalone `form_tag` would emit does not.
2502        assert!(!html.contains(r#"name="_csrf""#), "{html}");
2503        assert!(html.contains(r#"action="/orders""#), "{html}");
2504        assert!(html.contains(r#"method="post""#), "{html}");
2505    }
2506
2507    /// When a submit token is captured, `form_tag` also emits it under its
2508    /// captured field name (alongside the custom CSRF field).
2509    #[test]
2510    fn form_tag_emits_captured_submit_token_field() {
2511        let form = NestedChangesetForm::<Order, LineItem> {
2512            changeset: NestedChangeset::blank(Order {
2513                name: String::new(),
2514            }),
2515            csrf_token: Some("tok".to_owned()),
2516            csrf_field: "authenticity_token".to_owned(),
2517            submit_token: Some("stok-9".to_owned()),
2518            submit_field: "_submit_token".to_owned(),
2519        };
2520
2521        let html = form
2522            .form_tag("/orders", "post", maud::html! {})
2523            .into_string();
2524
2525        assert!(
2526            html.contains(r#"name="authenticity_token" value="tok""#),
2527            "{html}"
2528        );
2529        assert!(
2530            html.contains(r#"name="_submit_token" value="stok-9""#),
2531            "{html}"
2532        );
2533        assert!(!html.contains(r#"name="_csrf""#), "{html}");
2534    }
2535
2536    // ── Fix 2: blank render shows no premature parent errors ────────────
2537
2538    /// The initial `new`-page render from `blank`: the required parent field is
2539    /// empty but un-validated, so no error message or `aria-invalid="true"`
2540    /// appears before the user types.
2541    #[test]
2542    fn blank_form_render_shows_no_parent_validation_error() {
2543        let form = NestedChangesetForm::<Order, LineItem>::blank(
2544            Order {
2545                name: String::new(),
2546            },
2547            Some("tok".to_owned()),
2548        );
2549
2550        let html = form
2551            .form_tag(
2552                "/orders",
2553                "post",
2554                maud::html! {
2555                    (crate::form::required_text_input(&form.parent, "name", "Order name"))
2556                    (inputs_for(&*form, &InputsForOptions::default(), render_row))
2557                },
2558            )
2559            .into_string();
2560
2561        assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
2562        assert!(!html.contains("name required"), "{html}");
2563    }
2564
2565    /// Contrast: a re-render from a failed decode DOES surface the parent error
2566    /// and marks the field invalid — the exact behaviour `blank` suppresses on
2567    /// the initial GET render.
2568    #[test]
2569    fn decoded_invalid_parent_render_shows_error_unlike_blank() {
2570        let cs =
2571            decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
2572        let form = NestedChangesetForm::from_changeset(cs);
2573
2574        let html = form
2575            .form_tag(
2576                "/orders",
2577                "post",
2578                maud::html! {
2579                    (crate::form::required_text_input(&form.parent, "name", "Order name"))
2580                },
2581            )
2582            .into_string();
2583
2584        assert!(html.contains(r#"aria-invalid="true""#), "{html}");
2585        assert!(html.contains("name required"), "{html}");
2586    }
2587
2588    // ── Fix 3: blank forms can carry the current submit token ───────────
2589
2590    /// A `blank` form given a submit token (and a custom submit field name) emits
2591    /// the hidden submit-token input on the initial GET render, so the FIRST
2592    /// submission is protected against double-submit — not just later 422
2593    /// re-renders.
2594    #[test]
2595    fn blank_form_with_submit_token_emits_hidden_submit_field() {
2596        let form = NestedChangesetForm::<Order, LineItem>::blank(
2597            Order {
2598                name: String::new(),
2599            },
2600            Some("csrf".to_owned()),
2601        )
2602        .with_submit_field("authenticity_submit")
2603        .with_submit_token(Some("tok".to_owned()));
2604
2605        let html = form
2606            .form_tag("/orders", "post", maud::html! {})
2607            .into_string();
2608
2609        assert!(
2610            html.contains(r#"name="authenticity_submit" value="tok""#),
2611            "{html}"
2612        );
2613    }
2614
2615    /// Without supplying a submit token, a `blank` form emits NO submit-token
2616    /// hidden input — documenting the default and that the caller must supply the
2617    /// minted token on the initial GET to protect the first submit.
2618    #[test]
2619    fn blank_form_without_submit_token_omits_submit_field() {
2620        let form = NestedChangesetForm::<Order, LineItem>::blank(
2621            Order {
2622                name: String::new(),
2623            },
2624            Some("csrf".to_owned()),
2625        );
2626
2627        let html = form
2628            .form_tag("/orders", "post", maud::html! {})
2629            .into_string();
2630
2631        assert!(!html.contains("_submit_token"), "{html}");
2632        assert!(!html.contains(r#"name="_submit_token""#), "{html}");
2633    }
2634
2635    // ── seeded (edit-render) rendering ──────────────────────────────
2636
2637    /// A `seeded` changeset pre-renders its existing children through
2638    /// `inputs_for`: each seeded row's inputs carry the child's serialized values
2639    /// so an edit form shows the current line items, and a trailing blank template
2640    /// row is still appended for adding another child with no JS.
2641    #[test]
2642    fn seeded_rows_render_with_prefilled_values() {
2643        let cs = NestedChangeset::seeded(
2644            Order {
2645                name: "Existing order".to_owned(),
2646            },
2647            vec![
2648                LineItem {
2649                    sku: "A-1".to_owned(),
2650                    quantity: 2,
2651                },
2652                LineItem {
2653                    sku: "B-2".to_owned(),
2654                    quantity: 3,
2655                },
2656            ],
2657        );
2658        let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2659
2660        // Both seeded rows render their indexed inputs with pre-filled values.
2661        assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
2662        assert!(html.contains(r#"value="A-1""#), "{html}");
2663        assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
2664        assert!(html.contains(r#"value="B-2""#), "{html}");
2665        // The trailing blank template row is still appended (index 2).
2666        assert!(html.contains(r#"data-index="2""#), "{html}");
2667        assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
2668    }
2669}