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