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