Skip to main content

quillmark_core/document/
edit.rs

1//! Typed mutators for [`Document`] and [`Card`] with invariant enforcement.
2//!
3//! Every successful mutator leaves the document with every user field name
4//! matching `[A-Za-z_][A-Za-z0-9_]*` and every composable `$kind` passing
5//! `meta::is_valid_kind_name`, so the result is safely serializable via
6//! [`Document::to_plate_json`]. Mutators never modify `warnings`: those
7//! are immutable parse-time observations.
8//!
9//! Payload/body mutators (field store/fill/remove, `$ext` and `$seed`
10//! namespace writers, body replacement) live on [`Card`]; [`Document`] keeps
11//! document-level ops (quill-ref, push/insert/remove/move card).
12//!
13//! The `$ext` mutators carry no field-name invariant ($ext is an opaque
14//! mapping that never reaches the plate JSON backends consume), but they do
15//! enforce the §8 value-depth bound: `$ext` flows through the recursive
16//! emit and DTO paths like any other value.
17
18use std::collections::BTreeMap;
19
20use unicode_normalization::UnicodeNormalization;
21
22use quillmark_content::delta::diff_import;
23use quillmark_content::import::ImportError;
24use quillmark_content::{ApplyError, Delta, LineOp, MarkOp, Content};
25
26use crate::document::meta::{validate_composable_kind, CardKindError};
27use crate::error::diag_args;
28use crate::document::payload::MetaKey;
29use crate::document::{Card, Document, Payload};
30use crate::quill::{CoercionError, FieldSchema, FieldType, Leniency, QuillConfig};
31use crate::value::QuillValue;
32use crate::version::QuillReference;
33
34/// `true` if `name` matches `[A-Za-z_][A-Za-z0-9_]*` after NFC normalisation.
35///
36/// Lowercase is the recommended (canonical) convention, but uppercase ASCII
37/// letters are accepted and preserved verbatim. Collision-safety with system
38/// metadata comes entirely from the `$`-prefix exclusion: `$`-prefixed keys
39/// are reserved, so a user field can never shadow one regardless of case.
40pub fn is_valid_field_name(name: &str) -> bool {
41    let normalized: String = name.nfc().collect();
42    if normalized.is_empty() {
43        return false;
44    }
45    let mut chars = normalized.chars();
46    let first = chars.next().unwrap();
47    if !first.is_ascii_alphabetic() && first != '_' {
48        return false;
49    }
50    for ch in chars {
51        if !ch.is_ascii_alphanumeric() && ch != '_' {
52            return false;
53        }
54    }
55    true
56}
57
58/// Errors returned by document and card mutators.
59#[derive(Debug, Clone, PartialEq, thiserror::Error)]
60#[non_exhaustive]
61pub enum EditError {
62    #[error("invalid field name '{0}': must match [A-Za-z_][A-Za-z0-9_]*")]
63    InvalidFieldName(String),
64
65    /// A typed write ([`TypedWriter::set`](crate::TypedWriter::set) /
66    /// [`CardWriter::set`](crate::CardWriter::set)) addressed a well-formed name
67    /// that the bound schema does not declare (or a card whose `$kind` carries
68    /// no schema). The typed path resolves every name to a schema type, so an
69    /// undeclared name is a typo, not a fallback: it fails here instead of
70    /// landing silently in the opaque store. Reach for the raw
71    /// [`Card::store_field`](Card::store_field) when opaque storage is the intent.
72    #[error("field '{0}' is not declared in the schema")]
73    UnknownField(String),
74
75    #[error("invalid card kind '{0}': must match [a-z_][a-z0-9_]*")]
76    InvalidKindName(String),
77
78    #[error("card kind 'main' is reserved for the document root")]
79    ReservedKind,
80
81    #[error("index {index} is out of range (len = {len})")]
82    IndexOutOfRange { index: usize, len: usize },
83
84    #[error("value nests deeper than the maximum of {max} levels")]
85    ValueTooDeep { max: usize },
86
87    /// Markdown import failed: the content codec rejected the input for a body
88    /// *or* a field path (e.g. container nesting past
89    /// [`MAX_NESTING_DEPTH`](quillmark_content::MAX_NESTING_DEPTH)). Returned
90    /// instead of silently degrading the target to empty on a rejected import.
91    #[error("markdown import failed: {0}")]
92    Import(ImportError),
93
94    /// A richtext field value in the content-or-markdown encoding could not be
95    /// decoded: a JSON object that is not a canonical richtext content, a
96    /// markdown string that failed to import, or a shape that is neither
97    /// object, string, nor null. Returned by
98    /// [`Card::commit_field`](Card::commit_field) on a richtext field, by
99    /// [`Card::revise_field`](Card::revise_field) on a present non-content field,
100    /// and by [`Card::apply_field_richtext_change`](Card::apply_field_richtext_change).
101    #[error("richtext field '{field}' decode failed: {message}")]
102    FieldRichtextDecode { field: String, message: String },
103
104    /// A corpus read ([`TypedReader::get_content`](crate::TypedReader::get_content))
105    /// addressed a field whose declared type is not a content leaf. Which codec
106    /// decodes a value is a property of the declared type, not of the stored
107    /// shape, so the schema answers this before the payload is consulted: an
108    /// `integer` field has no corpus to return even when it happens to hold a
109    /// string.
110    ///
111    /// The condition is a *leaf* one, narrower than the subtree test conform
112    /// walks (`field_contains_content`): an `array<richtext>` carries content and
113    /// still has no one corpus, so it lands here. Read its elements through
114    /// [`get`](crate::TypedReader::get).
115    #[error("field '{field}' is declared '{declared}', which is not a content field")]
116    FieldNotContent { field: String, declared: String },
117
118    /// A richtext field written under the `richtext(inline)` constraint decoded
119    /// to a multi-block content (more than one line, a container, or an island).
120    /// The write-time counterpart of the coercion/validation `richtext(inline)`
121    /// check; returned by [`Card::commit_field`](Card::commit_field) when the
122    /// field's schema is `richtext` with `inline: true`.
123    #[error("richtext field '{0}' is not inline: richtext(inline) requires a single paragraph line with no list/quote container and no islands")]
124    FieldRichtextNotInline(String),
125
126    /// A typed write ([`Card::commit_field`](Card::commit_field)) could not
127    /// conform the value to the field's schema type: the general write-commit
128    /// failure for scalar/array/object types (a `"x"` for an `integer`, a
129    /// non-object for an `object`, …). Richtext fields report through the
130    /// dedicated [`FieldRichtextDecode`](Self::FieldRichtextDecode) /
131    /// [`FieldRichtextNotInline`](Self::FieldRichtextNotInline) variants
132    /// instead, so the richtext write surface is unchanged.
133    #[error("field '{field}' does not conform to its schema type: {message}")]
134    FieldConform {
135        field: String,
136        /// The schema type the write was conformed against, carried across
137        /// from [`CoercionError`] so the failure
138        /// states what the value had to become. `message` alone is English.
139        target: String,
140        message: String,
141    },
142
143    /// A content field-change bundle (text delta, line ops, mark ops) applied
144    /// out of bounds or broke an invariant normalization could not repair.
145    #[error("content apply failed: {0:?}")]
146    ContentApply(ApplyError),
147}
148
149impl EditError {
150    /// The bare variant name (e.g. `"InvalidFieldName"`). Retained as the
151    /// stable variant discriminator behind [`code`](Self::code); defined once
152    /// here so a new variant cannot drift between the two binding error mappers.
153    pub fn variant_name(&self) -> &'static str {
154        match self {
155            EditError::InvalidFieldName(_) => "InvalidFieldName",
156            EditError::UnknownField(_) => "UnknownField",
157            EditError::InvalidKindName(_) => "InvalidKindName",
158            EditError::ReservedKind => "ReservedKind",
159            EditError::IndexOutOfRange { .. } => "IndexOutOfRange",
160            EditError::ValueTooDeep { .. } => "ValueTooDeep",
161            EditError::Import(_) => "Import",
162            EditError::FieldRichtextDecode { .. } => "FieldRichtextDecode",
163            EditError::FieldNotContent { .. } => "FieldNotContent",
164            EditError::FieldRichtextNotInline(_) => "FieldRichtextNotInline",
165            EditError::FieldConform { .. } => "FieldConform",
166            EditError::ContentApply(_) => "ContentApply",
167        }
168    }
169
170    /// The namespaced diagnostic `code` (e.g. `"edit::invalid_field_name"`),
171    /// one per variant. This is the machine-routable identity both bindings
172    /// stamp onto the `Diagnostic` they raise: the `edit::*` peer of
173    /// `parse::*`, `validation::*`, and the rest of the taxonomy in
174    /// `prose/canon/ERROR.md`. Consumers route on this, not on message text.
175    pub fn code(&self) -> &'static str {
176        match self {
177            EditError::InvalidFieldName(_) => "edit::invalid_field_name",
178            EditError::UnknownField(_) => "edit::unknown_field",
179            EditError::InvalidKindName(_) => "edit::invalid_kind_name",
180            EditError::ReservedKind => "edit::reserved_kind",
181            EditError::IndexOutOfRange { .. } => "edit::index_out_of_range",
182            EditError::ValueTooDeep { .. } => "edit::value_too_deep",
183            EditError::Import(_) => "edit::import",
184            EditError::FieldRichtextDecode { .. } => "edit::field_richtext_decode",
185            EditError::FieldNotContent { .. } => "edit::field_not_content",
186            EditError::FieldRichtextNotInline(_) => "edit::field_richtext_not_inline",
187            EditError::FieldConform { .. } => "edit::field_conform",
188            EditError::ContentApply(_) => "edit::content_apply",
189        }
190    }
191
192    /// The facts this error's message interpolates. See
193    /// [`Diagnostic::args`](crate::error::Diagnostic::args); `ERROR.md`
194    /// § "Diagnostic args" says per code whether an empty map means a fixed
195    /// sentence or a fallback to `message`.
196    ///
197    /// `field` and `kind` ride here despite [`doc_path`](Self::doc_path) also
198    /// folding them into the anchor. The rule against duplicating an anchor
199    /// bars the assembled `path` string, and recovering a name from one is
200    /// unsound anyway: [`DocPath`](crate::path::DocPath) renders field
201    /// segments unescaped and parses on `.` and `[`, so exactly the malformed
202    /// names `InvalidFieldName` reports can round-trip into other segments.
203    pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
204        match self {
205            EditError::InvalidFieldName(field) => diag_args! { "field" => field },
206            EditError::UnknownField(field) => diag_args! { "field" => field },
207            EditError::InvalidKindName(kind) => diag_args! { "kind" => kind },
208            EditError::ReservedKind => diag_args! {},
209            EditError::IndexOutOfRange { index, len } => diag_args! {
210                "index" => index,
211                "len" => len,
212            },
213            EditError::ValueTooDeep { max } => diag_args! { "max" => max },
214            EditError::Import(_) => diag_args! {},
215            EditError::FieldRichtextDecode { field, message: _ } => diag_args! {
216                "field" => field,
217            },
218            EditError::FieldNotContent { field, declared } => diag_args! {
219                "field" => field,
220                "declared" => declared,
221            },
222            EditError::FieldRichtextNotInline(field) => diag_args! { "field" => field },
223            EditError::FieldConform {
224                field,
225                target,
226                message: _,
227            } => diag_args! {
228                "field" => field,
229                "target" => target,
230            },
231            EditError::ContentApply(_) => diag_args! {},
232        }
233    }
234
235    /// The [`DocPath`](crate::path::DocPath) this error anchors to, relative to
236    /// `base`, the card root the mutator ran against (`main` for a main-card
237    /// mutator, `cards.<kind>[i]` for a composable card, `cards[i]` for a
238    /// structural op on the array; empty only for a card built before it is
239    /// placed, which has no index yet).
240    ///
241    /// A field-named variant anchors at its field under `base`
242    /// (`main.<field>`, `cards.<kind>[i].<field>`, or a bare `<field>` when
243    /// `base` is empty: the pre-placement card);
244    /// [`IndexOutOfRange`](Self::IndexOutOfRange) at the document-array slot
245    /// `cards[index]`, base-independent: a structural op names a slot, not a
246    /// field; and the remaining variants (kind errors, depth, content
247    /// apply/import) anchor at `base` itself when it names a card, else carry no
248    /// anchor (a config-space `$seed` error keeps an empty base). Both
249    /// bindings route through this so a mutator diagnostic is addressable the
250    /// same way a validation diagnostic is.
251    pub fn doc_path(&self, base: &crate::path::DocPath) -> Option<crate::path::DocPath> {
252        use crate::path::DocPath;
253        match self {
254            EditError::InvalidFieldName(f)
255            | EditError::UnknownField(f)
256            | EditError::FieldRichtextNotInline(f)
257            | EditError::FieldConform { field: f, .. }
258            | EditError::FieldNotContent { field: f, .. }
259            | EditError::FieldRichtextDecode { field: f, .. } => Some(base.field(f)),
260            EditError::IndexOutOfRange { index, .. } => Some(DocPath::card(None, *index)),
261            _ => (!base.segs().is_empty()).then(|| base.clone()),
262        }
263    }
264}
265
266/// A field-level invariant violation, shared by every payload ingestion path.
267///
268/// Each boundary maps it to its own error type (`ParseError`,
269/// `StorageError`, `WireError`, `EditError`), so the invariant: every user
270/// field name matches `[A-Za-z_][A-Za-z0-9_]*` and no value nests past the §8
271/// depth limit: is enforced once, here, and a constructed `Document` can
272/// never violate it.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274#[non_exhaustive]
275pub enum FieldViolation {
276    /// The field name does not match `[A-Za-z_][A-Za-z0-9_]*` (spec §3.4 / §10).
277    InvalidName,
278    /// The value nests deeper than [`MAX_YAML_DEPTH`](crate::document::limits::MAX_YAML_DEPTH)
279    /// (spec §8).
280    TooDeep,
281}
282
283/// Map a [`FieldViolation`] to the mutator error surface, the single
284/// translation the `Card` mutators and the validating
285/// [`Payload::insert`](crate::document::Payload::insert) both route through.
286pub(crate) fn edit_error_from_violation(name: &str, v: FieldViolation) -> EditError {
287    match v {
288        FieldViolation::InvalidName => EditError::InvalidFieldName(name.to_string()),
289        FieldViolation::TooDeep => EditError::ValueTooDeep {
290            max: crate::document::limits::MAX_YAML_DEPTH,
291        },
292    }
293}
294
295/// Validate a user field at the mutator boundary, mapping a violation to the
296/// mutator error surface.
297fn check_field(name: &str, value: &serde_json::Value) -> Result<(), EditError> {
298    validate_field(name, value).map_err(|v| edit_error_from_violation(name, v))
299}
300
301/// Depth-bound an out-of-band meta map (`$ext` / `$seed`). Both ride the same
302/// recursive emit/DTO paths, so they carry the same §8 depth bound.
303fn check_meta_depth(map: &serde_json::Map<String, serde_json::Value>) -> Result<(), EditError> {
304    crate::value::depth_check_meta_map(map.clone(), |max| EditError::ValueTooDeep { max })?;
305    Ok(())
306}
307
308/// Validate a user field at the payload boundary: name conformance and
309/// value-depth bound. See [`FieldViolation`] for the invariant.
310pub fn validate_field(key: &str, value: &serde_json::Value) -> Result<(), FieldViolation> {
311    if !is_valid_field_name(key) {
312        return Err(FieldViolation::InvalidName);
313    }
314    if crate::value::json_depth_exceeds(value, crate::document::limits::MAX_YAML_DEPTH) {
315        return Err(FieldViolation::TooDeep);
316    }
317    Ok(())
318}
319
320/// Map a strict-write [`CoercionError`] to the field-write [`EditError`] surface.
321///
322/// A failed richtext coercion routes to the dedicated `FieldRichtext*` variants:
323/// the same surface [`Card::apply_field_richtext_change`] produces, and the
324/// one the wasm/Python error mappers (and their tests) key on. This keys on the
325/// coercion `target`, not the top-level field type, because the richtext
326/// constraint can be **nested**: an `array` of `richtext(inline)` items fails
327/// with `target == "richtext(inline)"` while the field's own type is `Array`.
328/// The richtext coercion emits exactly `"richtext"` / `"richtext(inline)"`
329/// (see `QuillConfig::conform_value`); every other target uses the general
330/// [`EditError::FieldConform`].
331fn conform_error_to_edit(name: &str, err: CoercionError) -> EditError {
332    let CoercionError::Uncoercible {
333        path: _,
334        value: _,
335        target,
336        reason,
337    } = err;
338    if target == "richtext(inline)" {
339        EditError::FieldRichtextNotInline(name.to_string())
340    } else if target == "richtext" {
341        EditError::FieldRichtextDecode {
342            field: name.to_string(),
343            message: reason,
344        }
345    } else {
346        EditError::FieldConform {
347            field: name.to_string(),
348            target,
349            message: reason,
350        }
351    }
352}
353
354/// Compute the canonical stored form of a typed field write **without applying
355/// it**, the dry-run shared by [`Card::commit_field`] and the batched,
356/// all-or-nothing [`TypedWriter::set_all`](crate::TypedWriter::set_all).
357///
358/// Strict `Leniency::Write` conform against `schema`; the name and stored-value
359/// depth are validated too, so a batch can collect every violation before any
360/// mutation. The unknown-name case never reaches here: the editor rejects it
361/// with [`EditError::UnknownField`] before there is a schema to conform against.
362pub(crate) fn resolve_field_write(
363    name: &str,
364    value: QuillValue,
365    schema: &FieldSchema,
366) -> Result<QuillValue, EditError> {
367    if !is_valid_field_name(name) {
368        return Err(EditError::InvalidFieldName(name.to_string()));
369    }
370    let stored = QuillConfig::conform_value(&value, schema, name, Leniency::Write)
371        .map_err(|e| conform_error_to_edit(name, e))?;
372    // Depth-bound the stored form (name already validated above).
373    check_field(name, stored.as_json())?;
374    Ok(stored)
375}
376
377impl Document {
378    pub fn set_quill_ref(&mut self, reference: QuillReference) {
379        self.main_mut().payload_mut().set_quill(reference);
380    }
381
382    pub fn card_mut(&mut self, index: usize) -> Option<&mut Card> {
383        self.cards_mut().get_mut(index)
384    }
385
386    /// Append a composable card. Its `$kind` must be a valid, non-reserved
387    /// composable kind ([`EditError::InvalidKindName`] /
388    /// [`EditError::ReservedKind`] otherwise): the invariant for any card in
389    /// the cards list, enforced here so every entry path shares it.
390    pub fn push_card(&mut self, card: Card) -> Result<(), EditError> {
391        Self::check_composable_kind(&card)?;
392        self.cards_vec_mut().push(card);
393        Ok(())
394    }
395
396    /// Insert a composable card at `index` (`index > len` →
397    /// [`EditError::IndexOutOfRange`]; invalid `$kind` →
398    /// [`EditError::InvalidKindName`] / [`EditError::ReservedKind`]).
399    pub fn insert_card(&mut self, index: usize, card: Card) -> Result<(), EditError> {
400        let len = self.cards().len();
401        if index > len {
402            return Err(EditError::IndexOutOfRange { index, len });
403        }
404        Self::check_composable_kind(&card)?;
405        self.cards_vec_mut().insert(index, card);
406        Ok(())
407    }
408
409    /// Validate that `card`'s `$kind` is a valid, non-reserved composable kind.
410    /// A card with no `$kind` is rejected as an invalid (empty) name.
411    fn check_composable_kind(card: &Card) -> Result<(), EditError> {
412        let kind = card.kind().unwrap_or("");
413        validate_composable_kind(kind).map_err(|e| match e {
414            CardKindError::InvalidName => EditError::InvalidKindName(kind.to_string()),
415            CardKindError::Reserved => EditError::ReservedKind,
416        })
417    }
418
419    pub fn remove_card(&mut self, index: usize) -> Option<Card> {
420        if index >= self.cards().len() {
421            return None;
422        }
423        Some(self.cards_vec_mut().remove(index))
424    }
425
426    /// Replace the `$kind` of the composable card at `index`.
427    ///
428    /// Only the `$kind` metadata changes; the payload and body are untouched
429    /// (field-bag semantics). Old-schema fields linger in the bag; new-schema
430    /// fields are absent until set explicitly. Schema migration is the caller's
431    /// responsibility: this is a structural primitive.
432    ///
433    /// Returns [`EditError::IndexOutOfRange`], [`EditError::InvalidKindName`],
434    /// or [`EditError::ReservedKind`] on constraint violations.
435    pub fn set_card_kind(
436        &mut self,
437        index: usize,
438        new_kind: impl Into<String>,
439    ) -> Result<(), EditError> {
440        let new_kind = new_kind.into();
441        validate_composable_kind(&new_kind).map_err(|e| match e {
442            CardKindError::InvalidName => EditError::InvalidKindName(new_kind.clone()),
443            CardKindError::Reserved => EditError::ReservedKind,
444        })?;
445        let len = self.cards().len();
446        let card = self
447            .card_mut(index)
448            .ok_or(EditError::IndexOutOfRange { index, len })?;
449        card.payload_mut().set_kind(new_kind);
450        Ok(())
451    }
452
453    /// Move card at `from` to position `to`. No-op when `from == to`.
454    /// Either index out of range → [`EditError::IndexOutOfRange`].
455    pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
456        let len = self.cards().len();
457        if from >= len {
458            return Err(EditError::IndexOutOfRange { index: from, len });
459        }
460        if to >= len {
461            return Err(EditError::IndexOutOfRange { index: to, len });
462        }
463        if from == to {
464            return Ok(());
465        }
466        let card = self.cards_vec_mut().remove(from);
467        self.cards_vec_mut().insert(to, card);
468        Ok(())
469    }
470}
471
472impl Card {
473    /// Create a composable card with the given kind, no fields, and an empty body.
474    pub fn new(kind: impl Into<String>) -> Result<Self, EditError> {
475        let kind = kind.into();
476        validate_composable_kind(&kind).map_err(|e| match e {
477            CardKindError::InvalidName => EditError::InvalidKindName(kind.clone()),
478            CardKindError::Reserved => EditError::ReservedKind,
479        })?;
480        let mut payload = Payload::new();
481        payload.set_kind(kind);
482        Ok(Card::from_parts(
483            payload,
484            quillmark_content::Content::empty(),
485        ))
486    }
487
488    /// Store a payload field verbatim, clearing any `!must_fill` marker on that
489    /// key, the opaque store (**store** = verbatim, coercion deferred to render;
490    /// contrast the typed [`TypedWriter::set`](crate::TypedWriter::set)). Scalars
491    /// convert in place (`store_field("qty", 3)`); see the `From` impls on
492    /// [`QuillValue`].
493    ///
494    /// Returns [`EditError::InvalidFieldName`] when `name` does not match
495    /// `[A-Za-z_][A-Za-z0-9_]*`.
496    pub fn store_field(&mut self, name: &str, value: impl Into<QuillValue>) -> Result<(), EditError> {
497        self.payload_mut()
498            .insert(name.to_string(), value.into())
499            .map_err(|v| edit_error_from_violation(name, v))?;
500        Ok(())
501    }
502
503    /// Store a payload field verbatim and mark it as a `!must_fill` placeholder.
504    /// `Null` emits as `key: !must_fill`; scalars/sequences as `key: !must_fill <value>`.
505    /// The opaque store's fill variant (quill-free, verbatim); same validation as
506    /// [`Card::store_field`].
507    pub fn store_fill(&mut self, name: &str, value: impl Into<QuillValue>) -> Result<(), EditError> {
508        self.payload_mut()
509            .insert_fill(name.to_string(), value.into())
510            .map_err(|v| edit_error_from_violation(name, v))?;
511        Ok(())
512    }
513
514    /// Store several payload fields verbatim and atomically, clearing any
515    /// `!must_fill` marker on each key, the opaque store's batch (contrast the
516    /// typed [`TypedWriter::set_all`](crate::TypedWriter::set_all)). The whole
517    /// batch is validated first: on any violation nothing is applied and every
518    /// offending field is reported as a `(name, error)` pair, so a caller feeding
519    /// externally-sourced names (database columns, form keys) sees all violations
520    /// in one pass instead of fix-rerun-repeat. Per-field rules are those of
521    /// [`Card::store_field`]; insertion order follows the iterator, and a
522    /// repeated name behaves like repeated `store_field` calls (last value
523    /// wins, first position kept).
524    pub fn store_fields<K, V, I>(&mut self, fields: I) -> Result<(), Vec<(String, EditError)>>
525    where
526        K: Into<String>,
527        V: Into<QuillValue>,
528        I: IntoIterator<Item = (K, V)>,
529    {
530        let fields: Vec<(String, QuillValue)> = fields
531            .into_iter()
532            .map(|(k, v)| (k.into(), v.into()))
533            .collect();
534        let errors: Vec<(String, EditError)> = fields
535            .iter()
536            .filter_map(|(name, value)| {
537                check_field(name, value.as_json())
538                    .err()
539                    .map(|e| (name.clone(), e))
540            })
541            .collect();
542        if !errors.is_empty() {
543            return Err(errors);
544        }
545        // Batch validated above; apply through the unchecked insert so the
546        // whole-batch check is not re-run per field.
547        for (name, value) in fields {
548            self.payload_mut().insert_unchecked(name, value);
549        }
550        Ok(())
551    }
552
553    /// Remove a payload field; returns `Ok(None)` if the name is absent.
554    /// Removal has no lane: the one verb serves every write path. Same
555    /// validation as [`Card::store_field`].
556    pub fn remove_field(&mut self, name: &str) -> Result<Option<QuillValue>, EditError> {
557        if !is_valid_field_name(name) {
558            return Err(EditError::InvalidFieldName(name.to_string()));
559        }
560        Ok(self.payload_mut().remove(name))
561    }
562
563    /// Replace the card's opaque `$ext` map wholesale, inserting it at the
564    /// canonical position (after `$quill`/`$kind`, before user fields)
565    /// when none existed. Passing an empty map records an explicit `$ext: {}`.
566    ///
567    /// `$ext` carries out-of-band consumer state (editor renames, agent
568    /// annotations, …) and is stripped from [`Document::to_plate_json`], so a
569    /// write here can never affect a render. Any nested comments attached to a
570    /// replaced `$ext` are dropped.
571    /// Returns [`EditError::ValueTooDeep`] when the map nests past the §8
572    /// depth limit: `$ext` never reaches the plate JSON, but it does flow
573    /// through the recursive emit and DTO paths, so it carries the same
574    /// depth bound as user fields.
575    ///
576    /// Quill-free and never coerced: an opaque `store_*` verb by the vocabulary
577    /// rule, not a typed `set`.
578    pub fn store_ext(
579        &mut self,
580        value: serde_json::Map<String, serde_json::Value>,
581    ) -> Result<(), EditError> {
582        check_meta_depth(&value)?;
583        self.payload_mut().set_ext(value);
584        Ok(())
585    }
586
587    /// Remove the card's `$ext` map *entirely*, returning the previous map if
588    /// present. This is a blunt escape hatch: it discards every namespace
589    /// (`$ext.editor`, `$ext.agent`, …) at once. To clear consumer
590    /// state, prefer [`Card::remove_ext_namespace`], which drops only your
591    /// own slot and leaves sibling consumers' state intact.
592    pub fn remove_ext(&mut self) -> Option<serde_json::Map<String, serde_json::Value>> {
593        self.payload_mut().take_ext()
594    }
595
596    /// Merge `value` into the card's `$ext` map under `namespace`, creating
597    /// the map when absent and replacing any existing value at that key.
598    ///
599    /// This is the recommended way to write `$ext`: it preserves sibling
600    /// namespaces, so independent consumers keying on their own slot
601    /// (`$ext.editor`, `$ext.agent`, …) don't clobber each other.
602    /// Returns [`EditError::ValueTooDeep`] when the merged map nests past
603    /// the §8 depth limit (see [`Card::store_ext`]); the card's `$ext` is
604    /// unchanged on error. Quill-free and never coerced: an opaque `store_*`
605    /// verb.
606    pub fn store_ext_namespace(
607        &mut self,
608        namespace: impl Into<String>,
609        value: serde_json::Value,
610    ) -> Result<(), EditError> {
611        self.merge_meta_namespace(MetaKey::Ext, namespace.into(), value)
612    }
613
614    /// Remove `namespace` from the card's `$ext` map, returning the value
615    /// that was stored there (or `None` when the map or the key was absent).
616    ///
617    /// This is the recommended way to clear `$ext` state: it is the
618    /// namespace-scoped inverse of [`Card::store_ext_namespace`] and preserves
619    /// sibling namespaces, where [`Card::remove_ext`] would wipe them all.
620    /// When removing the last namespace empties the map, the `$ext` entry is
621    /// dropped entirely (not left as `$ext: {}`), so
622    /// `store_ext_namespace(ns, v)` followed by `remove_ext_namespace(ns)`
623    /// restores a card that had no `$ext` to its original state.
624    pub fn remove_ext_namespace(&mut self, namespace: &str) -> Option<serde_json::Value> {
625        self.remove_meta_namespace(MetaKey::Ext, namespace)
626    }
627
628    /// Merge `value` into the `key` map under `namespace`, preserving siblings.
629    /// The map is written back only after the depth check passes, so the card
630    /// is unchanged on error.
631    fn merge_meta_namespace(
632        &mut self,
633        key: MetaKey,
634        namespace: String,
635        value: serde_json::Value,
636    ) -> Result<(), EditError> {
637        let mut map = self.payload_mut().meta(key).cloned().unwrap_or_default();
638        map.insert(namespace, value);
639        check_meta_depth(&map)?;
640        self.payload_mut().take_meta(key);
641        self.payload_mut().set_meta(key, map);
642        Ok(())
643    }
644
645    /// Drop `namespace` from the `key` map, returning what was there. Emptying
646    /// the map drops the entry rather than leaving `$<key>: {}`, so a
647    /// merge/remove pair restores a card that carried no such entry.
648    fn remove_meta_namespace(
649        &mut self,
650        key: MetaKey,
651        namespace: &str,
652    ) -> Option<serde_json::Value> {
653        let mut map = self.payload_mut().take_meta(key)?;
654        let removed = map.remove(namespace);
655        if !map.is_empty() {
656            self.payload_mut().set_meta(key, map);
657        }
658        removed
659    }
660
661    /// The raw `$seed` map (keyed by card-kind), or `None`. For a parsed,
662    /// per-kind overlay, index this map by kind and pass the entry to
663    /// [`crate::SeedOverlay::from_json`]. Only the main card carries `$seed`.
664    pub fn seed(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
665        self.payload().seed()
666    }
667
668    /// Merge a card-kind's seed overlay `value` into the card's `$seed` map
669    /// under `card_kind`, creating the map when absent and replacing any
670    /// existing overlay for that kind. Sibling kinds are preserved: this is
671    /// the per-kind-safe writer, the seed analogue of
672    /// [`Card::store_ext_namespace`]. `card_kind` must be a valid, non-reserved
673    /// composable kind ([`EditError::InvalidKindName`] / [`EditError::ReservedKind`]
674    /// otherwise): `$seed` is keyed by composable card-kind, unlike the
675    /// free-form namespaces of `$ext`. Returns [`EditError::ValueTooDeep`] when
676    /// the merged map nests past the §8 depth limit; the card is unchanged on
677    /// error. Quill-free and never coerced: an opaque `store_*` verb.
678    pub fn store_seed_namespace(
679        &mut self,
680        card_kind: impl Into<String>,
681        value: serde_json::Value,
682    ) -> Result<(), EditError> {
683        let card_kind = card_kind.into();
684        validate_composable_kind(&card_kind).map_err(|e| match e {
685            CardKindError::InvalidName => EditError::InvalidKindName(card_kind.clone()),
686            CardKindError::Reserved => EditError::ReservedKind,
687        })?;
688        self.merge_meta_namespace(MetaKey::Seed, card_kind, value)
689    }
690
691    /// Remove `card_kind` from the card's `$seed` map, returning the overlay
692    /// stored there (or `None`). When removing the last kind empties the map,
693    /// the `$seed` entry is dropped entirely (not left as `$seed: {}`).
694    /// The seed analogue of [`Card::remove_ext_namespace`].
695    pub fn remove_seed_namespace(&mut self, card_kind: &str) -> Option<serde_json::Value> {
696        self.remove_meta_namespace(MetaKey::Seed, card_kind)
697    }
698
699    /// Install the body content directly from a pre-built [`Content`]: **value
700    /// semantics**, the native richtext writer. A content is valid by
701    /// construction, so this is infallible: no markdown import, no diff, no
702    /// schema check; the identity anchors of the previous body are *gone*
703    /// (install-this-exact-value, so a `to_markdown → install` round-trip cannot
704    /// resurrect them). Use it when the caller already holds a content (a decoded
705    /// canonical-JSON body, another field's value, an editor's serialized state).
706    /// For "here's new authored markdown," use [`revise_body`](Self::revise_body),
707    /// which rebases surviving anchors; the cold-import path is spelled at the
708    /// call site as `install_body(import_body(md)?)`.
709    pub fn install_body(&mut self, content: Content) {
710        self.overwrite_body(content);
711    }
712
713    /// Install a richtext field's content directly from a pre-built [`Content`]:
714    /// the field-level twin of [`install_body`](Self::install_body). Value
715    /// semantics: stores the canonical content JSON verbatim (identity marks and
716    /// content-only marks such as `underline` intact), no diff, no schema check
717    /// (schema-blind, like [`apply_field_richtext_change`](Self::apply_field_richtext_change),
718    /// [`commit_field`](Self::commit_field) is the typed door). Returns
719    /// [`EditError::InvalidFieldName`] for a malformed name.
720    ///
721    /// **Richtext only.** A `plaintext` field rests as its literal string, so an
722    /// object landed here is off that field's resting form: a repairable
723    /// departure the next bound load ([`Quill::conform`](crate::Quill::conform))
724    /// converges. Write one through the typed door.
725    pub fn install_field(&mut self, name: &str, content: Content) -> Result<(), EditError> {
726        if !is_valid_field_name(name) {
727            return Err(EditError::InvalidFieldName(name.to_string()));
728        }
729        self.store_field_content(name, &content);
730        Ok(())
731    }
732
733    /// Store `content` as the canonical content-JSON value of field `name`: the
734    /// one place a richtext field's content is committed to the payload, shared by
735    /// [`install_field`](Self::install_field), [`revise_field`](Self::revise_field),
736    /// and [`apply_field_richtext_change`](Self::apply_field_richtext_change).
737    /// Assumes `name` is already validated (all three callers check it or resolve
738    /// an existing field first).
739    fn store_field_content(&mut self, name: &str, content: &Content) {
740        let canonical = quillmark_content::serial::to_canonical_value(content);
741        self.payload_mut()
742            .insert_unchecked(name.to_string(), QuillValue::from_json(canonical));
743    }
744
745    /// Write-time commit: validate and normalize `value` per the field's schema
746    /// `type` and store the canonical form. The typed sibling of the opaque
747    /// [`store_field`](Self::store_field): the one write verb for *every* field
748    /// type (richtext today, any future content model tomorrow), dispatching on
749    /// the [`FieldSchema`] rather than growing a per-type method.
750    ///
751    /// The two write disciplines: [`store_field`](Self::store_field) stores the
752    /// value opaquely and defers coercion to render (keystroke-level state,
753    /// data-in-flight); `commit_field` canonicalizes now and fails now (an
754    /// editor blur/save, an agent write). Neither is forced on the other.
755    ///
756    /// Behavior by `type`:
757    /// - **richtext**: imports a markdown string / adopts a content object and
758    ///   stores canonical content JSON, so identity marks (anchors, island ids)
759    ///   live on the stored value from the write; a `richtext(inline)` schema
760    ///   rejects a multi-block value with [`EditError::FieldRichtextNotInline`].
761    /// - **plaintext**: stores the **literal string**, importing a string
762    ///   verbatim or projecting a content object through `to_plaintext`. The
763    ///   codec is lossless on plain content, so the string is the whole value;
764    ///   the plate still carries the content object (the render floor coerces to
765    ///   it). A value carrying marks, islands, or block formatting is rejected,
766    ///   not stripped.
767    /// - **scalars** (`string`/`integer`/`number`/`boolean`/`datetime`): stores
768    ///   the coerced canonical (`"3"` → `3`), applying only value-parsing
769    ///   normalizations; a cross-type value that the render floor would coerce
770    ///   (e.g. `1` → `true`) or a shape mismatch fails here instead.
771    /// - **array** / **object**: coerces each element/property against the
772    ///   element/property schema.
773    /// - **null**: passes through unchanged (the null ≡ absent rule); nothing
774    ///   is coerced (a richtext `null` reads back as the empty content via
775    ///   [`field_richtext`](Self::field_richtext)).
776    ///
777    /// The caller supplies the `schema` because a [`Document`] holds only a
778    /// `$quill` *reference*, not the resolved schema; an editor holds it (see
779    /// [`crate::TypedWriter`], which resolves the schema per field and calls
780    /// this).
781    ///
782    /// Returns [`EditError::InvalidFieldName`] for a malformed name,
783    /// [`EditError::FieldRichtextDecode`] / [`EditError::FieldRichtextNotInline`]
784    /// for a richtext field, [`EditError::FieldConform`] for any other type
785    /// mismatch, and [`EditError::ValueTooDeep`] when the stored value nests
786    /// past the §8 depth limit.
787    pub fn commit_field(
788        &mut self,
789        name: &str,
790        value: impl Into<QuillValue>,
791        schema: &FieldSchema,
792    ) -> Result<(), EditError> {
793        let stored = resolve_field_write(name, value.into(), schema)?;
794        // `resolve_field_write` already validated name + stored-value depth.
795        self.payload_mut().insert_unchecked(name.to_string(), stored);
796        Ok(())
797    }
798
799    /// Revise the body from an authored markdown string: **edit semantics**,
800    /// the whole-document (stale-text / LLM / MCP) writer, and the receipt-
801    /// returning default write path. Imports the markdown, diffs it against the
802    /// current body, and rebases surviving identity anchors onto the new text
803    /// (cold import + [`diff_import`]), then returns the text [`Delta`] from the
804    /// old body to the new one: the change an editor bridge maps its own
805    /// positions through across a whole-document replace ([`Delta::map_pos`]).
806    /// Surviving identity anchors rebase; formatting marks are re-derived by the
807    /// fresh import. A pathologically over-nested input (`> MAX_NESTING_DEPTH`)
808    /// returns [`EditError::Import`] rather than silently degrading to the
809    /// empty content. Discard the receipt with `let _ = card.revise_body(md)?;`
810    /// when caret stability is not needed.
811    pub fn revise_body(&mut self, body: impl Into<String>) -> Result<Delta, EditError> {
812        let (content, delta) =
813            diff_import(self.body(), &body.into()).map_err(EditError::Import)?;
814        self.overwrite_body(content);
815        Ok(delta)
816    }
817
818    /// Decode the field's current content (an absent field imports from empty),
819    /// diff `body` against it so surviving anchors rebase, and return the new
820    /// content with its text [`Delta`]: the shared preamble of
821    /// [`revise_field`](Self::revise_field) and
822    /// [`revise_field_checked`](Self::revise_field_checked). Neither stores; the
823    /// caller lands the diffed content (raw, or schema-checked).
824    fn diff_field(
825        &self,
826        name: &str,
827        body: impl Into<String>,
828    ) -> Result<(Content, Delta), EditError> {
829        if !is_valid_field_name(name) {
830            return Err(EditError::InvalidFieldName(name.to_string()));
831        }
832        let base = match self.field_richtext(name) {
833            Some(Ok(rt)) => rt,
834            Some(Err(e)) => {
835                return Err(EditError::FieldRichtextDecode {
836                    field: name.to_string(),
837                    message: e.into_message(),
838                })
839            }
840            None => Content::empty(),
841        };
842        diff_import(&base, &body.into()).map_err(EditError::Import)
843    }
844
845    /// Revise a richtext field from an authored markdown string: the
846    /// field-level twin of [`revise_body`](Self::revise_body), and the
847    /// field-level `diff_import`. The other field-content writers are the cold
848    /// [`commit_field`](Self::commit_field) and the splice
849    /// [`apply_field_richtext_change`](Self::apply_field_richtext_change), so this
850    /// is the anchor-preserving path for rewriting a richtext field's markdown
851    /// wholesale. Decodes the field's current content as the diff base (an **absent**
852    /// field cold-imports from empty), rebases surviving anchors onto the new
853    /// text, re-stores the canonical content, and returns the text [`Delta`].
854    ///
855    /// Schema-blind by design: the content-writer stratum splices without the
856    /// quill (like [`apply_field_richtext_change`](Self::apply_field_richtext_change));
857    /// [`commit_field`](Self::commit_field) is the typed door that enforces
858    /// `richtext(inline)`, and a violation otherwise surfaces at validate/render.
859    ///
860    /// **Richtext only**, and the exclusion bites: this decodes the current value
861    /// as markdown, which eats a `plaintext` field's escapes (`a \*b\*` commits
862    /// back as `a *b*`) and leaves the corpus where that field's rest is a
863    /// string. The typed
864    /// [`TypedWriter::revise_field`](crate::TypedWriter::revise_field) resolves
865    /// the codec from the schema and is the plaintext-safe door.
866    ///
867    /// Returns [`EditError::InvalidFieldName`] for a malformed name,
868    /// [`EditError::FieldRichtextDecode`] when the field is present but is not a
869    /// richtext content (a scalar a `store_field` wrote), and
870    /// [`EditError::Import`] on an over-nested markdown input.
871    pub fn revise_field(&mut self, name: &str, body: impl Into<String>) -> Result<Delta, EditError> {
872        let (content, delta) = self.diff_field(name, body)?;
873        self.store_field_content(name, &content);
874        Ok(delta)
875    }
876
877    /// Revise a richtext field from markdown **with schema enforcement**: the
878    /// typed *and* anchor-preserving field write that neither
879    /// [`revise_field`](Self::revise_field) nor [`commit_field`](Self::commit_field)
880    /// provides alone. [`revise_field`](Self::revise_field) rebases anchors but is
881    /// schema-blind; [`commit_field`](Self::commit_field) enforces the schema but
882    /// cold-imports (the previous value's anchors are gone). This does both: diff
883    /// the markdown against the field's current content so surviving anchors rebase
884    /// (as [`revise_field`](Self::revise_field)), then enforce `schema` on the
885    /// *diffed result* through the same typed-conform path
886    /// [`commit_field`](Self::commit_field) runs, so a `richtext(inline)` schema
887    /// rejects a multi-block result with [`EditError::FieldRichtextNotInline`],
888    /// the error surface unchanged, while the anchors survive. Returns the text
889    /// [`Delta`] receipt.
890    ///
891    /// The primitive that [`TypedWriter::revise_field`](crate::TypedWriter::revise_field)
892    /// and [`CardWriter::revise_field`](crate::CardWriter::revise_field) wrap: they
893    /// resolve `schema` from the bound quill and call here. The schema runs on the
894    /// content the diff produced, so a non-richtext `schema` (nothing to preserve)
895    /// fails with the same [`EditError::FieldConform`]
896    /// [`commit_field`](Self::commit_field) would raise.
897    ///
898    /// Errors: [`EditError::InvalidFieldName`], [`EditError::FieldRichtextDecode`]
899    /// when the field is present but not a richtext content, [`EditError::Import`]
900    /// on an over-nested markdown input, and the conform errors of
901    /// [`commit_field`](Self::commit_field) on the diffed result. On any error the
902    /// field is unchanged.
903    pub fn revise_field_checked(
904        &mut self,
905        name: &str,
906        body: impl Into<String>,
907        schema: &FieldSchema,
908    ) -> Result<Delta, EditError> {
909        // A `plaintext` field has no anchors to rebase (`is_plain` forbids every
910        // mark), so the anchor lane is meaningless for it and the markdown codec
911        // is actively wrong: decoding the current value as markdown eats its
912        // escapes, so a byte-identical revise of `a \*b\*` would silently commit
913        // `a *b*`. It takes the literal codec instead: a plain-text diff and the
914        // same strict write, `Delta` receipt unchanged.
915        if matches!(schema.r#type, FieldType::PlainText { .. }) {
916            return self.revise_field_plaintext(name, body, schema);
917        }
918        let (content, delta) = self.diff_field(name, body)?;
919        // Enforce `schema` on the diffed (anchor-rebased) content through the same
920        // typed path `commit_field` uses: re-canonicalizing a content object keeps
921        // its identity marks (`decode_richtext_value`), so the inline check fires
922        // on the value anchors survived onto and the error surface is identical.
923        let canonical = quillmark_content::serial::to_canonical_value(&content);
924        let stored = resolve_field_write(name, QuillValue::from_json(canonical), schema)?;
925        self.payload_mut().insert_unchecked(name.to_string(), stored);
926        Ok(delta)
927    }
928
929    /// The `plaintext` arm of [`revise_field_checked`](Self::revise_field_checked):
930    /// diff the authored literal text against the field's current literal
931    /// projection and commit it through the same strict write. No markdown
932    /// import in either direction, so escapes, `*`, and `_` are ordinary
933    /// characters on both sides of the diff and a byte-identical revise is a
934    /// byte no-op. The `Delta` is over the literal text: the coordinate space
935    /// [`TypedReader::get`](crate::TypedReader::get) hands a plaintext field back in.
936    fn revise_field_plaintext(
937        &mut self,
938        name: &str,
939        text: impl Into<String>,
940        schema: &FieldSchema,
941    ) -> Result<Delta, EditError> {
942        // Ahead of the read, not folded into `resolve_field_write`'s own check:
943        // a directly-constructed payload can hold an ill-named field, and the
944        // read would then report its value's decode instead of the bad name.
945        if !is_valid_field_name(name) {
946            return Err(EditError::InvalidFieldName(name.to_string()));
947        }
948        let base = match self.field_plaintext_content(name) {
949            Some(Ok(rt)) => quillmark_content::export::to_plaintext(&rt),
950            Some(Err(e)) => {
951                return Err(EditError::FieldRichtextDecode {
952                    field: name.to_string(),
953                    message: e.into_message(),
954                })
955            }
956            None => String::new(),
957        };
958        // The strict write is the codec: it runs the same `from_plaintext`
959        // boundary cleanup (CRLF, bidi, island slot) and enforces
960        // `plaintext(inline)`, so the committed string is what the diff must
961        // measure against. On any refusal the field is unchanged.
962        let stored = resolve_field_write(name, QuillValue::from(text.into()), schema)?;
963        let delta = quillmark_content::delta::diff(
964            &base,
965            stored
966                .as_json()
967                .as_str()
968                .expect("a plaintext field rests as a string"),
969        );
970        self.payload_mut().insert_unchecked(name.to_string(), stored);
971        Ok(delta)
972    }
973
974    /// Apply a committed field-change bundle to the body content: the native
975    /// form-editor writer. Order is text delta → line ops → mark ops, then one
976    /// terminal normalization ([`Content::apply_field_change`]); mark ranges are
977    /// in final-text coordinates. Returns
978    /// [`EditError::ContentApply`] when an op is out of bounds; the apply is
979    /// all-or-nothing ([`Content::apply_field_change`]), so the body is
980    /// unchanged on error: apply the bundle against the body the delta was
981    /// computed from.
982    pub fn apply_body_change(
983        &mut self,
984        text_delta: &Delta,
985        line_ops: &[LineOp],
986        mark_ops: &[MarkOp],
987    ) -> Result<(), EditError> {
988        self.body_mut()
989            .apply_field_change(text_delta, line_ops, mark_ops)
990            .map_err(EditError::ContentApply)
991    }
992
993    /// Splice a content field-change bundle into a **richtext-valued field**'s
994    /// stored content: the field-path twin of [`apply_body_change`](Self::apply_body_change),
995    /// and what lets identity marks (anchors, island ids) persist on field
996    /// content across incremental edits. Decodes the field's canonical content,
997    /// applies the text delta plus any line/mark ops in the same all-or-nothing
998    /// bundle, and re-stores the canonical result.
999    ///
1000    /// Returns [`EditError::FieldRichtextDecode`] when the field is absent or its
1001    /// stored value is not a richtext content (the caller addresses a field it
1002    /// knows is richtext, exactly as when writing it), and
1003    /// [`EditError::ContentApply`] when the bundle applies out of bounds.
1004    ///
1005    /// **Richtext only**: a `plaintext` field rests as a string, so a splice
1006    /// lands it off its resting form (and its stored string decodes here as
1007    /// markdown). The next bound load converges it.
1008    pub fn apply_field_richtext_change(
1009        &mut self,
1010        name: &str,
1011        text_delta: &Delta,
1012        line_ops: &[LineOp],
1013        mark_ops: &[MarkOp],
1014    ) -> Result<(), EditError> {
1015        let mut content = match self.field_richtext(name) {
1016            Some(Ok(rt)) => rt,
1017            Some(Err(e)) => {
1018                return Err(EditError::FieldRichtextDecode {
1019                    field: name.to_string(),
1020                    message: e.into_message(),
1021                })
1022            }
1023            None => {
1024                return Err(EditError::FieldRichtextDecode {
1025                    field: name.to_string(),
1026                    message: "field is absent".to_string(),
1027                })
1028            }
1029        };
1030        content
1031            .apply_field_change(text_delta, line_ops, mark_ops)
1032            .map_err(EditError::ContentApply)?;
1033        self.store_field_content(name, &content);
1034        Ok(())
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use crate::path::DocPath;
1042
1043    #[test]
1044    fn field_error_anchors_under_the_card_base() {
1045        // A main field write: the `main` base roots the field path.
1046        let main = DocPath::main();
1047        assert_eq!(
1048            EditError::FieldConform {
1049                field: "font_size".into(),
1050                target: "integer".into(),
1051                message: "x".into(),
1052            }
1053            .doc_path(&main)
1054            .unwrap()
1055            .to_string(),
1056            "main.font_size"
1057        );
1058        // A card field write: the card root qualifies the field.
1059        let card = DocPath::card(Some("indorsement"), 1);
1060        assert_eq!(
1061            EditError::UnknownField("signature_block".into())
1062                .doc_path(&card)
1063                .unwrap()
1064                .to_string(),
1065            "cards.indorsement[1].signature_block"
1066        );
1067    }
1068
1069    #[test]
1070    fn index_out_of_range_anchors_at_the_array_slot() {
1071        // Structural op: names a slot, base-independent.
1072        for base in [DocPath::main(), DocPath::card(Some("note"), 0)] {
1073            assert_eq!(
1074                EditError::IndexOutOfRange { index: 4, len: 2 }
1075                    .doc_path(&base)
1076                    .unwrap()
1077                    .to_string(),
1078                "cards[4]"
1079            );
1080        }
1081    }
1082
1083    #[test]
1084    fn kind_and_depth_errors_anchor_at_base_or_nowhere() {
1085        // A kind error on a structural op carries the slot base it was given.
1086        assert_eq!(
1087            EditError::ReservedKind
1088                .doc_path(&DocPath::card(None, 2))
1089                .unwrap()
1090                .to_string(),
1091            "cards[2]"
1092        );
1093        // A config-space `$seed` depth error keeps an empty base: no anchor.
1094        assert_eq!(
1095            EditError::ValueTooDeep { max: 8 }.doc_path(&DocPath::new()),
1096            None
1097        );
1098        // A main-card depth error roots at `main` (its base names the card).
1099        assert_eq!(
1100            EditError::ValueTooDeep { max: 8 }
1101                .doc_path(&DocPath::main())
1102                .unwrap()
1103                .to_string(),
1104            "main"
1105        );
1106    }
1107}