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