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 /// Replace the tag (sentinel) of the composable card at `index`.
177 ///
178 /// **Field-bag semantics.** This mutates only the sentinel; the card's
179 /// frontmatter and body are untouched. After the call:
180 ///
181 /// - Fields valid under both old and new schemas round-trip unchanged.
182 /// - Fields only in the old schema linger in the bag (silently ignored
183 /// by `project_form` and `validate_document`, but still emitted by
184 /// `to_markdown()`).
185 /// - Fields only in the new schema are absent — surfaced as `Default`
186 /// or `Missing` by `project_form`, and `MissingRequired` by
187 /// `validate_document`.
188 ///
189 /// Schema-aware migration (clearing orphans, applying defaults, etc.) is
190 /// the caller's responsibility — `set_card_tag` is a structural primitive.
191 ///
192 /// # Invariants enforced
193 ///
194 /// - `index` must be in `0..len`. Out of range returns
195 /// [`EditError::IndexOutOfRange`].
196 /// - `new_tag` must match `[a-z_][a-z0-9_]*`. Invalid tags return
197 /// [`EditError::InvalidTagName`].
198 ///
199 /// # Warnings
200 ///
201 /// This method never modifies `warnings`.
202 pub fn set_card_tag(
203 &mut self,
204 index: usize,
205 new_tag: impl Into<String>,
206 ) -> Result<(), EditError> {
207 let new_tag = new_tag.into();
208 if !is_valid_tag_name(&new_tag) {
209 return Err(EditError::InvalidTagName(new_tag));
210 }
211 let len = self.cards().len();
212 let card = self
213 .card_mut(index)
214 .ok_or(EditError::IndexOutOfRange { index, len })?;
215 card.replace_sentinel(Sentinel::Card(new_tag));
216 Ok(())
217 }
218
219 /// Move the composable card at `from` to position `to`.
220 ///
221 /// If `from == to`, this is a no-op and returns `Ok(())`.
222 ///
223 /// # Invariants enforced
224 ///
225 /// Both `from` and `to` must be in `0..len`. Either being out of range
226 /// returns [`EditError::IndexOutOfRange`] with the offending index.
227 ///
228 /// # Warnings
229 ///
230 /// This method never modifies `warnings`.
231 pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
232 let len = self.cards().len();
233 if from >= len {
234 return Err(EditError::IndexOutOfRange { index: from, len });
235 }
236 if to >= len {
237 return Err(EditError::IndexOutOfRange { index: to, len });
238 }
239 if from == to {
240 return Ok(());
241 }
242 let card = self.cards_vec_mut().remove(from);
243 self.cards_vec_mut().insert(to, card);
244 Ok(())
245 }
246}
247
248// ── impl Card ────────────────────────────────────────────────────────────────
249
250impl Card {
251 /// Create a new, empty composable card with the given tag.
252 ///
253 /// # Invariants enforced
254 ///
255 /// `tag` must match `[a-z_][a-z0-9_]*`. An invalid tag returns
256 /// [`EditError::InvalidTagName`].
257 ///
258 /// The new card has no fields and an empty body.
259 pub fn new(tag: impl Into<String>) -> Result<Self, EditError> {
260 let tag = tag.into();
261 if !is_valid_tag_name(&tag) {
262 return Err(EditError::InvalidTagName(tag));
263 }
264 Ok(Card::new_with_sentinel(
265 Sentinel::Card(tag),
266 Frontmatter::new(),
267 String::new(),
268 ))
269 }
270
271 /// Set a frontmatter field by name. Always clears the `!fill` marker for
272 /// that key — the "user filled in" path.
273 ///
274 /// # Invariants enforced
275 ///
276 /// - `name` must not be one of the reserved sentinel names.
277 /// Returns [`EditError::ReservedName`].
278 /// - `name` must match `[a-z_][a-z0-9_]*` after NFC normalisation.
279 /// Returns [`EditError::InvalidFieldName`].
280 ///
281 /// # Validity
282 ///
283 /// After a successful call the card remains valid: `frontmatter`
284 /// contains no reserved key and the value is stored at the correct key.
285 ///
286 /// # Warnings
287 ///
288 /// Card mutators never modify the parent document's `warnings`.
289 pub fn set_field(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
290 if is_reserved_name(name) {
291 return Err(EditError::ReservedName(name.to_string()));
292 }
293 if !is_valid_field_name(name) {
294 return Err(EditError::InvalidFieldName(name.to_string()));
295 }
296 self.frontmatter_mut().insert(name.to_string(), value);
297 Ok(())
298 }
299
300 /// Set a frontmatter field AND mark it as a `!fill` placeholder — the
301 /// "reset to placeholder" path. A `Null` value emits as `key: !fill`;
302 /// a scalar or sequence value emits as `key: !fill <value>`.
303 ///
304 /// # Invariants enforced
305 ///
306 /// Same as [`Card::set_field`].
307 ///
308 /// # Warnings
309 ///
310 /// Card mutators never modify the parent document's `warnings`.
311 pub fn set_fill(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
312 if is_reserved_name(name) {
313 return Err(EditError::ReservedName(name.to_string()));
314 }
315 if !is_valid_field_name(name) {
316 return Err(EditError::InvalidFieldName(name.to_string()));
317 }
318 self.frontmatter_mut().insert_fill(name.to_string(), value);
319 Ok(())
320 }
321
322 /// Remove a frontmatter field by name, returning the value if it existed.
323 ///
324 /// # Invariants enforced
325 ///
326 /// - `name` must not be one of the reserved sentinel names.
327 /// Returns [`EditError::ReservedName`].
328 /// - `name` must match `[a-z_][a-z0-9_]*` after NFC normalisation.
329 /// Returns [`EditError::InvalidFieldName`].
330 ///
331 /// Absence of an otherwise-valid name returns `Ok(None)`.
332 ///
333 /// # Warnings
334 ///
335 /// Card mutators never modify the parent document's `warnings`.
336 pub fn remove_field(&mut self, name: &str) -> Result<Option<QuillValue>, EditError> {
337 if is_reserved_name(name) {
338 return Err(EditError::ReservedName(name.to_string()));
339 }
340 if !is_valid_field_name(name) {
341 return Err(EditError::InvalidFieldName(name.to_string()));
342 }
343 Ok(self.frontmatter_mut().remove(name))
344 }
345
346 /// Replace the card's Markdown body.
347 ///
348 /// # Warnings
349 ///
350 /// Card mutators never modify the parent document's `warnings`.
351 pub fn replace_body(&mut self, body: impl Into<String>) {
352 self.overwrite_body(body.into());
353 }
354}