Skip to main content

quillmark_core/document/
mod.rs

1//! Parsing and typed in-memory model for Quillmark card-yaml documents.
2//!
3//! A [`Document`] holds a root [`Card`] plus ordered composable cards; each
4//! card carries a [`Payload`] — source-ordered items ([`PayloadItem`]:
5//! `$quill`/`$kind`/`$id` metadata, user fields, and comments, in the order
6//! they appear in the block's YAML content) — and a Markdown body.
7//! [`Document::parse`] returns errors for malformed YAML, unclosed
8//! fences, a missing root `$quill`, or unknown `$`-prefixed system keys.
9//!
10//! See [markdown-spec.md](https://github.com/borb-sh/quillmark/blob/main/prose/references/markdown-spec.md)
11//! for the card-yaml format specification.
12
13use serde::{Deserialize, Serialize};
14
15use quillmark_content::import::{from_markdown as import_markdown, ImportError};
16use quillmark_content::Content;
17
18use crate::error::ParseError;
19use crate::version::QuillReference;
20use crate::Diagnostic;
21
22/// The single markdown→content boundary for card bodies. Every construction path
23/// that starts from an authored markdown string ([`Document::parse`],
24/// wire/storage deserialization, seeding, blueprint) routes through it, so the
25/// markdown parser is reached from exactly one helper. An empty string yields
26/// the empty content without invoking the parser.
27pub(crate) fn import_body(md: &str) -> Result<Content, ImportError> {
28    if md.is_empty() {
29        Ok(Content::empty())
30    } else {
31        import_markdown(md)
32    }
33}
34
35/// Which encoding a `decode_richtext_value` failure came from, so a call site
36/// can prefix its diagnostic per encoding without re-deriving the dispatch.
37/// Surfaced publicly as the error of [`Card::field_richtext`].
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum RichtextDecodeError {
40    /// A JSON object that is not a valid canonical content.
41    NotContent(String),
42    /// A markdown string that failed to import.
43    BadMarkdown(String),
44}
45
46impl RichtextDecodeError {
47    /// The inner failure message, without an encoding-specific prefix.
48    pub fn into_message(self) -> String {
49        match self {
50            RichtextDecodeError::NotContent(m) | RichtextDecodeError::BadMarkdown(m) => m,
51        }
52    }
53}
54
55/// Decode a JSON value in either accepted richtext encoding: a canonical content
56/// **object** ([`from_canonical_value`](quillmark_content::serial::from_canonical_value))
57/// or an authored markdown **string** (via [`import_body`], the single markdown
58/// boundary). The one place the object-vs-string dispatch lives; a call site
59/// handles the shapes that are neither — `null`, array, scalar — and maps the
60/// error into its own type.
61///
62/// - `Some(Ok(rt))` — decoded.
63/// - `Some(Err(e))` — an object that is not a content, or a string that failed
64///   to import; `e` names the encoding so the caller can prefix its message.
65/// - `None` — the value is neither an object nor a string.
66pub(crate) fn decode_richtext_value(
67    value: &serde_json::Value,
68) -> Option<Result<Content, RichtextDecodeError>> {
69    match value {
70        serde_json::Value::Object(_) => Some(
71            quillmark_content::serial::from_canonical_value(value)
72                .map_err(|e| RichtextDecodeError::NotContent(e.to_string())),
73        ),
74        serde_json::Value::String(md) => {
75            Some(import_body(md).map_err(|e| RichtextDecodeError::BadMarkdown(e.to_string())))
76        }
77        _ => None,
78    }
79}
80
81/// Decode a JSON value for a `plaintext` field: a canonical content **object**
82/// (revalidated) or a literal **string** imported verbatim
83/// ([`from_plaintext`](quillmark_content::from_plaintext) — never markdown, so
84/// `*hi*` stays four plain characters). The plaintext twin of
85/// [`decode_richtext_value`]: the string branch is infallible (literal import
86/// can't fail), so only the object branch yields `Err` (`String` message). A
87/// call site handles the shapes that are neither — `null`, array, scalar. This
88/// is the single plaintext object-vs-string dispatch, shared by the coercion
89/// literal-import site and the validation shape check.
90///
91/// - `Some(Ok(rt))` — decoded.
92/// - `Some(Err(msg))` — an object that is not a valid content.
93/// - `None` — the value is neither an object nor a string.
94pub(crate) fn decode_plaintext_value(
95    value: &serde_json::Value,
96) -> Option<Result<Content, String>> {
97    match value {
98        serde_json::Value::Object(_) => {
99            Some(quillmark_content::serial::from_canonical_value(value).map_err(|e| e.to_string()))
100        }
101        serde_json::Value::String(s) => Some(Ok(quillmark_content::from_plaintext(s))),
102        _ => None,
103    }
104}
105
106pub mod assemble;
107pub mod dto;
108pub mod edit;
109pub mod emit;
110pub mod fences;
111pub mod limits;
112pub mod meta;
113pub mod payload;
114pub mod prescan;
115pub mod wire;
116pub(crate) mod yaml_hints;
117
118pub use dto::{peek_schema_version, StorageError, StoredDocument, SCHEMA_V0_93_0};
119pub use edit::EditError;
120pub use meta::{is_valid_kind_name, validate_composable_kind, CardKindError};
121pub use payload::{MetaKey, Payload, PayloadItem};
122pub use wire::{CardWire, PayloadItemWire, WireError};
123
124/// Authoring-format rules for the `~~~` card-yaml markdown surface.
125///
126/// Surfaced verbatim to LLM/MCP consumers (and to CLI / Python bindings via
127/// the same text) so error parity holds — every consumer reads the same
128/// rules. This is the single source of truth; bindings should call into it
129/// rather than re-stating the rules in their own glue.
130pub const FORMAT_RULES: &str = "Document format rules:
131\u{2022} Block opener and closer are EXACTLY `~~~` (three tildes, no info string). The `~~~card-yaml` opener is also accepted as a non-canonical alias.
132\u{2022} A blank line must precede every `~~~` block opener (unless it is line 1), and the opener must be at column zero (no leading spaces). An indented `~~~` is an ordinary code block, not a card.
133\u{2022} The first block is the root and MUST contain `$quill: <name>@<version>`. Its `$kind` is `main` by position \u{2014} an explicit `$kind: main` is accepted but not required. Additional blocks declare composable cards via `$kind: <card_kind>`.
134\u{2022} Reserved `$`-keys: `$quill`, `$kind`, `$id`, `$ext`, `$seed`. User fields use lowercase snake_case.
135\u{2022} Prose body is the text after a block's closing `~~~`, up to the next opener or EOF. To include a literal fenced code block in prose, use a backtick fence (```); any column-zero `~~~` block is parsed as card metadata.
136\u{2022} A field that already shows a concrete value carries a default and is shippable as-is \u{2014} keep the line, override the value, or delete it to fall back to the default. A blank or null value (`field:`, `field: null`, `field: ~`) is treated the same as omitting the field: it falls back to the default, or to the type-empty zero value.
137\u{2022} `field: !must_fill <value>` marks a placeholder awaiting your input \u{2014} replace it with a real value and drop the `!must_fill` tag before shipping. A bare `field: !must_fill` is an empty placeholder. A leftover marker never blocks rendering, but it is reported as a warning until you replace it.
138\u{2022} Numbers and booleans MUST be unquoted (`year: 2025`, `pinned: true`); quoting turns them into strings and fails validation.
139\u{2022} Plain-scalar values cannot start with `*` or `&` (YAML alias/anchor markers) and cannot contain `: ` (colon-space). For markdown emphasis, embedded colons, or other special prefixes, quote the value: `field: '**bold**'` or `field: \"Name: subtitle\"`. Multi-line values use `|-`, not multi-line quoted scalars.";
140
141/// Authoring-ergonomics header that introduces a blueprint to an LLM/MCP
142/// consumer. The `{quill}` placeholder is substituted with the quill name.
143/// Designed to be shown above [`FORMAT_RULES`], which covers field-level
144/// semantics like the `!must_fill` marker — keep the wording tight here so the
145/// two strings do not duplicate guidance.
146const BLUEPRINT_INSTRUCTION_TEMPLATE: &str =
147    "Fill in the `{quill}` blueprint below: replace each `!must_fill` placeholder with a real \
148value and edit the body prose. Submit the filled markdown as `content` to `create_document`.";
149
150/// Render the blueprint-instruction header with `quill_name` substituted in.
151/// Single source of truth for the prose so every binding shows identical text.
152pub fn blueprint_instruction(quill_name: &str) -> String {
153    BLUEPRINT_INSTRUCTION_TEMPLATE.replace("{quill}", quill_name)
154}
155
156#[cfg(test)]
157mod tests;
158
159/// The record of one parse: the [`Document`] and any non-fatal warnings.
160/// Returned by [`Document::parse`], the single parse entry. Warnings live here
161/// and only here — `Document` is the value (equality, the storage DTO, and
162/// mutators all exclude warnings); `Parsed` is the parse *event*. A caller that
163/// wants only the document writes `Document::parse(md)?.document`.
164#[derive(Debug)]
165#[must_use = "carries parse warnings; read `.document`/`.warnings` or bind it"]
166pub struct Parsed {
167    pub document: Document,
168    pub warnings: Vec<Diagnostic>,
169}
170
171/// A single card-yaml block (root or composable). `body` is the content
172/// ([`Content`]) form of the prose after the closing fence — the empty content
173/// when none follows; check `card.body().is_blank()`. Markdown is a projection:
174/// [`Card::body_markdown`] re-emits it.
175#[derive(Debug, Clone, PartialEq)]
176pub struct Card {
177    payload: Payload,
178    body: Content,
179}
180
181impl Card {
182    /// Create a `Card` from its parts without validation. `body` is the content
183    /// form; to build from an authored markdown string, import it first via the
184    /// crate-internal `import_body` boundary. For user-facing construction of
185    /// composable cards use [`Card::new`].
186    pub fn from_parts(payload: Payload, body: Content) -> Self {
187        Self { payload, body }
188    }
189
190    pub fn quill(&self) -> Option<&QuillReference> {
191        self.payload.quill()
192    }
193
194    pub fn kind(&self) -> Option<&str> {
195        self.payload.kind()
196    }
197
198    pub fn id(&self) -> Option<&str> {
199        self.payload.id()
200    }
201
202    /// Opaque `$ext` map for out-of-band extension data (UI editor state,
203    /// agent annotations, …). Carried through Markdown and storage DTO
204    /// round-trips; never emitted into the plate JSON consumed by
205    /// backends.
206    pub fn ext(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
207        self.payload.ext()
208    }
209
210    pub fn payload(&self) -> &Payload {
211        &self.payload
212    }
213
214    pub fn payload_mut(&mut self) -> &mut Payload {
215        &mut self.payload
216    }
217
218    /// The card body as a [`Content`] content — the canonical content model.
219    /// For the markdown projection use [`Card::body_markdown`].
220    pub fn body(&self) -> &Content {
221        &self.body
222    }
223
224    /// The card body rendered back to its markdown projection. This is a
225    /// derived view (`export ∘ body`), not stored state; a `Document` round-trip
226    /// therefore canonicalizes the body (e.g. `__b__` → `**b**`).
227    pub fn body_markdown(&self) -> String {
228        quillmark_content::export::to_markdown(&self.body)
229    }
230
231    pub(crate) fn overwrite_body(&mut self, body: Content) {
232        self.body = body;
233    }
234
235    pub(crate) fn body_mut(&mut self) -> &mut Content {
236        &mut self.body
237    }
238
239    /// Read a richtext-valued user field back as a [`Content`] content — the
240    /// field-level twin of [`Card::body`]. Decodes the stored value through the
241    /// same object-or-markdown dispatch the writer
242    /// ([`commit_field`](Card::commit_field)) commits, so a field
243    /// stored as a canonical content reads back losslessly (identity marks
244    /// intact) and a still-authored markdown string imports.
245    ///
246    /// - `None` — the field is absent.
247    /// - `Some(Ok(rt))` — decoded content.
248    /// - `Some(Err(_))` — the field is present but neither a content object nor
249    ///   an importable markdown string (e.g. a bare number a `store_field` wrote).
250    ///
251    /// A `Document` carries no schema, so this cannot itself tell a richtext
252    /// field from a plain string field; the caller names a field it knows is
253    /// richtext, exactly as it does when writing.
254    pub fn field_richtext(&self, name: &str) -> Option<Result<Content, RichtextDecodeError>> {
255        let value = self.payload.get(name)?.as_json();
256        Some(match crate::document::decode_richtext_value(value) {
257            Some(result) => result,
258            None => match value {
259                serde_json::Value::Null => Ok(Content::empty()),
260                _ => Err(RichtextDecodeError::NotContent(
261                    "expected a richtext content object or a markdown string".to_string(),
262                )),
263            },
264        })
265    }
266
267    /// The markdown projection of a richtext-valued field (`export ∘ decode`) —
268    /// the field-level twin of [`Card::body_markdown`], and the projection an
269    /// emit or a markdown save writes for a content-valued field. The projection
270    /// twin of [`field_richtext`](Card::field_richtext), carrying its `Ok`/`Err`
271    /// decode outcome:
272    ///
273    /// - `None` — the field is absent.
274    /// - `Some(Ok(md))` — the projected markdown.
275    /// - `Some(Err(_))` — the field is present but does not decode as richtext
276    ///   (a scalar/array/object a `store_field` wrote, or a non-content object).
277    ///
278    /// Absence returns `None`; a present non-richtext value returns `Some(Err)`,
279    /// so the projection surfaces the type mismatch instead of blanking on it.
280    pub fn field_markdown(&self, name: &str) -> Option<Result<String, RichtextDecodeError>> {
281        Some(self.field_richtext(name)?.map(|rt| quillmark_content::export::to_markdown(&rt)))
282    }
283
284    /// The plaintext projection of a content-valued field (`to_plaintext ∘
285    /// decode`) — the literal-codec twin of [`field_markdown`](Card::field_markdown),
286    /// for a `plaintext`-typed field: marks are never interpreted, so the text is
287    /// verbatim both ways. Carries [`field_richtext`](Card::field_richtext)'s
288    /// `Ok`/`Err` decode outcome:
289    ///
290    /// - `None` — the field is absent.
291    /// - `Some(Ok(text))` — the projected literal text.
292    /// - `Some(Err(_))` — the field is present but does not decode as content.
293    pub fn field_plaintext(&self, name: &str) -> Option<Result<String, RichtextDecodeError>> {
294        Some(self.field_richtext(name)?.map(|rt| quillmark_content::export::to_plaintext(&rt)))
295    }
296}
297
298/// A parsed, per-kind **seed overlay**: the sparse fields (and optional body)
299/// a newly-added card of a given kind starts with. Built from a `$seed[<kind>]`
300/// entry of the main card's [`Card::seed`] map via [`SeedOverlay::from_json`],
301/// and layered over the quill's schema-example seed by
302/// [`crate::Quill::seed_card`] (overlay › example › absent). The reserved inner
303/// key `$body` carries the body override; every other user field becomes an
304/// entry, while any other `$`-prefixed key is reserved and dropped.
305#[derive(Debug, Clone, PartialEq, Default)]
306pub struct SeedOverlay {
307    /// Field-value overrides, keyed by field name.
308    pub fields: indexmap::IndexMap<String, crate::value::QuillValue>,
309    /// Body override, when the overlay declares a `$body` string.
310    pub body: Option<String>,
311}
312
313impl SeedOverlay {
314    /// Parse an overlay from a `$seed[<kind>]` JSON value, or `None` when it is
315    /// not a mapping. Use this to turn the raw overlay object a consumer reads
316    /// from the main card's `$seed` map ([`Card::seed`]) into a typed overlay to
317    /// hand to [`crate::Quill::seed_card`] — e.g.
318    /// `doc.main().seed().and_then(|m| m.get(kind)).and_then(SeedOverlay::from_json)`.
319    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
320        value.as_object().map(Self::from_json_map)
321    }
322
323    /// Build an overlay from a single `$seed[<kind>]` JSON map: the reserved
324    /// `$body` string becomes [`body`](Self::body); every other user-field entry
325    /// becomes a field. A non-string `$body` is ignored (no body override). Any
326    /// other `$`-prefixed key is reserved and dropped — never stored as a user
327    /// field — since an overlay only ever carries user fields plus `$body`.
328    fn from_json_map(map: &serde_json::Map<String, serde_json::Value>) -> Self {
329        let mut fields = indexmap::IndexMap::new();
330        let mut body = None;
331        for (key, value) in map {
332            if key == "$body" {
333                if let Some(s) = value.as_str() {
334                    body = Some(s.to_string());
335                }
336            } else if key.starts_with('$') {
337                // Reserved key other than `$body`: not a user field. Drop it
338                // rather than smuggle a `$`-key into the field set.
339                continue;
340            } else {
341                fields.insert(
342                    key.clone(),
343                    crate::value::QuillValue::from_json(value.clone()),
344                );
345            }
346        }
347        SeedOverlay { fields, body }
348    }
349}
350
351/// A fully-parsed Quillmark document. Serde routes through [`StoredDocument`];
352/// for the plate wire shape see [`Document::to_plate_json`].
353///
354/// Parse-time warnings are *not* document state — they ride out-of-band on
355/// [`Parsed`] from [`Document::parse`], the single owner. Equality and the
356/// storage DTO therefore cover only structural content (`main` and `cards`).
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358#[serde(into = "StoredDocument", try_from = "StoredDocument")]
359pub struct Document {
360    main: Card,
361    cards: Vec<Card>,
362}
363
364impl Document {
365    /// Create a blank document: a main card carrying only `$quill`, an empty
366    /// body, and no composable cards. The programmatic blank canvas — every
367    /// schema field is absent and resolves at render time (`default`, else
368    /// type-empty zero), so nothing the caller did not set reaches the
369    /// output. For an example-filled starter shaped like the blueprint, use
370    /// `Quill::seed_document`.
371    pub fn new(quill: QuillReference) -> Self {
372        let mut payload = Payload::new();
373        payload.set_quill(quill);
374        // Parsed main cards always carry `$kind: main` (the parser normalizes
375        // it in); match that shape so a blank document round-trips equal.
376        payload.set_kind("main");
377        Self {
378            main: Card::from_parts(payload, Content::empty()),
379            cards: Vec::new(),
380        }
381    }
382
383    /// Create a `Document` from a pre-built main card and composable cards.
384    /// `main` must carry `$quill`; composable cards must not.
385    pub fn from_main_and_cards(main: Card, cards: Vec<Card>) -> Self {
386        debug_assert!(main.quill().is_some(), "main card must carry `$quill`");
387        debug_assert!(
388            cards.iter().all(|c| c.quill().is_none()),
389            "composable cards must not carry `$quill`"
390        );
391        debug_assert!(
392            cards.iter().all(|c| c.seed().is_none()),
393            "composable cards must not carry `$seed`"
394        );
395        Self { main, cards }
396    }
397
398    /// Parse card-yaml Markdown into a [`Parsed`] — the [`Document`] plus any
399    /// non-fatal warnings. The single parse entry; a caller that wants only the
400    /// document writes `Document::parse(md)?.document`. Errors on malformed
401    /// YAML, a missing root `$quill`, an over-size input, and the other
402    /// [`ParseError`] variants.
403    #[doc(alias = "from_markdown")]
404    pub fn parse(markdown: &str) -> Result<Parsed, ParseError> {
405        assemble::decompose_with_warnings(markdown)
406            .map(|(document, warnings)| Parsed { document, warnings })
407    }
408
409    pub fn main(&self) -> &Card {
410        &self.main
411    }
412
413    pub fn main_mut(&mut self) -> &mut Card {
414        &mut self.main
415    }
416
417    /// The `$quill` reference from the root block. Always present on parsed documents.
418    pub fn quill_reference(&self) -> QuillReference {
419        self.main
420            .quill()
421            .cloned()
422            .expect("root block's $quill is validated at parse time")
423    }
424
425    pub fn cards(&self) -> &[Card] {
426        &self.cards
427    }
428
429    pub fn cards_mut(&mut self) -> &mut [Card] {
430        &mut self.cards
431    }
432
433    /// A single composable card by index — the immutable twin of
434    /// [`card_mut`](Document::card_mut), so reading one card's payload does not
435    /// require materializing every card via [`cards`](Document::cards). `None`
436    /// when out of range.
437    pub fn card(&self, index: usize) -> Option<&Card> {
438        self.cards.get(index)
439    }
440
441    /// The first composable card whose `$id` equals `id`, with its index —
442    /// resolving the canonical durable address ([PROGRAMMATIC.md]) without a
443    /// hand-rolled scan over [`cards`](Document::cards). `$id` is non-unique by
444    /// design, so this returns the first match; `None` when no card carries it.
445    ///
446    /// [PROGRAMMATIC.md]: https://github.com/borb-sh/quillmark/blob/main/prose/canon/PROGRAMMATIC.md
447    pub fn find_card(&self, id: &str) -> Option<(usize, &Card)> {
448        self.cards
449            .iter()
450            .enumerate()
451            .find(|(_, card)| card.id() == Some(id))
452    }
453
454    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
455        &mut self.cards
456    }
457
458    /// Serialize to the JSON wire shape consumed by backend plates. This is
459    /// the **only** place in `quillmark-core` that produces this shape:
460    ///
461    /// ```json
462    /// {
463    ///   "$quill": "<ref>",
464    ///   "$body": { "text": "…", "lines": [...], "marks": [...], "islands": [...] },
465    ///   "$cards": [{ "$kind": "<tag>", "$body": <content>, "<field>": <value>, ... }],
466    ///   "<field>": <value>, ...
467    /// }
468    /// ```
469    ///
470    /// `$body` (global and per-card) is canonical Content-JSON — the content as
471    /// a nested object, not a markdown string. Richtext payload fields likewise
472    /// cross as content objects (committed at coercion time).
473    ///
474    /// `$`-prefixed keys carry document-level metadata (quill ref, body
475    /// text, card list, card kind). User payload fields stay flat at the
476    /// root — they cannot collide with `$` keys because user field names are
477    /// never `$`-prefixed (they match `[A-Za-z_][A-Za-z0-9_]*`).
478    pub fn to_plate_json(&self) -> serde_json::Value {
479        let mut map = serde_json::Map::new();
480
481        map.insert(
482            "$quill".to_string(),
483            serde_json::Value::String(self.quill_reference().to_string()),
484        );
485
486        // The seam carries the body as canonical Content-JSON (Option A): a
487        // nested content object, byte-identical to `to_canonical_json`, never a lossy
488        // markdown string. Backends lower the content (typst → markup + source
489        // map; pdfform → `.text`); the markdown projection is `body_markdown`.
490        map.insert(
491            "$body".to_string(),
492            quillmark_content::serial::to_canonical_value(self.main.body()),
493        );
494
495        let cards_array: Vec<serde_json::Value> = self
496            .cards
497            .iter()
498            .map(|card| {
499                let mut card_map = serde_json::Map::new();
500                card_map.insert(
501                    "$kind".to_string(),
502                    serde_json::Value::String(card.kind().unwrap_or("").to_string()),
503                );
504                card_map.insert(
505                    "$body".to_string(),
506                    quillmark_content::serial::to_canonical_value(card.body()),
507                );
508                for (key, value) in card.payload.iter() {
509                    card_map.insert(key.clone(), value.as_json().clone());
510                }
511                serde_json::Value::Object(card_map)
512            })
513            .collect();
514
515        map.insert("$cards".to_string(), serde_json::Value::Array(cards_array));
516
517        for (key, value) in self.main.payload.iter() {
518            map.insert(key.clone(), value.as_json().clone());
519        }
520
521        serde_json::Value::Object(map)
522    }
523}