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(crate) mod yaml_hints;
35pub mod wire;
36
37pub use dto::{peek_schema_version, StorageError, StoredDocument, SCHEMA_V0_81_0, SCHEMA_V0_82_0};
38pub use edit::EditError;
39pub use meta::{is_valid_kind_name, validate_composable_kind, CardKindError};
40pub use payload::{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`. 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 `; omit-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 fully-parsed Quillmark document. Serde routes through [`StoredDocument`];
137/// for the plate wire shape see [`Document::to_plate_json`].
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(into = "StoredDocument", try_from = "StoredDocument")]
140pub struct Document {
141    main: Card,
142    cards: Vec<Card>,
143    warnings: Vec<Diagnostic>,
144}
145
146// `warnings` are parse-time observations and vary on round-trips; equality
147// covers only structural content (`main` and `cards`).
148impl PartialEq for Document {
149    fn eq(&self, other: &Self) -> bool {
150        self.main == other.main && self.cards == other.cards
151    }
152}
153
154impl Document {
155    /// Create a `Document` from a pre-built main card and composable cards.
156    /// `main` must carry `$quill`; composable cards must not.
157    pub fn from_main_and_cards(main: Card, cards: Vec<Card>, warnings: Vec<Diagnostic>) -> Self {
158        debug_assert!(main.quill().is_some(), "main card must carry `$quill`");
159        debug_assert!(
160            cards.iter().all(|c| c.quill().is_none()),
161            "composable cards must not carry `$quill`"
162        );
163        Self {
164            main,
165            cards,
166            warnings,
167        }
168    }
169
170    pub fn from_markdown(markdown: &str) -> Result<Self, ParseError> {
171        assemble::decompose(markdown)
172    }
173
174    pub fn from_markdown_with_warnings(markdown: &str) -> Result<ParseOutput, ParseError> {
175        assemble::decompose_with_warnings(markdown)
176            .map(|(document, warnings)| ParseOutput { document, warnings })
177    }
178
179    pub fn main(&self) -> &Card {
180        &self.main
181    }
182
183    pub fn main_mut(&mut self) -> &mut Card {
184        &mut self.main
185    }
186
187    /// The `$quill` reference from the root block. Always present on parsed documents.
188    pub fn quill_reference(&self) -> QuillReference {
189        self.main
190            .quill()
191            .cloned()
192            .expect("root block's $quill is validated at parse time")
193    }
194
195    pub fn cards(&self) -> &[Card] {
196        &self.cards
197    }
198
199    pub fn cards_mut(&mut self) -> &mut [Card] {
200        &mut self.cards
201    }
202
203    /// Non-fatal warnings from the parse; empty for programmatically built documents.
204    pub fn warnings(&self) -> &[Diagnostic] {
205        &self.warnings
206    }
207
208    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
209        &mut self.cards
210    }
211
212    /// Serialize to the JSON wire shape consumed by backend plates. This is
213    /// the **only** place in `quillmark-core` that produces this shape:
214    ///
215    /// ```json
216    /// {
217    ///   "$quill": "<ref>",
218    ///   "$body": "<global-body>",
219    ///   "$cards": [{ "$kind": "<tag>", "$body": "<card-body>", "<field>": <value>, ... }],
220    ///   "<field>": <value>, ...
221    /// }
222    /// ```
223    ///
224    /// `$`-prefixed keys carry document-level metadata (quill ref, body
225    /// text, card list, card kind). User payload fields stay flat at the
226    /// root — they cannot collide with `$` keys because field names must
227    /// match `[a-z_][a-z0-9_]*`.
228    pub fn to_plate_json(&self) -> serde_json::Value {
229        let mut map = serde_json::Map::new();
230
231        map.insert(
232            "$quill".to_string(),
233            serde_json::Value::String(self.quill_reference().to_string()),
234        );
235
236        map.insert(
237            "$body".to_string(),
238            serde_json::Value::String(self.main.body.clone()),
239        );
240
241        let cards_array: Vec<serde_json::Value> = self
242            .cards
243            .iter()
244            .map(|card| {
245                let mut card_map = serde_json::Map::new();
246                card_map.insert(
247                    "$kind".to_string(),
248                    serde_json::Value::String(card.kind().unwrap_or("").to_string()),
249                );
250                card_map.insert(
251                    "$body".to_string(),
252                    serde_json::Value::String(card.body.clone()),
253                );
254                for (key, value) in card.payload.iter() {
255                    card_map.insert(key.clone(), value.as_json().clone());
256                }
257                serde_json::Value::Object(card_map)
258            })
259            .collect();
260
261        map.insert("$cards".to_string(), serde_json::Value::Array(cards_array));
262
263        for (key, value) in self.main.payload.iter() {
264            map.insert(key.clone(), value.as_json().clone());
265        }
266
267        serde_json::Value::Object(map)
268    }
269}