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