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