Skip to main content

quillmark_core/document/
edit.rs

1//! # Document Editor Surface
2//!
3//! Typed mutators for [`Document`] and [`Card`] with invariant enforcement.
4//!
5//! ## Invariants
6//!
7//! Every successful mutator call leaves the document in a state that:
8//! - Contains no reserved key in any card's frontmatter (`BODY`, `CARDS`, `QUILL`, `CARD`).
9//! - Has every composable `card.tag()` passing `sentinel::is_valid_tag_name`.
10//! - Can be safely serialized via [`Document::to_plate_json`].
11//!
12//! **Mutators never modify `warnings`.**  Warnings are parse-time observations
13//! and remain stable for the lifetime of the document.
14//!
15//! ## Surface
16//!
17//! After the document-rework, frontmatter and body mutators live on [`Card`]:
18//! `doc.main_mut().set_field(…)`, `doc.main_mut().replace_body(…)`,
19//! `doc.cards_mut()[i].set_field(…)`. [`Document`] keeps only document-level
20//! operations (quill-ref, push/insert/remove/move card).
21
22use unicode_normalization::UnicodeNormalization;
23
24use crate::document::sentinel::is_valid_tag_name;
25use crate::document::{Card, Document, Frontmatter, Sentinel};
26use crate::value::QuillValue;
27use crate::version::QuillReference;
28
29// ── Reserved names ──────────────────────────────────────────────────────────
30
31/// Reserved field names that may not appear in any `Card`'s frontmatter.
32/// These are the sentinel keys whose presence in user-visible fields would
33/// corrupt the plate wire format or the parser's structural invariants.
34pub const RESERVED_NAMES: &[&str] = &["BODY", "CARDS", "QUILL", "CARD"];
35
36/// Returns `true` if `name` is one of the four reserved sentinel names.
37#[inline]
38pub fn is_reserved_name(name: &str) -> bool {
39    RESERVED_NAMES.contains(&name)
40}
41
42// ── Field name validation ───────────────────────────────────────────────────
43
44/// Returns `true` if `name` is a valid frontmatter / card field name.
45///
46/// A valid field name matches `[a-z_][a-z0-9_]*` after NFC normalisation.
47/// Upper-case identifiers are intentionally excluded; they are reserved for
48/// sentinel keys (`QUILL`, `CARD`, `BODY`, `CARDS`).
49pub fn is_valid_field_name(name: &str) -> bool {
50    // NFC-normalize first so that, e.g., composed vs decomposed forms compare equal.
51    let normalized: String = name.nfc().collect();
52    if normalized.is_empty() {
53        return false;
54    }
55    let mut chars = normalized.chars();
56    let first = chars.next().unwrap();
57    if !first.is_ascii_lowercase() && first != '_' {
58        return false;
59    }
60    for ch in chars {
61        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
62            return false;
63        }
64    }
65    true
66}
67
68// ── EditError ────────────────────────────────────────────────────────────────
69
70/// Errors returned by document and card mutators.
71///
72/// `EditError` is distinct from [`crate::error::ParseError`]: it carries no
73/// source-location information because edits happen after parsing.
74#[derive(Debug, Clone, PartialEq, thiserror::Error)]
75pub enum EditError {
76    /// The supplied name is one of the four reserved sentinel keys
77    /// (`BODY`, `CARDS`, `QUILL`, `CARD`).
78    #[error("reserved name '{0}' cannot be used as a field name")]
79    ReservedName(String),
80
81    /// The supplied name does not match `[a-z_][a-z0-9_]*`.
82    #[error("invalid field name '{0}': must match [a-z_][a-z0-9_]*")]
83    InvalidFieldName(String),
84
85    /// The supplied tag does not match `[a-z_][a-z0-9_]*`.
86    #[error("invalid tag name '{0}': must match [a-z_][a-z0-9_]*")]
87    InvalidTagName(String),
88
89    /// A card index was out of the valid range.
90    #[error("index {index} is out of range (len = {len})")]
91    IndexOutOfRange { index: usize, len: usize },
92}
93
94// ── impl Document ────────────────────────────────────────────────────────────
95
96impl Document {
97    /// Replace the QUILL reference on the main card's sentinel.
98    ///
99    /// # Invariants enforced
100    ///
101    /// The `QuillReference` type guarantees structural validity; no further
102    /// checks are needed here.
103    ///
104    /// # Warnings
105    ///
106    /// This method never modifies `warnings`.
107    pub fn set_quill_ref(&mut self, reference: QuillReference) {
108        self.main_mut().replace_sentinel(Sentinel::Main(reference));
109    }
110
111    // ── Card mutators ────────────────────────────────────────────────────────
112
113    /// Return a mutable reference to the composable card at `index`, or `None`
114    /// if out of range.
115    ///
116    /// # Warnings
117    ///
118    /// This method never modifies `warnings`.
119    pub fn card_mut(&mut self, index: usize) -> Option<&mut Card> {
120        self.cards_mut().get_mut(index)
121    }
122
123    /// Append a composable card to the end of the card list.
124    ///
125    /// # Invariants
126    ///
127    /// `card.sentinel()` must be [`Sentinel::Card`]; a main card cannot be
128    /// appended as a composable card. Debug assert.
129    ///
130    /// # Warnings
131    ///
132    /// This method never modifies `warnings`.
133    pub fn push_card(&mut self, card: Card) {
134        debug_assert!(
135            !card.sentinel().is_main(),
136            "cannot push a Main-sentinel card as a composable card"
137        );
138        self.cards_vec_mut().push(card);
139    }
140
141    /// Insert a composable card at `index`.
142    ///
143    /// # Invariants enforced
144    ///
145    /// `index` must be in `0..=len`.  An `index > len` returns
146    /// [`EditError::IndexOutOfRange`].
147    ///
148    /// # Warnings
149    ///
150    /// This method never modifies `warnings`.
151    pub fn insert_card(&mut self, index: usize, card: Card) -> Result<(), EditError> {
152        debug_assert!(
153            !card.sentinel().is_main(),
154            "cannot insert a Main-sentinel card as a composable card"
155        );
156        let len = self.cards().len();
157        if index > len {
158            return Err(EditError::IndexOutOfRange { index, len });
159        }
160        self.cards_vec_mut().insert(index, card);
161        Ok(())
162    }
163
164    /// Remove and return the composable card at `index`, or `None` if out of range.
165    ///
166    /// # Warnings
167    ///
168    /// This method never modifies `warnings`.
169    pub fn remove_card(&mut self, index: usize) -> Option<Card> {
170        if index >= self.cards().len() {
171            return None;
172        }
173        Some(self.cards_vec_mut().remove(index))
174    }
175
176    /// Move the composable card at `from` to position `to`.
177    ///
178    /// If `from == to`, this is a no-op and returns `Ok(())`.
179    ///
180    /// # Invariants enforced
181    ///
182    /// Both `from` and `to` must be in `0..len`.  Either being out of range
183    /// returns [`EditError::IndexOutOfRange`] with the offending index.
184    ///
185    /// # Warnings
186    ///
187    /// This method never modifies `warnings`.
188    pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
189        let len = self.cards().len();
190        if from >= len {
191            return Err(EditError::IndexOutOfRange { index: from, len });
192        }
193        if to >= len {
194            return Err(EditError::IndexOutOfRange { index: to, len });
195        }
196        if from == to {
197            return Ok(());
198        }
199        let card = self.cards_vec_mut().remove(from);
200        self.cards_vec_mut().insert(to, card);
201        Ok(())
202    }
203}
204
205// ── impl Card ────────────────────────────────────────────────────────────────
206
207impl Card {
208    /// Create a new, empty composable card with the given tag.
209    ///
210    /// # Invariants enforced
211    ///
212    /// `tag` must match `[a-z_][a-z0-9_]*`.  An invalid tag returns
213    /// [`EditError::InvalidTagName`].
214    ///
215    /// The new card has no fields and an empty body.
216    pub fn new(tag: impl Into<String>) -> Result<Self, EditError> {
217        let tag = tag.into();
218        if !is_valid_tag_name(&tag) {
219            return Err(EditError::InvalidTagName(tag));
220        }
221        Ok(Card::new_with_sentinel(
222            Sentinel::Card(tag),
223            Frontmatter::new(),
224            String::new(),
225        ))
226    }
227
228    /// Set a frontmatter field by name. Always clears the `!fill` marker for
229    /// that key — the "user filled in" path.
230    ///
231    /// # Invariants enforced
232    ///
233    /// - `name` must not be one of the reserved sentinel names.
234    ///   Returns [`EditError::ReservedName`].
235    /// - `name` must match `[a-z_][a-z0-9_]*` after NFC normalisation.
236    ///   Returns [`EditError::InvalidFieldName`].
237    ///
238    /// # Validity
239    ///
240    /// After a successful call the card remains valid: `frontmatter`
241    /// contains no reserved key and the value is stored at the correct key.
242    ///
243    /// # Warnings
244    ///
245    /// Card mutators never modify the parent document's `warnings`.
246    pub fn set_field(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
247        if is_reserved_name(name) {
248            return Err(EditError::ReservedName(name.to_string()));
249        }
250        if !is_valid_field_name(name) {
251            return Err(EditError::InvalidFieldName(name.to_string()));
252        }
253        self.frontmatter_mut().insert(name.to_string(), value);
254        Ok(())
255    }
256
257    /// Set a frontmatter field AND mark it as a `!fill` placeholder — the
258    /// "reset to placeholder" path. A `Null` value emits as `key: !fill`;
259    /// a scalar or sequence value emits as `key: !fill <value>`.
260    ///
261    /// # Invariants enforced
262    ///
263    /// Same as [`Card::set_field`].
264    ///
265    /// # Warnings
266    ///
267    /// Card mutators never modify the parent document's `warnings`.
268    pub fn set_fill(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
269        if is_reserved_name(name) {
270            return Err(EditError::ReservedName(name.to_string()));
271        }
272        if !is_valid_field_name(name) {
273            return Err(EditError::InvalidFieldName(name.to_string()));
274        }
275        self.frontmatter_mut().insert_fill(name.to_string(), value);
276        Ok(())
277    }
278
279    /// Remove a frontmatter field by name, returning the value if it existed.
280    ///
281    /// Reserved names cannot be present in the frontmatter (the parser
282    /// guarantees this), so passing a reserved name simply returns `None`.
283    ///
284    /// # Warnings
285    ///
286    /// Card mutators never modify the parent document's `warnings`.
287    pub fn remove_field(&mut self, name: &str) -> Option<QuillValue> {
288        self.frontmatter_mut().remove(name)
289    }
290
291    /// Replace the card's Markdown body.
292    ///
293    /// # Warnings
294    ///
295    /// Card mutators never modify the parent document's `warnings`.
296    pub fn replace_body(&mut self, body: impl Into<String>) {
297        self.overwrite_body(body.into());
298    }
299}