Skip to main content

quillmark_core/
writer.rs

1//! Schema-bound typed writer: the front door for typed field writes.
2//!
3//! [`Card::commit_field`](crate::Card::commit_field) asks the caller to fetch a
4//! [`FieldSchema`] per write. Every consumer that wants typed writes (a
5//! form editor, an MCP server) already holds the resolved [`QuillConfig`]: it
6//! renders with it. [`Quill::writer`](crate::Quill::writer) binds the schema
7//! once, so callers issue one verb (`set`) and never pass a type token or an
8//! `inline` flag: the writer resolves each field's type itself, strict-commits
9//! a schema field, and rejects a name the schema does not declare with
10//! [`EditError::UnknownField`], on the typed path an undeclared name is a typo,
11//! not a fallback. Opaque storage stays available on purpose through the raw
12//! [`Card::store_field`](crate::Card::store_field) verb.
13//!
14//! ```ignore
15//! let mut w = quill.writer(&mut doc);
16//! w.set("subject", "Q3 results")?;       // richtext(inline) → strict content commit
17//! w.set("qty", "3")?;                    // integer → strict coerce, stores 3
18//! w.card(2)?.set("desc", content_json)?;  // card kind → CardSchema → field type
19//! w.set_all([("a", "1"), ("b", "2")])?;  // batched, all-or-nothing
20//! ```
21//!
22//! The writer holds `&mut Document` and `&QuillConfig`, so a bound `TypedWriter`
23//! cannot cross a binding boundary that carries no lifetimes (wasm-bindgen /
24//! pyo3 objects); those surfaces construct one per call from the quill handle.
25//!
26//! [`Quill::conform`](crate::Quill::conform) is this same strict commit driven
27//! by the schema rather than by a caller: it walks a document's declared content
28//! fields through the write-resolution seam these verbs use, so what an
29//! ingestion lands and what a write lands are the same bytes. Where a write
30//! refuses, conform leaves the value authored under a `conform::*` warning.
31
32use indexmap::IndexMap;
33
34use crate::document::edit::resolve_field_write;
35use crate::document::{Card, Document, EditError};
36use crate::quill::{CardSchema, FieldSchema, QuillConfig};
37use crate::value::QuillValue;
38use crate::Delta;
39
40/// A [`Document`] bound to its [`QuillConfig`] for typed writes. Construct with
41/// [`Quill::writer`](crate::Quill::writer). Writes target the main card; use
42/// [`card`](Self::card) for a composable card.
43pub struct TypedWriter<'a> {
44    config: &'a QuillConfig,
45    doc: &'a mut Document,
46}
47
48impl<'a> TypedWriter<'a> {
49    /// Bind `doc` to `config`. Prefer [`Quill::writer`](crate::Quill::writer).
50    pub fn new(config: &'a QuillConfig, doc: &'a mut Document) -> Self {
51        Self { config, doc }
52    }
53
54    /// Write a field on the main card. Resolves the field's schema type and
55    /// strict-commits it; a name the schema does not declare fails with
56    /// [`EditError::UnknownField`] rather than falling to the opaque store: on
57    /// the typed path it is a typo. For deliberate opaque storage use the raw
58    /// [`Card::store_field`](crate::Card::store_field). Other errors are those of
59    /// [`Card::commit_field`](crate::Card::commit_field).
60    pub fn set(&mut self, name: &str, value: impl Into<QuillValue>) -> Result<(), EditError> {
61        let config = self.config;
62        match config.main.fields.get(name) {
63            Some(schema) => self.doc.main_mut().commit_field(name, value, schema),
64            None => Err(EditError::UnknownField(name.to_string())),
65        }
66    }
67
68    /// Write several main-card fields atomically, the typed twin of
69    /// [`Card::store_fields`](crate::Card::store_fields). Every field is resolved
70    /// (strict conform, or [`EditError::UnknownField`] for a name the schema does
71    /// not declare) before any is applied; on any violation nothing is written
72    /// and every offending field is returned as a `(name, error)` pair, so a
73    /// caller submitting a whole form sees every typo in one pass the way
74    /// [`Card::store_fields`](crate::Card::store_fields) does.
75    pub fn set_all<K, V, I>(&mut self, fields: I) -> Result<(), Vec<(String, EditError)>>
76    where
77        K: Into<String>,
78        V: Into<QuillValue>,
79        I: IntoIterator<Item = (K, V)>,
80    {
81        let schema = Some(&self.config.main.fields);
82        set_all_impl(self.doc.main_mut(), schema, fields)
83    }
84
85    /// Revise the main card's body from markdown (edit semantics: surviving
86    /// anchors rebase), discarding the text delta, the receipt-free body write.
87    /// Call [`Card::revise_body`](crate::Card::revise_body) on `doc.main_mut()`
88    /// for the [`Delta`] receipt.
89    pub fn set_body(&mut self, markdown: &str) -> Result<(), EditError> {
90        self.doc.main_mut().revise_body(markdown).map(|_| ())
91    }
92
93    /// Revise a content field on the main card from authored text: typed *and*
94    /// anchor-preserving. Resolves the field's schema and defers to
95    /// [`Card::revise_field_checked`](crate::Card::revise_field_checked), so
96    /// surviving anchors rebase and the diffed result is schema-conformed
97    /// (`richtext(inline)` rejects a multi-block result). Returns the text
98    /// [`Delta`]. A name the schema does not declare fails with
99    /// [`EditError::UnknownField`], as [`set`](Self::set).
100    ///
101    /// The codec comes from the declared type: `richtext` diffs markdown and
102    /// rebases anchors; `plaintext` diffs the literal text (nothing to rebase,
103    /// `is_plain` forbids every mark) and never imports markdown, so a
104    /// byte-identical revise of a value carrying escapes is a byte no-op.
105    pub fn revise_field(&mut self, name: &str, text: &str) -> Result<Delta, EditError> {
106        match self.config.main.fields.get(name) {
107            Some(schema) => self.doc.main_mut().revise_field_checked(name, text, schema),
108            None => Err(EditError::UnknownField(name.to_string())),
109        }
110    }
111
112    /// Build a composable card of `kind`, typed-commit `fields` onto it,
113    /// optionally set its body from markdown, and place it, the fused
114    /// [`Card::new`](crate::Card::new) + typed writes + insertion. `at` picks the
115    /// position: `None` appends ([`push_card`]), `Some(i)` inserts at index `i`
116    /// ([`insert_card`]), so a positioned typed insert is one atomic call rather
117    /// than `add_card` + [`move_card`](Document::move_card). The card is committed
118    /// in full *before* it joins the document, so it is transactional by
119    /// construction: a rejected field (or an invalid kind, body, or out-of-range
120    /// `at`) leaves the document untouched. Field errors use the all-or-nothing
121    /// bundle of [`set_all`](Self::set_all); an invalid kind or body, or an
122    /// out-of-range position, surfaces as a single-entry bundle keyed `$kind` /
123    /// `$body`.
124    ///
125    /// [`push_card`]: Document::push_card
126    /// [`insert_card`]: Document::insert_card
127    pub fn add_card<K, V, I>(
128        &mut self,
129        kind: &str,
130        fields: I,
131        body: Option<&str>,
132        at: Option<usize>,
133    ) -> Result<(), Vec<(String, EditError)>>
134    where
135        K: Into<String>,
136        V: Into<QuillValue>,
137        I: IntoIterator<Item = (K, V)>,
138    {
139        let mut card = Card::new(kind).map_err(|e| vec![("$kind".to_string(), e)])?;
140        let schema = self.config.card_kind(kind).map(|s| &s.fields);
141        set_all_impl(&mut card, schema, fields)?;
142        if let Some(md) = body {
143            card.revise_body(md)
144                .map_err(|e| vec![("$body".to_string(), e)])?;
145        }
146        match at {
147            Some(index) => self.doc.insert_card(index, card),
148            None => self.doc.push_card(card),
149        }
150        .map_err(|e| vec![("$kind".to_string(), e)])?;
151        Ok(())
152    }
153
154    /// Remove the composable card at `index`, returning it: the writer
155    /// spelling of [`Document::remove_card`], mirroring the JS `writer.removeCard`
156    /// sugar. `None` when `index` is out of range.
157    pub fn remove_card(&mut self, index: usize) -> Option<Card> {
158        self.doc.remove_card(index)
159    }
160
161    /// A schema-bound writer for the composable card at `index`. The card's
162    /// `$kind` resolves its [`CardSchema`]; an unknown kind carries no schema, so
163    /// every field on it is undeclared and its typed writes fail with
164    /// [`EditError::UnknownField`] (write such a card opaquely through
165    /// [`Card::store_field`](crate::Card::store_field)). Returns
166    /// [`EditError::IndexOutOfRange`] when `index` is out of range.
167    pub fn card(&mut self, index: usize) -> Result<CardWriter<'_>, EditError> {
168        let config = self.config;
169        let len = self.doc.cards().len();
170        let card = self
171            .doc
172            .card_mut(index)
173            .ok_or(EditError::IndexOutOfRange { index, len })?;
174        let schema = card.kind().and_then(|k| config.card_kind(k));
175        Ok(CardWriter { schema, card })
176    }
177}
178
179/// A single composable card bound to its [`CardSchema`], from
180/// [`TypedWriter::card`]. Same `set` / `set_all` verbs as [`TypedWriter`].
181pub struct CardWriter<'a> {
182    schema: Option<&'a CardSchema>,
183    card: &'a mut Card,
184}
185
186impl CardWriter<'_> {
187    /// The card's `$kind`, if any.
188    pub fn kind(&self) -> Option<&str> {
189        self.card.kind()
190    }
191
192    /// Write a field on this card. Resolves the field against the card's
193    /// [`CardSchema`] and strict-commits it; a field the schema does not declare
194    /// (or any field when the card kind is unknown) fails with
195    /// [`EditError::UnknownField`] rather than storing opaquely.
196    pub fn set(&mut self, name: &str, value: impl Into<QuillValue>) -> Result<(), EditError> {
197        match self.schema.and_then(|s| s.fields.get(name)) {
198            Some(schema) => self.card.commit_field(name, value, schema),
199            None => Err(EditError::UnknownField(name.to_string())),
200        }
201    }
202
203    /// Revise this card's body from markdown (edit semantics), discarding the
204    /// delta: the card twin of [`TypedWriter::set_body`].
205    pub fn set_body(&mut self, markdown: &str) -> Result<(), EditError> {
206        self.card.revise_body(markdown).map(|_| ())
207    }
208
209    /// Revise a content field on this card from authored text: typed *and*
210    /// anchor-preserving; the card twin of [`TypedWriter::revise_field`], codec
211    /// included (`richtext` diffs markdown, `plaintext` the literal text).
212    /// Resolves the field against the card's [`CardSchema`]; an undeclared name
213    /// (or any field when the card kind is unknown) fails with
214    /// [`EditError::UnknownField`].
215    pub fn revise_field(&mut self, name: &str, text: &str) -> Result<Delta, EditError> {
216        match self.schema.and_then(|s| s.fields.get(name)) {
217            Some(schema) => self.card.revise_field_checked(name, text, schema),
218            None => Err(EditError::UnknownField(name.to_string())),
219        }
220    }
221
222    /// Write several fields on this card atomically; see
223    /// [`TypedWriter::set_all`]; an undeclared name aborts the whole batch with
224    /// [`EditError::UnknownField`].
225    pub fn set_all<K, V, I>(&mut self, fields: I) -> Result<(), Vec<(String, EditError)>>
226    where
227        K: Into<String>,
228        V: Into<QuillValue>,
229        I: IntoIterator<Item = (K, V)>,
230    {
231        set_all_impl(self.card, self.schema.map(|s| &s.fields), fields)
232    }
233}
234
235/// All-or-nothing batched write shared by [`TypedWriter::set_all`] and
236/// [`CardWriter::set_all`]: resolve every field first (collecting every error),
237/// apply none on failure, apply all on success. A name absent from
238/// `fields_schema` (or every name, when the whole schema is `None`: an unknown
239/// card kind) is an [`EditError::UnknownField`], the batch form of the scalar
240/// `set`'s reject-the-typo decision.
241fn set_all_impl<K, V, I>(
242    card: &mut Card,
243    fields_schema: Option<&IndexMap<String, FieldSchema>>,
244    fields: I,
245) -> Result<(), Vec<(String, EditError)>>
246where
247    K: Into<String>,
248    V: Into<QuillValue>,
249    I: IntoIterator<Item = (K, V)>,
250{
251    let fields: Vec<(String, QuillValue)> = fields
252        .into_iter()
253        .map(|(k, v)| (k.into(), v.into()))
254        .collect();
255
256    let mut resolved: Vec<(String, QuillValue)> = Vec::with_capacity(fields.len());
257    let mut errors: Vec<(String, EditError)> = Vec::new();
258    for (name, value) in fields {
259        match fields_schema.and_then(|m| m.get(&name)) {
260            Some(schema) => match resolve_field_write(&name, value, schema) {
261                Ok(stored) => resolved.push((name, stored)),
262                Err(e) => errors.push((name, e)),
263            },
264            None => errors.push((name.clone(), EditError::UnknownField(name))),
265        }
266    }
267    if !errors.is_empty() {
268        return Err(errors);
269    }
270    // Every entry validated by `resolve_field_write` above; apply unchecked.
271    for (name, stored) in resolved {
272        card.payload_mut().insert_unchecked(name, stored);
273    }
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::document::{Card, Document};
281    use crate::version::QuillReference;
282    use std::str::FromStr;
283
284    const QUILL_YAML: &str = "\
285quill:
286  name: memo
287  backend: typst
288  version: 1.0.0
289  description: Editor test quill
290main:
291  fields:
292    subject:
293      type: richtext
294      inline: true
295    qty:
296      type: integer
297card_kinds:
298  note:
299    fields:
300      body:
301        type: richtext
302";
303
304    fn config() -> QuillConfig {
305        QuillConfig::from_yaml(QUILL_YAML).expect("valid quill")
306    }
307
308    fn blank_doc() -> Document {
309        Document::new(QuillReference::from_str("memo@1.0.0").unwrap())
310    }
311
312    #[test]
313    fn set_resolves_schema_field_as_typed_commit() {
314        let config = config();
315        let mut doc = blank_doc();
316        let mut ed = TypedWriter::new(&config, &mut doc);
317
318        // A schema field commits typed: "3" → 3, richtext string → content.
319        ed.set("qty", "3").unwrap();
320        ed.set("subject", "Hello").unwrap();
321        assert_eq!(
322            doc.main().payload().get("qty").unwrap().as_json(),
323            &serde_json::json!(3)
324        );
325        assert_eq!(doc.main().field_markdown("subject").unwrap().unwrap(), "Hello");
326    }
327
328    #[test]
329    fn set_rejects_unknown_field() {
330        let config = config();
331        let mut doc = blank_doc();
332        let mut ed = TypedWriter::new(&config, &mut doc);
333        // Unknown field on the typed path is a typo, not a fallback: it fails
334        // here and nothing is written. Opaque storage is the raw `store_field`.
335        let err = ed.set("notafield", "x").unwrap_err();
336        assert_eq!(err.variant_name(), "UnknownField");
337        assert!(doc.main().payload().get("notafield").is_none());
338    }
339
340    #[test]
341    fn set_all_is_all_or_nothing() {
342        let config = config();
343        let mut doc = blank_doc();
344        let mut ed = TypedWriter::new(&config, &mut doc);
345        // One bad field aborts the whole batch; nothing is applied.
346        let errs = ed
347            .set_all([("qty", "5"), ("subject", "bad\n\nblock")])
348            .unwrap_err();
349        assert_eq!(errs.len(), 1);
350        assert_eq!(errs[0].0, "subject");
351        assert!(doc.main().payload().get("qty").is_none());
352
353        // A clean batch applies every field.
354        let mut ed = TypedWriter::new(&config, &mut doc);
355        ed.set_all([("qty", "5"), ("subject", "ok")]).unwrap();
356        assert_eq!(
357            doc.main().payload().get("qty").unwrap().as_json(),
358            &serde_json::json!(5)
359        );
360    }
361
362    #[test]
363    fn set_all_rejects_unknown_field() {
364        let config = config();
365        let mut doc = blank_doc();
366        let mut ed = TypedWriter::new(&config, &mut doc);
367        // A whole-form submit with a typo'd name: `qty` is a schema field, `titel`
368        // is not. The undeclared name aborts the all-or-nothing batch: nothing is
369        // written and the typo is reported.
370        let errs = ed.set_all([("qty", "3"), ("titel", "oops")]).unwrap_err();
371        assert_eq!(errs.len(), 1);
372        assert_eq!(errs[0].0, "titel");
373        assert_eq!(errs[0].1.variant_name(), "UnknownField");
374        assert!(doc.main().payload().get("qty").is_none());
375    }
376
377    #[test]
378    fn set_body_revises_main_body() {
379        let config = config();
380        let mut doc = blank_doc();
381        let mut ed = TypedWriter::new(&config, &mut doc);
382        ed.set_body("**hi**").unwrap();
383        assert_eq!(doc.main().body_markdown(), "**hi**");
384    }
385
386    #[test]
387    fn add_card_fuses_new_commit_push() {
388        let config = config();
389        let mut doc = blank_doc();
390        let mut ed = TypedWriter::new(&config, &mut doc);
391        ed.add_card("note", [("body", "**hi**")], Some("card body"), None)
392            .unwrap();
393        assert_eq!(doc.cards().len(), 1);
394        assert_eq!(doc.cards()[0].kind(), Some("note"));
395        assert_eq!(doc.cards()[0].field_markdown("body").unwrap().unwrap(), "**hi**");
396        assert_eq!(doc.cards()[0].body_markdown(), "card body");
397    }
398
399    #[test]
400    fn add_card_at_inserts_and_remove_card_returns() {
401        let config = config();
402        let mut doc = blank_doc();
403        {
404            let mut ed = TypedWriter::new(&config, &mut doc);
405            ed.add_card("note", [("body", "a")], None, None).unwrap();
406            ed.add_card("note", [("body", "c")], None, None).unwrap();
407            // Positioned typed insert in one atomic call.
408            ed.add_card("note", [("body", "b")], None, Some(1)).unwrap();
409        }
410        let bodies: Vec<String> = doc
411            .cards()
412            .iter()
413            .map(|c| c.field_markdown("body").unwrap().unwrap())
414            .collect();
415        assert_eq!(bodies, ["a", "b", "c"]);
416
417        // An out-of-range position is transactional: nothing is inserted.
418        {
419            let mut ed = TypedWriter::new(&config, &mut doc);
420            let errs = ed
421                .add_card("note", [("body", "x")], None, Some(9))
422                .unwrap_err();
423            assert_eq!(errs[0].0, "$kind");
424        }
425        assert_eq!(doc.cards().len(), 3);
426
427        // remove_card returns the removed card; None out of range.
428        {
429            let mut ed = TypedWriter::new(&config, &mut doc);
430            let removed = ed.remove_card(1).unwrap();
431            assert_eq!(removed.field_markdown("body").unwrap().unwrap(), "b");
432            assert!(ed.remove_card(5).is_none());
433        }
434        assert_eq!(doc.cards().len(), 2);
435    }
436
437    #[test]
438    fn add_card_is_transactional_on_bad_field() {
439        let config = config();
440        let mut doc = blank_doc();
441        let mut ed = TypedWriter::new(&config, &mut doc);
442        // An undeclared field aborts the commit; the card never joins the document.
443        let errs = ed
444            .add_card("note", [("stray", "x")], None, None)
445            .unwrap_err();
446        assert_eq!(errs[0].0, "stray");
447        assert_eq!(errs[0].1.variant_name(), "UnknownField");
448        assert_eq!(doc.cards().len(), 0);
449    }
450
451    #[test]
452    fn add_card_reports_invalid_kind() {
453        let config = config();
454        let mut doc = blank_doc();
455        let mut ed = TypedWriter::new(&config, &mut doc);
456        // A reserved kind is refused before any card is built.
457        let errs = ed
458            .add_card("$reserved", [] as [(&str, &str); 0], None, None)
459            .unwrap_err();
460        assert_eq!(errs[0].0, "$kind");
461        assert_eq!(doc.cards().len(), 0);
462    }
463
464    #[test]
465    fn card_writer_resolves_card_kind_schema() {
466        let config = config();
467        let mut doc = blank_doc();
468        doc.push_card(Card::new("note").unwrap()).unwrap();
469
470        let mut ed = TypedWriter::new(&config, &mut doc);
471        let mut card_ed = ed.card(0).unwrap();
472        card_ed.set("body", "**hi**").unwrap();
473        // Unknown field on a known card → rejected as a typo.
474        let err = card_ed.set("stray", "v").unwrap_err();
475        assert_eq!(err.variant_name(), "UnknownField");
476
477        assert_eq!(doc.cards()[0].field_markdown("body").unwrap().unwrap(), "**hi**");
478
479        // Out-of-range card index errors.
480        let mut ed = TypedWriter::new(&config, &mut doc);
481        assert!(matches!(
482            ed.card(9),
483            Err(EditError::IndexOutOfRange { .. })
484        ));
485    }
486
487    #[test]
488    fn revise_field_is_typed_and_rejects_unknown_and_non_inline() {
489        let config = config();
490        let mut doc = blank_doc();
491        let mut ed = TypedWriter::new(&config, &mut doc);
492        // Typed richtext write lands the content and returns a Delta receipt.
493        let _delta = ed.revise_field("subject", "Hello").unwrap();
494        assert_eq!(doc.main().field_markdown("subject").unwrap().unwrap(), "Hello");
495
496        // Unknown name is a typo, not a fallback.
497        let mut ed = TypedWriter::new(&config, &mut doc);
498        assert_eq!(
499            ed.revise_field("nope", "x").unwrap_err().variant_name(),
500            "UnknownField"
501        );
502        // richtext(inline) rejects a multi-block result; the field is unchanged.
503        let err = ed.revise_field("subject", "a\n\nb").unwrap_err();
504        assert_eq!(err.variant_name(), "FieldRichtextNotInline");
505        assert_eq!(doc.main().field_markdown("subject").unwrap().unwrap(), "Hello");
506    }
507
508    #[test]
509    fn card_writer_revise_field_resolves_card_schema() {
510        let config = config();
511        let mut doc = blank_doc();
512        doc.push_card(Card::new("note").unwrap()).unwrap();
513
514        let mut ed = TypedWriter::new(&config, &mut doc);
515        ed.card(0).unwrap().revise_field("body", "**hi**").unwrap();
516        assert_eq!(doc.cards()[0].field_markdown("body").unwrap().unwrap(), "**hi**");
517
518        let mut ed = TypedWriter::new(&config, &mut doc);
519        assert_eq!(
520            ed.card(0)
521                .unwrap()
522                .revise_field("stray", "x")
523                .unwrap_err()
524                .variant_name(),
525            "UnknownField"
526        );
527    }
528}