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}