Skip to main content

quillmark_core/
reader.rs

1//! Schema-bound typed reader: the read twin of
2//! [`TypedWriter`](crate::TypedWriter).
3//!
4//! The read surface's verbs split by read-vs-write, but the deeper fault line is
5//! **interpret-vs-transport**. The verbatim [`payload().get`](crate::Card::payload)
6//! (the WASM binding's `Document.getStored`) is *transport*: it returns the stored
7//! value verbatim, schema-free and round-trippable, the disambiguation / debug
8//! read. Projecting a field to
9//! markdown is *interpretation*: a schema-shaped question ("this field's
10//! richtext, as markdown") that a schema-free `Document` cannot answer without
11//! guessing which fields are even richtext. [`Card::field_markdown`] carries the
12//! projection but has no schema to name the field set, so an unknown field name
13//! reads back as absent rather than as the typo it is.
14//!
15//! [`Quill::reader`](crate::Quill::reader) binds the schema (where the authority
16//! already lives, the writer's twin) so a single verb interprets by the field's
17//! declared type:
18//!
19//! ```ignore
20//! let v = quill.reader(&doc);
21//! v.get("subject")?;            // richtext → Some(Markdown(..))
22//! v.get("qty")?;                // integer  → Some(Value(3))
23//! v.get("absent")?;             // absent   → None
24//! v.get("nope");                // unknown name → Err(UnknownField)
25//! v.card(2)?.get("body")?;      // card field, kind resolves its schema
26//! ```
27//!
28//! **absence returns; mismatch raises; an unknown name is a typo.** A `richtext`
29//! field projects to markdown ([`ReadValue::Markdown`]) and a `plaintext` field
30//! to its literal text ([`ReadValue::Plaintext`]); every other declared type
31//! returns its canonical value verbatim ([`ReadValue::Value`]): the same
32//! transport `Document` reads, now reached with schema authority. A present value
33//! that does not decode under a content field raises
34//! [`EditError::FieldRichtextDecode`], the mismatch [`Card::field_markdown`] /
35//! [`Card::field_plaintext`] surfaces. A name the schema does not declare raises
36//! [`EditError::UnknownField`], exactly as [`TypedWriter::set`](crate::TypedWriter::set)
37//! rejects it on the write side.
38//!
39//! The body read stays quill-free: a body's type is a format fact, not a schema
40//! fact, so [`get_body`](TypedReader::get_body) mirrors
41//! [`Card::body_markdown`](crate::Card::body_markdown) rather than consulting the
42//! schema.
43//!
44//! Like [`TypedWriter`](crate::TypedWriter), a bound reader holds `&Document`
45//! and `&QuillConfig`, so
46//! it cannot cross a binding boundary that carries no lifetimes (wasm-bindgen /
47//! pyo3); those surfaces construct one per call from the quill handle.
48
49use indexmap::IndexMap;
50
51use crate::document::{Card, Document, EditError, RichtextDecodeError};
52use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
53use crate::value::QuillValue;
54
55/// The interpreted value at a field address: the output of [`TypedReader::get`].
56/// A content field decodes to its codec's projection (`richtext` to markdown,
57/// `plaintext` to literal text); every other declared type carries its canonical
58/// value verbatim (the transport read, reached through the schema). Absence is
59/// the `None` of the enclosing `Option`, not a variant here.
60#[derive(Debug, Clone, PartialEq)]
61#[non_exhaustive]
62pub enum ReadValue {
63    /// A `richtext` field projected to markdown (`export ∘ decode`): the lossy,
64    /// on-demand view (content-only marks do not survive markdown).
65    Markdown(String),
66    /// A `plaintext` field projected through its literal codec (`to_plaintext ∘
67    /// decode`): verbatim text, marks never interpreted (`*hi*` is four
68    /// characters, not emphasis).
69    Plaintext(String),
70    /// A non-content field's canonical value, verbatim: the schema-free
71    /// transport read a `Document` returns, delivered here with schema authority.
72    Value(QuillValue),
73}
74
75/// A [`Document`] bound to its [`QuillConfig`] for typed reads. Construct with
76/// [`Quill::reader`](crate::Quill::reader). Reads target the main card; use
77/// [`card`](Self::card) for a composable card. The read twin of
78/// [`TypedWriter`](crate::TypedWriter).
79pub struct TypedReader<'a> {
80    config: &'a QuillConfig,
81    doc: &'a Document,
82}
83
84impl<'a> TypedReader<'a> {
85    /// Bind `doc` to `config`. Prefer [`Quill::reader`](crate::Quill::reader).
86    pub fn new(config: &'a QuillConfig, doc: &'a Document) -> Self {
87        Self { config, doc }
88    }
89
90    /// Read a main-card field, interpreted by its declared type: `richtext` to
91    /// markdown ([`ReadValue::Markdown`]), `plaintext` to literal text
92    /// ([`ReadValue::Plaintext`]), every other type verbatim
93    /// ([`ReadValue::Value`]). `Ok(None)` when the field is absent;
94    /// [`EditError::UnknownField`] for a name the schema does not declare (a typo,
95    /// as on the write side); [`EditError::FieldRichtextDecode`] when a content
96    /// field holds a value that does not decode (a scalar an opaque
97    /// [`store_field`](crate::Card::store_field) wrote).
98    pub fn get(&self, name: &str) -> Result<Option<ReadValue>, EditError> {
99        read_field(self.doc.main(), Some(&self.config.main.fields), name)
100    }
101
102    /// The main body's markdown projection, the quill-free body read
103    /// ([`Card::body_markdown`](crate::Card::body_markdown)). A body's type is a
104    /// format fact, not a schema fact, so this consults no schema and never
105    /// raises; the body is never absent.
106    pub fn get_body(&self) -> String {
107        self.doc.main().body_markdown()
108    }
109
110    /// A schema-bound reader for the composable card at `index`. The card's
111    /// `$kind` resolves its [`CardSchema`]; an unknown kind carries no schema, so
112    /// every field name on it is undeclared and reads with
113    /// [`EditError::UnknownField`] (read such a card verbatim through
114    /// [`Card::payload`]). [`EditError::IndexOutOfRange`] when `index` is out of
115    /// range: a boundary error, not an absent field, as the card write verbs
116    /// treat it.
117    pub fn card(&self, index: usize) -> Result<CardReader<'_>, EditError> {
118        let len = self.doc.cards().len();
119        let card = self
120            .doc
121            .card(index)
122            .ok_or(EditError::IndexOutOfRange { index, len })?;
123        let schema = card.kind().and_then(|k| self.config.card_kind(k));
124        Ok(CardReader { schema, card })
125    }
126}
127
128/// A single composable card bound to its [`CardSchema`], from
129/// [`TypedReader::card`]. Same `get` / `get_body` verbs as [`TypedReader`],
130/// reading the card at its bound index.
131pub struct CardReader<'a> {
132    schema: Option<&'a CardSchema>,
133    card: &'a Card,
134}
135
136impl CardReader<'_> {
137    /// The card's `$kind`, if any.
138    pub fn kind(&self) -> Option<&str> {
139        self.card.kind()
140    }
141
142    /// Read a field on this card, interpreted by its declared type: the card
143    /// twin of [`TypedReader::get`]. Resolves the field against the card's
144    /// [`CardSchema`]; a name the schema does not declare (or any name when the
145    /// card kind is unknown) reads with [`EditError::UnknownField`].
146    pub fn get(&self, name: &str) -> Result<Option<ReadValue>, EditError> {
147        read_field(self.card, self.schema.map(|s| &s.fields), name)
148    }
149
150    /// This card's body markdown: the card twin of [`TypedReader::get_body`],
151    /// quill-free and never raising.
152    pub fn get_body(&self) -> String {
153        self.card.body_markdown()
154    }
155}
156
157/// The shared read dispatch behind [`TypedReader::get`] and [`CardReader::get`]:
158/// resolve `name` against `fields_schema` (an unknown name, or every name when
159/// the whole schema is `None` (an unknown card kind) is
160/// [`EditError::UnknownField`]), then interpret by the field's declared type. A
161/// content field projects through its codec (`richtext` via
162/// [`Card::field_markdown`], `plaintext` via [`Card::field_plaintext`]) each
163/// carrying the projection's absent (`None`) / mismatch
164/// ([`EditError::FieldRichtextDecode`]) outcomes; every other type returns its
165/// canonical value verbatim, `None` when absent.
166fn read_field(
167    card: &Card,
168    fields_schema: Option<&IndexMap<String, FieldSchema>>,
169    name: &str,
170) -> Result<Option<ReadValue>, EditError> {
171    let schema = fields_schema
172        .and_then(|m| m.get(name))
173        .ok_or_else(|| EditError::UnknownField(name.to_string()))?;
174    match schema.r#type {
175        FieldType::RichText { .. } => project(card.field_markdown(name), name, ReadValue::Markdown),
176        FieldType::PlainText { .. } => {
177            project(card.field_plaintext(name), name, ReadValue::Plaintext)
178        }
179        _ => Ok(card
180            .payload()
181            .get(name)
182            .map(|v| ReadValue::Value(v.clone()))),
183    }
184}
185
186/// Lift a codec projection ([`Card::field_markdown`] / [`Card::field_plaintext`])
187/// into a [`ReadValue`]: `None` absent, `Some(Ok)` wrapped by `wrap`, `Some(Err)`
188/// the [`EditError::FieldRichtextDecode`] mismatch naming `name`.
189fn project(
190    projection: Option<Result<String, RichtextDecodeError>>,
191    name: &str,
192    wrap: fn(String) -> ReadValue,
193) -> Result<Option<ReadValue>, EditError> {
194    match projection {
195        None => Ok(None),
196        Some(Ok(text)) => Ok(Some(wrap(text))),
197        Some(Err(e)) => Err(EditError::FieldRichtextDecode {
198            field: name.to_string(),
199            message: e.into_message(),
200        }),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::document::Document;
208    use crate::version::QuillReference;
209    use std::str::FromStr;
210
211    const QUILL_YAML: &str = "\
212quill:
213  name: memo
214  backend: typst
215  version: 1.0.0
216  description: Reader test quill
217main:
218  fields:
219    subject:
220      type: richtext
221      inline: true
222    note:
223      type: plaintext
224    qty:
225      type: integer
226card_kinds:
227  note:
228    fields:
229      body:
230        type: richtext
231";
232
233    fn config() -> QuillConfig {
234        QuillConfig::from_yaml(QUILL_YAML).expect("valid quill")
235    }
236
237    fn blank_doc() -> Document {
238        Document::new(QuillReference::from_str("memo@1.0.0").unwrap())
239    }
240
241    // Build a document through the writer, then read it back through the view.
242    fn seeded_doc(config: &QuillConfig) -> Document {
243        let mut doc = blank_doc();
244        {
245            let mut w = crate::TypedWriter::new(config, &mut doc);
246            w.set("subject", "Hello **world**").unwrap();
247            w.set("qty", "3").unwrap();
248            w.add_card("note", [("body", "a *card*")], None, None).unwrap();
249        }
250        doc
251    }
252
253    #[test]
254    fn richtext_field_projects_to_markdown() {
255        let config = config();
256        let doc = seeded_doc(&config);
257        let view = TypedReader::new(&config, &doc);
258        assert_eq!(
259            view.get("subject").unwrap(),
260            Some(ReadValue::Markdown("Hello **world**".to_string()))
261        );
262    }
263
264    #[test]
265    fn plaintext_field_projects_to_literal_text() {
266        let config = config();
267        let mut doc = blank_doc();
268        {
269            let mut w = crate::TypedWriter::new(&config, &mut doc);
270            // Marks are literal under plaintext: `*hi*` is verbatim, not emphasis.
271            w.set("note", "a *literal* line").unwrap();
272        }
273        let view = TypedReader::new(&config, &doc);
274        assert_eq!(
275            view.get("note").unwrap(),
276            Some(ReadValue::Plaintext("a *literal* line".to_string()))
277        );
278    }
279
280    #[test]
281    fn scalar_field_returns_canonical_value() {
282        let config = config();
283        let doc = seeded_doc(&config);
284        let view = TypedReader::new(&config, &doc);
285        assert_eq!(
286            view.get("qty").unwrap(),
287            Some(ReadValue::Value(QuillValue::from_json(serde_json::json!(3))))
288        );
289    }
290
291    #[test]
292    fn absent_field_returns_none() {
293        let config = config();
294        let doc = blank_doc();
295        let view = TypedReader::new(&config, &doc);
296        assert_eq!(view.get("subject").unwrap(), None);
297        assert_eq!(view.get("qty").unwrap(), None);
298    }
299
300    #[test]
301    fn unknown_field_name_raises() {
302        let config = config();
303        let doc = blank_doc();
304        let view = TypedReader::new(&config, &doc);
305        assert!(matches!(
306            view.get("nope"),
307            Err(EditError::UnknownField(name)) if name == "nope"
308        ));
309    }
310
311    #[test]
312    fn richtext_field_holding_scalar_raises_mismatch() {
313        let config = config();
314        let mut doc = blank_doc();
315        // An opaque write puts a bare number under the `subject` richtext field.
316        doc.main_mut()
317            .store_field("subject", QuillValue::from_json(serde_json::json!(3)))
318            .unwrap();
319        let view = TypedReader::new(&config, &doc);
320        assert!(matches!(
321            view.get("subject"),
322            Err(EditError::FieldRichtextDecode { field, .. }) if field == "subject"
323        ));
324    }
325
326    #[test]
327    fn card_field_reads_through_kind_schema() {
328        let config = config();
329        let doc = seeded_doc(&config);
330        let view = TypedReader::new(&config, &doc);
331        let card = view.card(0).unwrap();
332        assert_eq!(card.kind(), Some("note"));
333        assert_eq!(
334            card.get("body").unwrap(),
335            Some(ReadValue::Markdown("a *card*".to_string()))
336        );
337        assert!(matches!(card.get("nope"), Err(EditError::UnknownField(_))));
338    }
339
340    #[test]
341    fn card_out_of_range_raises() {
342        let config = config();
343        let doc = blank_doc();
344        let view = TypedReader::new(&config, &doc);
345        assert!(matches!(
346            view.card(9),
347            Err(EditError::IndexOutOfRange { index: 9, len: 0 })
348        ));
349    }
350
351    #[test]
352    fn body_read_is_quill_free() {
353        let config = config();
354        let mut doc = blank_doc();
355        doc.main_mut().revise_body("A **body**.").unwrap();
356        let view = TypedReader::new(&config, &doc);
357        assert_eq!(view.get_body(), "A **body**.");
358    }
359}