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