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