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-z_][a-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 live on [`Card`] (`set_field`, `set_fill`,
10//! `remove_field`, `set_ext`, `remove_ext`, `set_ext_namespace`,
11//! `remove_ext_namespace`, `replace_body`); [`Document`] keeps
12//! document-level ops (quill-ref, push/insert/remove/move card).
13//!
14//! The `$ext` mutators carry no field-name invariant ($ext is an opaque
15//! mapping that never reaches the plate JSON backends consume), but they do
16//! enforce the §8 value-depth bound: `$ext` flows through the recursive
17//! emit and DTO paths like any other value.
18
19use unicode_normalization::UnicodeNormalization;
20
21use crate::document::meta::{validate_composable_kind, CardKindError};
22use crate::document::{Card, Document, Payload};
23use crate::value::QuillValue;
24use crate::version::QuillReference;
25
26/// `true` if `name` matches `[a-z_][a-z0-9_]*` after NFC normalisation.
27pub fn is_valid_field_name(name: &str) -> bool {
28 let normalized: String = name.nfc().collect();
29 if normalized.is_empty() {
30 return false;
31 }
32 let mut chars = normalized.chars();
33 let first = chars.next().unwrap();
34 if !first.is_ascii_lowercase() && first != '_' {
35 return false;
36 }
37 for ch in chars {
38 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
39 return false;
40 }
41 }
42 true
43}
44
45/// Errors returned by document and card mutators.
46#[derive(Debug, Clone, PartialEq, thiserror::Error)]
47pub enum EditError {
48 #[error("invalid field name '{0}': must match [a-z_][a-z0-9_]*")]
49 InvalidFieldName(String),
50
51 #[error("invalid card kind '{0}': must match [a-z_][a-z0-9_]*")]
52 InvalidKindName(String),
53
54 #[error("card kind 'main' is reserved for the document root")]
55 ReservedKind,
56
57 #[error("index {index} is out of range (len = {len})")]
58 IndexOutOfRange { index: usize, len: usize },
59
60 #[error("value nests deeper than the maximum of {max} levels")]
61 ValueTooDeep { max: usize },
62}
63
64impl EditError {
65 /// The bare variant name (e.g. `"InvalidFieldName"`). Each binding surfaces
66 /// it as the `[EditError::<Variant>]` message prefix; defined once here so a
67 /// new variant cannot drift across the three binding error mappers.
68 pub fn variant_name(&self) -> &'static str {
69 match self {
70 EditError::InvalidFieldName(_) => "InvalidFieldName",
71 EditError::InvalidKindName(_) => "InvalidKindName",
72 EditError::ReservedKind => "ReservedKind",
73 EditError::IndexOutOfRange { .. } => "IndexOutOfRange",
74 EditError::ValueTooDeep { .. } => "ValueTooDeep",
75 }
76 }
77}
78
79/// A field-level invariant violation, shared by every payload ingestion path.
80///
81/// Each boundary maps it to its own error type (`ParseError`,
82/// `StorageError`, `WireError`, `EditError`), so the invariant — every user
83/// field name matches `[a-z_][a-z0-9_]*` and no value nests past the §8
84/// depth limit — is enforced once, here, and a constructed `Document` can
85/// never violate it.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum FieldViolation {
88 /// The field name does not match `[a-z_][a-z0-9_]*` (spec §3.4 / §10).
89 InvalidName,
90 /// The value nests deeper than [`MAX_YAML_DEPTH`](crate::document::limits::MAX_YAML_DEPTH)
91 /// (spec §8).
92 TooDeep,
93}
94
95/// Map a [`FieldViolation`] to the mutator error surface.
96fn check_field(name: &str, value: &serde_json::Value) -> Result<(), EditError> {
97 validate_field(name, value).map_err(|v| match v {
98 FieldViolation::InvalidName => EditError::InvalidFieldName(name.to_string()),
99 FieldViolation::TooDeep => EditError::ValueTooDeep {
100 max: crate::document::limits::MAX_YAML_DEPTH,
101 },
102 })
103}
104
105/// Depth-bound an out-of-band meta map (`$ext` / `$seed`). Both ride the same
106/// recursive emit/DTO paths, so they carry the same §8 depth bound.
107fn check_meta_depth(map: &serde_json::Map<String, serde_json::Value>) -> Result<(), EditError> {
108 let as_value = serde_json::Value::Object(map.clone());
109 if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH) {
110 return Err(EditError::ValueTooDeep {
111 max: crate::document::limits::MAX_YAML_DEPTH,
112 });
113 }
114 Ok(())
115}
116
117/// Validate a user field at the payload boundary: name conformance and
118/// value-depth bound. See [`FieldViolation`] for the invariant.
119pub fn validate_field(key: &str, value: &serde_json::Value) -> Result<(), FieldViolation> {
120 if !is_valid_field_name(key) {
121 return Err(FieldViolation::InvalidName);
122 }
123 if crate::value::json_depth_exceeds(value, crate::document::limits::MAX_YAML_DEPTH) {
124 return Err(FieldViolation::TooDeep);
125 }
126 Ok(())
127}
128
129impl Document {
130 pub fn set_quill_ref(&mut self, reference: QuillReference) {
131 self.main_mut().payload_mut().set_quill(reference);
132 }
133
134 pub fn card_mut(&mut self, index: usize) -> Option<&mut Card> {
135 self.cards_mut().get_mut(index)
136 }
137
138 /// Append a composable card. Its `$kind` must be a valid, non-reserved
139 /// composable kind ([`EditError::InvalidKindName`] /
140 /// [`EditError::ReservedKind`] otherwise) — the invariant for any card in
141 /// the cards list, enforced here so every entry path shares it.
142 pub fn push_card(&mut self, card: Card) -> Result<(), EditError> {
143 Self::check_composable_kind(&card)?;
144 self.cards_vec_mut().push(card);
145 Ok(())
146 }
147
148 /// Insert a composable card at `index` (`index > len` →
149 /// [`EditError::IndexOutOfRange`]; invalid `$kind` →
150 /// [`EditError::InvalidKindName`] / [`EditError::ReservedKind`]).
151 pub fn insert_card(&mut self, index: usize, card: Card) -> Result<(), EditError> {
152 let len = self.cards().len();
153 if index > len {
154 return Err(EditError::IndexOutOfRange { index, len });
155 }
156 Self::check_composable_kind(&card)?;
157 self.cards_vec_mut().insert(index, card);
158 Ok(())
159 }
160
161 /// Validate that `card`'s `$kind` is a valid, non-reserved composable kind.
162 /// A card with no `$kind` is rejected as an invalid (empty) name.
163 fn check_composable_kind(card: &Card) -> Result<(), EditError> {
164 let kind = card.kind().unwrap_or("");
165 validate_composable_kind(kind).map_err(|e| match e {
166 CardKindError::InvalidName => EditError::InvalidKindName(kind.to_string()),
167 CardKindError::Reserved => EditError::ReservedKind,
168 })
169 }
170
171 pub fn remove_card(&mut self, index: usize) -> Option<Card> {
172 if index >= self.cards().len() {
173 return None;
174 }
175 Some(self.cards_vec_mut().remove(index))
176 }
177
178 /// Replace the `$kind` of the composable card at `index`.
179 ///
180 /// Only the `$kind` metadata changes; the payload and body are untouched
181 /// (field-bag semantics). Old-schema fields linger in the bag; new-schema
182 /// fields are absent until set explicitly. Schema migration is the caller's
183 /// responsibility — this is a structural primitive.
184 ///
185 /// Returns [`EditError::IndexOutOfRange`], [`EditError::InvalidKindName`],
186 /// or [`EditError::ReservedKind`] on constraint violations.
187 pub fn set_card_kind(
188 &mut self,
189 index: usize,
190 new_kind: impl Into<String>,
191 ) -> Result<(), EditError> {
192 let new_kind = new_kind.into();
193 validate_composable_kind(&new_kind).map_err(|e| match e {
194 CardKindError::InvalidName => EditError::InvalidKindName(new_kind.clone()),
195 CardKindError::Reserved => EditError::ReservedKind,
196 })?;
197 let len = self.cards().len();
198 let card = self
199 .card_mut(index)
200 .ok_or(EditError::IndexOutOfRange { index, len })?;
201 card.payload_mut().set_kind(new_kind);
202 Ok(())
203 }
204
205 /// Move card at `from` to position `to`. No-op when `from == to`.
206 /// Either index out of range → [`EditError::IndexOutOfRange`].
207 pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
208 let len = self.cards().len();
209 if from >= len {
210 return Err(EditError::IndexOutOfRange { index: from, len });
211 }
212 if to >= len {
213 return Err(EditError::IndexOutOfRange { index: to, len });
214 }
215 if from == to {
216 return Ok(());
217 }
218 let card = self.cards_vec_mut().remove(from);
219 self.cards_vec_mut().insert(to, card);
220 Ok(())
221 }
222}
223
224impl Card {
225 /// Create a composable card with the given kind, no fields, and an empty body.
226 pub fn new(kind: impl Into<String>) -> Result<Self, EditError> {
227 let kind = kind.into();
228 validate_composable_kind(&kind).map_err(|e| match e {
229 CardKindError::InvalidName => EditError::InvalidKindName(kind.clone()),
230 CardKindError::Reserved => EditError::ReservedKind,
231 })?;
232 let mut payload = Payload::new();
233 payload.set_kind(kind);
234 Ok(Card::from_parts(payload, String::new()))
235 }
236
237 /// Set a payload field, clearing any `!must_fill` marker on that key.
238 ///
239 /// Returns [`EditError::InvalidFieldName`] when `name` does not match
240 /// `[a-z_][a-z0-9_]*`.
241 pub fn set_field(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
242 check_field(name, value.as_json())?;
243 self.payload_mut().insert(name.to_string(), value);
244 Ok(())
245 }
246
247 /// Set a payload field and mark it as a `!must_fill` placeholder.
248 /// `Null` emits as `key: !must_fill`; scalars/sequences as `key: !must_fill <value>`.
249 /// Same validation as [`Card::set_field`].
250 pub fn set_fill(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
251 check_field(name, value.as_json())?;
252 self.payload_mut().insert_fill(name.to_string(), value);
253 Ok(())
254 }
255
256 /// Remove a payload field; returns `Ok(None)` if the name is absent.
257 /// Same validation as [`Card::set_field`].
258 pub fn remove_field(&mut self, name: &str) -> Result<Option<QuillValue>, EditError> {
259 if !is_valid_field_name(name) {
260 return Err(EditError::InvalidFieldName(name.to_string()));
261 }
262 Ok(self.payload_mut().remove(name))
263 }
264
265 /// Replace the card's opaque `$ext` map wholesale, inserting it at the
266 /// canonical position (after `$quill`/`$kind`/`$id`, before user fields)
267 /// when none existed. Passing an empty map records an explicit `$ext: {}`.
268 ///
269 /// `$ext` carries out-of-band consumer state (editor renames, agent
270 /// annotations, …) and is stripped from [`Document::to_plate_json`], so a
271 /// write here can never affect a render. Any nested comments attached to a
272 /// replaced `$ext` are dropped.
273 /// Returns [`EditError::ValueTooDeep`] when the map nests past the §8
274 /// depth limit — `$ext` never reaches the plate JSON, but it does flow
275 /// through the recursive emit and DTO paths, so it carries the same
276 /// depth bound as user fields.
277 pub fn set_ext(
278 &mut self,
279 value: serde_json::Map<String, serde_json::Value>,
280 ) -> Result<(), EditError> {
281 check_meta_depth(&value)?;
282 self.payload_mut().set_ext(value);
283 Ok(())
284 }
285
286 /// Remove the card's `$ext` map *entirely*, returning the previous map if
287 /// present. This is a blunt escape hatch — it discards every namespace
288 /// (`$ext.presentation`, `$ext.agent`, …) at once. To clear consumer
289 /// state, prefer [`Card::remove_ext_namespace`], which drops only your
290 /// own slot and leaves sibling consumers' state intact.
291 pub fn remove_ext(&mut self) -> Option<serde_json::Map<String, serde_json::Value>> {
292 self.payload_mut().take_ext()
293 }
294
295 /// Merge `value` into the card's `$ext` map under `namespace`, creating
296 /// the map when absent and replacing any existing value at that key.
297 ///
298 /// This is the recommended way to write `$ext`: it preserves sibling
299 /// namespaces, so independent consumers keying on their own slot
300 /// (`$ext.presentation`, `$ext.agent`, …) don't clobber each other.
301 /// Returns [`EditError::ValueTooDeep`] when the merged map nests past
302 /// the §8 depth limit (see [`Card::set_ext`]); the card's `$ext` is
303 /// unchanged on error.
304 pub fn set_ext_namespace(
305 &mut self,
306 namespace: impl Into<String>,
307 value: serde_json::Value,
308 ) -> Result<(), EditError> {
309 let mut map = self.payload_mut().ext().cloned().unwrap_or_default();
310 map.insert(namespace.into(), value);
311 check_meta_depth(&map)?;
312 self.payload_mut().take_ext();
313 self.payload_mut().set_ext(map);
314 Ok(())
315 }
316
317 /// Remove `namespace` from the card's `$ext` map, returning the value
318 /// that was stored there (or `None` when the map or the key was absent).
319 ///
320 /// This is the recommended way to clear `$ext` state: it is the
321 /// namespace-scoped inverse of [`Card::set_ext_namespace`] and preserves
322 /// sibling namespaces, where [`Card::remove_ext`] would wipe them all.
323 /// When removing the last namespace empties the map, the `$ext` entry is
324 /// dropped entirely (not left as `$ext: {}`), so
325 /// `set_ext_namespace(ns, v)` followed by `remove_ext_namespace(ns)`
326 /// restores a card that had no `$ext` to its original state.
327 pub fn remove_ext_namespace(&mut self, namespace: &str) -> Option<serde_json::Value> {
328 let mut map = self.payload_mut().take_ext()?;
329 let removed = map.remove(namespace);
330 if !map.is_empty() {
331 self.payload_mut().set_ext(map);
332 }
333 removed
334 }
335
336 /// The raw `$seed` map (keyed by card-kind), or `None`. For a parsed,
337 /// per-kind overlay, index this map by kind and pass the entry to
338 /// [`crate::SeedOverlay::from_json`]. Only the main card carries `$seed`.
339 pub fn seed(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
340 self.payload().seed()
341 }
342
343 /// Merge a card-kind's seed overlay `value` into the card's `$seed` map
344 /// under `card_kind`, creating the map when absent and replacing any
345 /// existing overlay for that kind. Sibling kinds are preserved — this is
346 /// the per-kind-safe writer, the seed analogue of
347 /// [`Card::set_ext_namespace`]. `card_kind` must be a valid, non-reserved
348 /// composable kind ([`EditError::InvalidKindName`] / [`EditError::ReservedKind`]
349 /// otherwise) — `$seed` is keyed by composable card-kind, unlike the
350 /// free-form namespaces of `$ext`. Returns [`EditError::ValueTooDeep`] when
351 /// the merged map nests past the §8 depth limit; the card is unchanged on
352 /// error.
353 pub fn set_seed_namespace(
354 &mut self,
355 card_kind: impl Into<String>,
356 value: serde_json::Value,
357 ) -> Result<(), EditError> {
358 let card_kind = card_kind.into();
359 validate_composable_kind(&card_kind).map_err(|e| match e {
360 CardKindError::InvalidName => EditError::InvalidKindName(card_kind.clone()),
361 CardKindError::Reserved => EditError::ReservedKind,
362 })?;
363 let mut map = self.payload_mut().seed().cloned().unwrap_or_default();
364 map.insert(card_kind, value);
365 check_meta_depth(&map)?;
366 self.payload_mut().take_seed();
367 self.payload_mut().set_seed(map);
368 Ok(())
369 }
370
371 /// Remove `card_kind` from the card's `$seed` map, returning the overlay
372 /// stored there (or `None`). When removing the last kind empties the map,
373 /// the `$seed` entry is dropped entirely (not left as `$seed: {}`).
374 /// The seed analogue of [`Card::remove_ext_namespace`].
375 pub fn remove_seed_namespace(&mut self, card_kind: &str) -> Option<serde_json::Value> {
376 let mut map = self.payload_mut().take_seed()?;
377 let removed = map.remove(card_kind);
378 if !map.is_empty() {
379 self.payload_mut().set_seed(map);
380 }
381 removed
382 }
383
384 pub fn replace_body(&mut self, body: impl Into<String>) {
385 self.overwrite_body(body.into());
386 }
387}