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
64/// A field-level invariant violation, shared by every payload ingestion path.
65///
66/// Each boundary maps it to its own error type (`ParseError`,
67/// `StorageError`, `WireError`, `EditError`), so the invariant — every user
68/// field name matches `[a-z_][a-z0-9_]*` and no value nests past the §8
69/// depth limit — is enforced once, here, and a constructed `Document` can
70/// never violate it.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum FieldViolation {
73 /// The field name does not match `[a-z_][a-z0-9_]*` (spec §3.4 / §10).
74 InvalidName,
75 /// The value nests deeper than [`MAX_YAML_DEPTH`](crate::document::limits::MAX_YAML_DEPTH)
76 /// (spec §8).
77 TooDeep,
78}
79
80/// Map a [`FieldViolation`] to the mutator error surface.
81fn check_field(name: &str, value: &serde_json::Value) -> Result<(), EditError> {
82 validate_field(name, value).map_err(|v| match v {
83 FieldViolation::InvalidName => EditError::InvalidFieldName(name.to_string()),
84 FieldViolation::TooDeep => EditError::ValueTooDeep {
85 max: crate::document::limits::MAX_YAML_DEPTH,
86 },
87 })
88}
89
90/// Depth-bound an `$ext` map (its name is fixed; only depth applies).
91fn check_ext_depth(map: &serde_json::Map<String, serde_json::Value>) -> Result<(), EditError> {
92 let as_value = serde_json::Value::Object(map.clone());
93 if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH) {
94 return Err(EditError::ValueTooDeep {
95 max: crate::document::limits::MAX_YAML_DEPTH,
96 });
97 }
98 Ok(())
99}
100
101/// Validate a user field at the payload boundary: name conformance and
102/// value-depth bound. See [`FieldViolation`] for the invariant.
103pub fn validate_field(key: &str, value: &serde_json::Value) -> Result<(), FieldViolation> {
104 if !is_valid_field_name(key) {
105 return Err(FieldViolation::InvalidName);
106 }
107 if crate::value::json_depth_exceeds(value, crate::document::limits::MAX_YAML_DEPTH) {
108 return Err(FieldViolation::TooDeep);
109 }
110 Ok(())
111}
112
113impl Document {
114 pub fn set_quill_ref(&mut self, reference: QuillReference) {
115 self.main_mut().payload_mut().set_quill(reference);
116 }
117
118 pub fn card_mut(&mut self, index: usize) -> Option<&mut Card> {
119 self.cards_mut().get_mut(index)
120 }
121
122 /// Append a composable card. Its `$kind` must be a valid, non-reserved
123 /// composable kind ([`EditError::InvalidKindName`] /
124 /// [`EditError::ReservedKind`] otherwise) — the invariant for any card in
125 /// the cards list, enforced here so every entry path shares it.
126 pub fn push_card(&mut self, card: Card) -> Result<(), EditError> {
127 Self::check_composable_kind(&card)?;
128 self.cards_vec_mut().push(card);
129 Ok(())
130 }
131
132 /// Insert a composable card at `index` (`index > len` →
133 /// [`EditError::IndexOutOfRange`]; invalid `$kind` →
134 /// [`EditError::InvalidKindName`] / [`EditError::ReservedKind`]).
135 pub fn insert_card(&mut self, index: usize, card: Card) -> Result<(), EditError> {
136 let len = self.cards().len();
137 if index > len {
138 return Err(EditError::IndexOutOfRange { index, len });
139 }
140 Self::check_composable_kind(&card)?;
141 self.cards_vec_mut().insert(index, card);
142 Ok(())
143 }
144
145 /// Validate that `card`'s `$kind` is a valid, non-reserved composable kind.
146 /// A card with no `$kind` is rejected as an invalid (empty) name.
147 fn check_composable_kind(card: &Card) -> Result<(), EditError> {
148 let kind = card.kind().unwrap_or("");
149 validate_composable_kind(kind).map_err(|e| match e {
150 CardKindError::InvalidName => EditError::InvalidKindName(kind.to_string()),
151 CardKindError::Reserved => EditError::ReservedKind,
152 })
153 }
154
155 pub fn remove_card(&mut self, index: usize) -> Option<Card> {
156 if index >= self.cards().len() {
157 return None;
158 }
159 Some(self.cards_vec_mut().remove(index))
160 }
161
162 /// Replace the `$kind` of the composable card at `index`.
163 ///
164 /// Only the `$kind` metadata changes; the payload and body are untouched
165 /// (field-bag semantics). Old-schema fields linger in the bag; new-schema
166 /// fields are absent until set explicitly. Schema migration is the caller's
167 /// responsibility — this is a structural primitive.
168 ///
169 /// Returns [`EditError::IndexOutOfRange`], [`EditError::InvalidKindName`],
170 /// or [`EditError::ReservedKind`] on constraint violations.
171 pub fn set_card_kind(
172 &mut self,
173 index: usize,
174 new_kind: impl Into<String>,
175 ) -> Result<(), EditError> {
176 let new_kind = new_kind.into();
177 validate_composable_kind(&new_kind).map_err(|e| match e {
178 CardKindError::InvalidName => EditError::InvalidKindName(new_kind.clone()),
179 CardKindError::Reserved => EditError::ReservedKind,
180 })?;
181 let len = self.cards().len();
182 let card = self
183 .card_mut(index)
184 .ok_or(EditError::IndexOutOfRange { index, len })?;
185 card.payload_mut().set_kind(new_kind);
186 Ok(())
187 }
188
189 /// Move card at `from` to position `to`. No-op when `from == to`.
190 /// Either index out of range → [`EditError::IndexOutOfRange`].
191 pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
192 let len = self.cards().len();
193 if from >= len {
194 return Err(EditError::IndexOutOfRange { index: from, len });
195 }
196 if to >= len {
197 return Err(EditError::IndexOutOfRange { index: to, len });
198 }
199 if from == to {
200 return Ok(());
201 }
202 let card = self.cards_vec_mut().remove(from);
203 self.cards_vec_mut().insert(to, card);
204 Ok(())
205 }
206}
207
208impl Card {
209 /// Create a composable card with the given kind, no fields, and an empty body.
210 pub fn new(kind: impl Into<String>) -> Result<Self, EditError> {
211 let kind = kind.into();
212 validate_composable_kind(&kind).map_err(|e| match e {
213 CardKindError::InvalidName => EditError::InvalidKindName(kind.clone()),
214 CardKindError::Reserved => EditError::ReservedKind,
215 })?;
216 let mut payload = Payload::new();
217 payload.set_kind(kind);
218 Ok(Card::from_parts(payload, String::new()))
219 }
220
221 /// Set a payload field, clearing any `!fill` marker on that key.
222 ///
223 /// Returns [`EditError::InvalidFieldName`] when `name` does not match
224 /// `[a-z_][a-z0-9_]*`.
225 pub fn set_field(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
226 check_field(name, value.as_json())?;
227 self.payload_mut().insert(name.to_string(), value);
228 Ok(())
229 }
230
231 /// Set a payload field and mark it as a `!fill` placeholder.
232 /// `Null` emits as `key: !fill`; scalars/sequences as `key: !fill <value>`.
233 /// Same validation as [`Card::set_field`].
234 pub fn set_fill(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
235 check_field(name, value.as_json())?;
236 self.payload_mut().insert_fill(name.to_string(), value);
237 Ok(())
238 }
239
240 /// Remove a payload field; returns `Ok(None)` if the name is absent.
241 /// Same validation as [`Card::set_field`].
242 pub fn remove_field(&mut self, name: &str) -> Result<Option<QuillValue>, EditError> {
243 if !is_valid_field_name(name) {
244 return Err(EditError::InvalidFieldName(name.to_string()));
245 }
246 Ok(self.payload_mut().remove(name))
247 }
248
249 /// Replace the card's opaque `$ext` map wholesale, inserting it at the
250 /// canonical position (after `$quill`/`$kind`/`$id`, before user fields)
251 /// when none existed. Passing an empty map records an explicit `$ext: {}`.
252 ///
253 /// `$ext` carries out-of-band consumer state (editor renames, agent
254 /// annotations, …) and is stripped from [`Document::to_plate_json`], so a
255 /// write here can never affect a render. Any nested comments attached to a
256 /// replaced `$ext` are dropped.
257 /// Returns [`EditError::ValueTooDeep`] when the map nests past the §8
258 /// depth limit — `$ext` never reaches the plate JSON, but it does flow
259 /// through the recursive emit and DTO paths, so it carries the same
260 /// depth bound as user fields.
261 pub fn set_ext(
262 &mut self,
263 value: serde_json::Map<String, serde_json::Value>,
264 ) -> Result<(), EditError> {
265 check_ext_depth(&value)?;
266 self.payload_mut().set_ext(value);
267 Ok(())
268 }
269
270 /// Remove the card's `$ext` map *entirely*, returning the previous map if
271 /// present. This is a blunt escape hatch — it discards every namespace
272 /// (`$ext.presentation`, `$ext.agent`, …) at once. To clear consumer
273 /// state, prefer [`Card::remove_ext_namespace`], which drops only your
274 /// own slot and leaves sibling consumers' state intact.
275 pub fn remove_ext(&mut self) -> Option<serde_json::Map<String, serde_json::Value>> {
276 self.payload_mut().take_ext()
277 }
278
279 /// Merge `value` into the card's `$ext` map under `namespace`, creating
280 /// the map when absent and replacing any existing value at that key.
281 ///
282 /// This is the recommended way to write `$ext`: it preserves sibling
283 /// namespaces, so independent consumers keying on their own slot
284 /// (`$ext.presentation`, `$ext.agent`, …) don't clobber each other.
285 /// Returns [`EditError::ValueTooDeep`] when the merged map nests past
286 /// the §8 depth limit (see [`Card::set_ext`]); the card's `$ext` is
287 /// unchanged on error.
288 pub fn set_ext_namespace(
289 &mut self,
290 namespace: impl Into<String>,
291 value: serde_json::Value,
292 ) -> Result<(), EditError> {
293 let mut map = self
294 .payload_mut()
295 .ext()
296 .cloned()
297 .unwrap_or_default();
298 map.insert(namespace.into(), value);
299 check_ext_depth(&map)?;
300 self.payload_mut().take_ext();
301 self.payload_mut().set_ext(map);
302 Ok(())
303 }
304
305 /// Remove `namespace` from the card's `$ext` map, returning the value
306 /// that was stored there (or `None` when the map or the key was absent).
307 ///
308 /// This is the recommended way to clear `$ext` state: it is the
309 /// namespace-scoped inverse of [`Card::set_ext_namespace`] and preserves
310 /// sibling namespaces, where [`Card::remove_ext`] would wipe them all.
311 /// When removing the last namespace empties the map, the `$ext` entry is
312 /// dropped entirely (not left as `$ext: {}`), so
313 /// `set_ext_namespace(ns, v)` followed by `remove_ext_namespace(ns)`
314 /// restores a card that had no `$ext` to its original state.
315 pub fn remove_ext_namespace(&mut self, namespace: &str) -> Option<serde_json::Value> {
316 let mut map = self.payload_mut().take_ext()?;
317 let removed = map.remove(namespace);
318 if !map.is_empty() {
319 self.payload_mut().set_ext(map);
320 }
321 removed
322 }
323
324 pub fn replace_body(&mut self, body: impl Into<String>) {
325 self.overwrite_body(body.into());
326 }
327}