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