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}