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