Skip to main content

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}