Skip to main content

quillmark_core/document/
mod.rs

1//! Parsing and typed in-memory model for Quillmark card-yaml documents.
2//!
3//! ## Key types
4//!
5//! - [`Document`]: Root card plus ordered composable cards.
6//! - [`Card`]: A single card block carrying a [`Payload`] and a Markdown body.
7//! - [`Payload`]: Source-ordered items — `$quill`/`$kind`/`$id` metadata, user
8//!   fields, and comments — from a block's YAML content.
9//! - [`PayloadItem`]: The item variant — `Quill`/`Kind`/`Id`/`Field`/`Comment`.
10//!
11//! ## Errors
12//!
13//! [`Document::from_markdown`] returns errors for malformed YAML, unclosed
14//! fences, a missing root `$quill`, or unknown `$`-prefixed system keys.
15//!
16//! See [markdown-spec.md](https://github.com/quillmark-org/quillmark/blob/main/prose/references/markdown-spec.md)
17//! for the card-yaml format specification.
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::ParseError;
22use crate::version::QuillReference;
23use crate::Diagnostic;
24
25pub mod assemble;
26pub mod dto;
27pub mod edit;
28pub mod emit;
29pub mod fences;
30pub mod limits;
31pub mod meta;
32pub mod payload;
33pub mod prescan;
34pub mod wire;
35pub(crate) mod yaml_hints;
36
37pub use dto::{peek_schema_version, StorageError, StoredDocument, SCHEMA_V0_92_0};
38pub use edit::EditError;
39pub use meta::{is_valid_kind_name, validate_composable_kind, CardKindError};
40pub use payload::{MetaKey, Payload, PayloadItem};
41pub use wire::{CardWire, PayloadItemWire, WireError};
42
43/// Authoring-format rules for the `~~~` card-yaml markdown surface.
44///
45/// Surfaced verbatim to LLM/MCP consumers (and to CLI / Python bindings via
46/// the same text) so error parity holds — every consumer reads the same
47/// rules. This is the single source of truth; bindings should call into it
48/// rather than re-stating the rules in their own glue.
49pub const FORMAT_RULES: &str = "Document format rules:
50\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.
51\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.
52\u{2022} The first block is the root and MUST contain `$quill: <name>@<version>` and `$kind: main`. Additional blocks declare composable cards via `$kind: <card_kind>`.
53\u{2022} Reserved `$`-keys: `$quill`, `$kind`, `$id`, `$ext`, `$seed`. User fields use lowercase snake_case.
54\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.
55\u{2022} `; delete-ok` fields carry a default \u{2014} keep the line, override the value, or delete the entire line to use the default. Do not write `field:`, `field: null`, or `field: ~` \u{2014} all three parse as explicit YAML null and fail validation.
56\u{2022} Numbers and booleans MUST be unquoted (`year: 2025`, `pinned: true`); quoting turns them into strings and fails validation.
57\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.";
58
59/// Authoring-ergonomics header that introduces a blueprint to an LLM/MCP
60/// consumer. The `{quill}` placeholder is substituted with the quill name.
61/// Designed to be shown above [`FORMAT_RULES`], which covers field-level
62/// semantics like `; delete-ok` — keep the wording tight here so the two
63/// strings do not duplicate guidance.
64const BLUEPRINT_INSTRUCTION_TEMPLATE: &str =
65    "Fill in the `{quill}` blueprint below: replace each `<must-fill>` sentinel and edit the \
66body prose. Submit the filled markdown as `content` to `create_document`.";
67
68/// Render the blueprint-instruction header with `quill_name` substituted in.
69/// Single source of truth for the prose so every binding shows identical text.
70pub fn blueprint_instruction(quill_name: &str) -> String {
71    BLUEPRINT_INSTRUCTION_TEMPLATE.replace("{quill}", quill_name)
72}
73
74#[cfg(test)]
75mod tests;
76
77/// Parse result with the document and any non-fatal warnings.
78#[derive(Debug)]
79pub struct ParseOutput {
80    pub document: Document,
81    pub warnings: Vec<Diagnostic>,
82}
83
84/// A single card-yaml block (root or composable). `body` is `""` when no
85/// content follows the closing fence; check `card.body().is_empty()`.
86#[derive(Debug, Clone, PartialEq)]
87pub struct Card {
88    payload: Payload,
89    body: String,
90}
91
92impl Card {
93    /// Create a `Card` from its parts without validation. For user-facing
94    /// construction of composable cards use [`Card::new`].
95    pub fn from_parts(payload: Payload, body: String) -> Self {
96        Self { payload, body }
97    }
98
99    pub fn quill(&self) -> Option<&QuillReference> {
100        self.payload.quill()
101    }
102
103    pub fn kind(&self) -> Option<&str> {
104        self.payload.kind()
105    }
106
107    pub fn id(&self) -> Option<&str> {
108        self.payload.id()
109    }
110
111    /// Opaque `$ext` map for out-of-band extension data (UI editor state,
112    /// agent annotations, …). Carried through Markdown and storage DTO
113    /// round-trips; never emitted into the plate JSON consumed by
114    /// backends.
115    pub fn ext(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
116        self.payload.ext()
117    }
118
119    pub fn payload(&self) -> &Payload {
120        &self.payload
121    }
122
123    pub fn payload_mut(&mut self) -> &mut Payload {
124        &mut self.payload
125    }
126
127    pub fn body(&self) -> &str {
128        &self.body
129    }
130
131    pub(crate) fn overwrite_body(&mut self, body: String) {
132        self.body = body;
133    }
134}
135
136/// A parsed, per-kind **seed overlay**: the sparse fields (and optional body)
137/// a newly-added card of a given kind starts with. Built from a `$seed[<kind>]`
138/// entry of the main card's [`Card::seed`] map via [`SeedOverlay::from_json`],
139/// and layered over the quill's schema-example seed by
140/// [`crate::Quill::seed_card`] (overlay › example › absent). The reserved inner
141/// key `$body` carries the body override; every other user field becomes an
142/// entry, while any other `$`-prefixed key is reserved and dropped.
143#[derive(Debug, Clone, PartialEq, Default)]
144pub struct SeedOverlay {
145    /// Field-value overrides, keyed by field name.
146    pub fields: indexmap::IndexMap<String, crate::value::QuillValue>,
147    /// Body override, when the overlay declares a `$body` string.
148    pub body: Option<String>,
149}
150
151impl SeedOverlay {
152    /// Parse an overlay from a `$seed[<kind>]` JSON value, or `None` when it is
153    /// not a mapping. Use this to turn the raw overlay object a consumer reads
154    /// from the main card's `$seed` map ([`Card::seed`]) into a typed overlay to
155    /// hand to [`crate::Quill::seed_card`] — e.g.
156    /// `doc.main().seed().and_then(|m| m.get(kind)).and_then(SeedOverlay::from_json)`.
157    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
158        value.as_object().map(Self::from_json_map)
159    }
160
161    /// Build an overlay from a single `$seed[<kind>]` JSON map: the reserved
162    /// `$body` string becomes [`body`](Self::body); every other user-field entry
163    /// becomes a field. A non-string `$body` is ignored (no body override). Any
164    /// other `$`-prefixed key is reserved and dropped — never stored as a user
165    /// field — since an overlay only ever carries user fields plus `$body`.
166    fn from_json_map(map: &serde_json::Map<String, serde_json::Value>) -> Self {
167        let mut fields = indexmap::IndexMap::new();
168        let mut body = None;
169        for (key, value) in map {
170            if key == "$body" {
171                if let Some(s) = value.as_str() {
172                    body = Some(s.to_string());
173                }
174            } else if key.starts_with('$') {
175                // Reserved key other than `$body`: not a user field. Drop it
176                // rather than smuggle a `$`-key into the field set.
177                continue;
178            } else {
179                fields.insert(
180                    key.clone(),
181                    crate::value::QuillValue::from_json(value.clone()),
182                );
183            }
184        }
185        SeedOverlay { fields, body }
186    }
187}
188
189/// A fully-parsed Quillmark document. Serde routes through [`StoredDocument`];
190/// for the plate wire shape see [`Document::to_plate_json`].
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(into = "StoredDocument", try_from = "StoredDocument")]
193pub struct Document {
194    main: Card,
195    cards: Vec<Card>,
196    warnings: Vec<Diagnostic>,
197}
198
199// `warnings` are parse-time observations and vary on round-trips; equality
200// covers only structural content (`main` and `cards`).
201impl PartialEq for Document {
202    fn eq(&self, other: &Self) -> bool {
203        self.main == other.main && self.cards == other.cards
204    }
205}
206
207impl Document {
208    /// Create a `Document` from a pre-built main card and composable cards.
209    /// `main` must carry `$quill`; composable cards must not.
210    pub fn from_main_and_cards(main: Card, cards: Vec<Card>, warnings: Vec<Diagnostic>) -> Self {
211        debug_assert!(main.quill().is_some(), "main card must carry `$quill`");
212        debug_assert!(
213            cards.iter().all(|c| c.quill().is_none()),
214            "composable cards must not carry `$quill`"
215        );
216        debug_assert!(
217            cards.iter().all(|c| c.seed().is_none()),
218            "composable cards must not carry `$seed`"
219        );
220        Self {
221            main,
222            cards,
223            warnings,
224        }
225    }
226
227    pub fn from_markdown(markdown: &str) -> Result<Self, ParseError> {
228        assemble::decompose(markdown)
229    }
230
231    pub fn from_markdown_with_warnings(markdown: &str) -> Result<ParseOutput, ParseError> {
232        assemble::decompose_with_warnings(markdown)
233            .map(|(document, warnings)| ParseOutput { document, warnings })
234    }
235
236    pub fn main(&self) -> &Card {
237        &self.main
238    }
239
240    pub fn main_mut(&mut self) -> &mut Card {
241        &mut self.main
242    }
243
244    /// The `$quill` reference from the root block. Always present on parsed documents.
245    pub fn quill_reference(&self) -> QuillReference {
246        self.main
247            .quill()
248            .cloned()
249            .expect("root block's $quill is validated at parse time")
250    }
251
252    pub fn cards(&self) -> &[Card] {
253        &self.cards
254    }
255
256    pub fn cards_mut(&mut self) -> &mut [Card] {
257        &mut self.cards
258    }
259
260    /// Non-fatal warnings from the parse; empty for programmatically built documents.
261    pub fn warnings(&self) -> &[Diagnostic] {
262        &self.warnings
263    }
264
265    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
266        &mut self.cards
267    }
268
269    /// Serialize to the JSON wire shape consumed by backend plates. This is
270    /// the **only** place in `quillmark-core` that produces this shape:
271    ///
272    /// ```json
273    /// {
274    ///   "$quill": "<ref>",
275    ///   "$body": "<global-body>",
276    ///   "$cards": [{ "$kind": "<tag>", "$body": "<card-body>", "<field>": <value>, ... }],
277    ///   "<field>": <value>, ...
278    /// }
279    /// ```
280    ///
281    /// `$`-prefixed keys carry document-level metadata (quill ref, body
282    /// text, card list, card kind). User payload fields stay flat at the
283    /// root — they cannot collide with `$` keys because user field names are
284    /// never `$`-prefixed (they match `[A-Za-z_][A-Za-z0-9_]*`).
285    pub fn to_plate_json(&self) -> serde_json::Value {
286        let mut map = serde_json::Map::new();
287
288        map.insert(
289            "$quill".to_string(),
290            serde_json::Value::String(self.quill_reference().to_string()),
291        );
292
293        map.insert(
294            "$body".to_string(),
295            serde_json::Value::String(self.main.body.clone()),
296        );
297
298        let cards_array: Vec<serde_json::Value> = self
299            .cards
300            .iter()
301            .map(|card| {
302                let mut card_map = serde_json::Map::new();
303                card_map.insert(
304                    "$kind".to_string(),
305                    serde_json::Value::String(card.kind().unwrap_or("").to_string()),
306                );
307                card_map.insert(
308                    "$body".to_string(),
309                    serde_json::Value::String(card.body.clone()),
310                );
311                for (key, value) in card.payload.iter() {
312                    card_map.insert(key.clone(), value.as_json().clone());
313                }
314                serde_json::Value::Object(card_map)
315            })
316            .collect();
317
318        map.insert("$cards".to_string(), serde_json::Value::Array(cards_array));
319
320        for (key, value) in self.main.payload.iter() {
321            map.insert(key.clone(), value.as_json().clone());
322        }
323
324        serde_json::Value::Object(map)
325    }
326}