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