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;
35
36pub use dto::{peek_schema_version, StorageError, StoredDocument, SCHEMA_V0_81_0, SCHEMA_V0_82_0};
37pub use edit::EditError;
38pub use meta::{is_valid_kind_name, validate_composable_kind, CardKindError};
39pub use payload::{Payload, PayloadItem};
40
41/// Authoring-format rules for the `~~~` card-yaml markdown surface.
42///
43/// Surfaced verbatim to LLM/MCP consumers (and to CLI / Python bindings via
44/// the same text) so error parity holds — every consumer reads the same
45/// rules. This is the single source of truth; bindings should call into it
46/// rather than re-stating the rules in their own glue.
47pub const FORMAT_RULES: &str = "Document format rules:
48\u{2022} Block opener and closer are EXACTLY `~~~` (three tildes, no info string). The legacy `~~~card-yaml` opener is still accepted but is no longer canonical.
49\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.
50\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>`.
51\u{2022} Reserved `$`-keys: `$quill`, `$kind`, `$id`, `$ext`. User fields use lowercase snake_case.
52\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.
53\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.
54\u{2022} Numbers and booleans MUST be unquoted (`year: 2025`, `pinned: true`); quoting turns them into strings and fails validation.
55\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.";
56
57/// Authoring-ergonomics header that introduces a blueprint to an LLM/MCP
58/// consumer. The `{quill}` placeholder is substituted with the quill name.
59/// Designed to be shown above [`FORMAT_RULES`], which covers field-level
60/// semantics like `; omit-ok` — keep the wording tight here so the two
61/// strings do not duplicate guidance.
62const BLUEPRINT_INSTRUCTION_TEMPLATE: &str =
63    "Fill in the `{quill}` blueprint below: replace each `<must-fill>` sentinel and edit the \
64body prose. Submit the filled markdown as `content` to `create_document`.";
65
66/// Render the blueprint-instruction header with `quill_name` substituted in.
67/// Single source of truth for the prose so every binding shows identical text.
68pub fn blueprint_instruction(quill_name: &str) -> String {
69    BLUEPRINT_INSTRUCTION_TEMPLATE.replace("{quill}", quill_name)
70}
71
72#[cfg(test)]
73mod tests;
74
75/// Parse result with the document and any non-fatal warnings.
76#[derive(Debug)]
77pub struct ParseOutput {
78    pub document: Document,
79    pub warnings: Vec<Diagnostic>,
80}
81
82/// A single card-yaml block (root or composable). `body` is `""` when no
83/// content follows the closing fence; check `card.body().is_empty()`.
84#[derive(Debug, Clone, PartialEq)]
85pub struct Card {
86    payload: Payload,
87    body: String,
88}
89
90impl Card {
91    /// Create a `Card` from its parts without validation. For user-facing
92    /// construction of composable cards use [`Card::new`].
93    pub fn from_parts(payload: Payload, body: String) -> Self {
94        Self { payload, body }
95    }
96
97    pub fn quill(&self) -> Option<&QuillReference> {
98        self.payload.quill()
99    }
100
101    pub fn kind(&self) -> Option<&str> {
102        self.payload.kind()
103    }
104
105    pub fn id(&self) -> Option<&str> {
106        self.payload.id()
107    }
108
109    /// Opaque `$ext` map for out-of-band extension data (UI editor state,
110    /// agent annotations, …). Carried through Markdown and storage DTO
111    /// round-trips; never emitted into the plate JSON consumed by
112    /// backends.
113    pub fn ext(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
114        self.payload.ext()
115    }
116
117    pub fn payload(&self) -> &Payload {
118        &self.payload
119    }
120
121    pub fn payload_mut(&mut self) -> &mut Payload {
122        &mut self.payload
123    }
124
125    pub fn body(&self) -> &str {
126        &self.body
127    }
128
129    pub(crate) fn overwrite_body(&mut self, body: String) {
130        self.body = body;
131    }
132}
133
134/// A fully-parsed Quillmark document. Serde routes through [`StoredDocument`];
135/// for the plate wire shape see [`Document::to_plate_json`].
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(into = "StoredDocument", try_from = "StoredDocument")]
138pub struct Document {
139    main: Card,
140    cards: Vec<Card>,
141    warnings: Vec<Diagnostic>,
142}
143
144// `warnings` are parse-time observations and vary on round-trips; equality
145// covers only structural content (`main` and `cards`).
146impl PartialEq for Document {
147    fn eq(&self, other: &Self) -> bool {
148        self.main == other.main && self.cards == other.cards
149    }
150}
151
152impl Document {
153    /// Create a `Document` from a pre-built main card and composable cards.
154    /// `main` must carry `$quill`; composable cards must not.
155    pub fn from_main_and_cards(main: Card, cards: Vec<Card>, warnings: Vec<Diagnostic>) -> Self {
156        debug_assert!(main.quill().is_some(), "main card must carry `$quill`");
157        debug_assert!(
158            cards.iter().all(|c| c.quill().is_none()),
159            "composable cards must not carry `$quill`"
160        );
161        Self {
162            main,
163            cards,
164            warnings,
165        }
166    }
167
168    pub fn from_markdown(markdown: &str) -> Result<Self, ParseError> {
169        assemble::decompose(markdown)
170    }
171
172    pub fn from_markdown_with_warnings(markdown: &str) -> Result<ParseOutput, ParseError> {
173        assemble::decompose_with_warnings(markdown)
174            .map(|(document, warnings)| ParseOutput { document, warnings })
175    }
176
177    pub fn main(&self) -> &Card {
178        &self.main
179    }
180
181    pub fn main_mut(&mut self) -> &mut Card {
182        &mut self.main
183    }
184
185    /// The `$quill` reference from the root block. Always present on parsed documents.
186    pub fn quill_reference(&self) -> QuillReference {
187        self.main
188            .quill()
189            .cloned()
190            .expect("root block's $quill is validated at parse time")
191    }
192
193    pub fn cards(&self) -> &[Card] {
194        &self.cards
195    }
196
197    pub fn cards_mut(&mut self) -> &mut [Card] {
198        &mut self.cards
199    }
200
201    /// Non-fatal warnings from the parse; empty for programmatically built documents.
202    pub fn warnings(&self) -> &[Diagnostic] {
203        &self.warnings
204    }
205
206    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
207        &mut self.cards
208    }
209
210    /// Serialize to the JSON wire shape consumed by backend plates. This is
211    /// the **only** place in `quillmark-core` that produces this shape:
212    ///
213    /// ```json
214    /// {
215    ///   "$quill": "<ref>",
216    ///   "$body": "<global-body>",
217    ///   "$cards": [{ "$kind": "<tag>", "$body": "<card-body>", "<field>": <value>, ... }],
218    ///   "<field>": <value>, ...
219    /// }
220    /// ```
221    ///
222    /// `$`-prefixed keys carry document-level metadata (quill ref, body
223    /// text, card list, card kind). User payload fields stay flat at the
224    /// root — they cannot collide with `$` keys because field names must
225    /// match `[a-z_][a-z0-9_]*`.
226    pub fn to_plate_json(&self) -> serde_json::Value {
227        let mut map = serde_json::Map::new();
228
229        map.insert(
230            "$quill".to_string(),
231            serde_json::Value::String(self.quill_reference().to_string()),
232        );
233
234        map.insert(
235            "$body".to_string(),
236            serde_json::Value::String(self.main.body.clone()),
237        );
238
239        let cards_array: Vec<serde_json::Value> = self
240            .cards
241            .iter()
242            .map(|card| {
243                let mut card_map = serde_json::Map::new();
244                card_map.insert(
245                    "$kind".to_string(),
246                    serde_json::Value::String(card.kind().unwrap_or("").to_string()),
247                );
248                card_map.insert(
249                    "$body".to_string(),
250                    serde_json::Value::String(card.body.clone()),
251                );
252                for (key, value) in card.payload.iter() {
253                    card_map.insert(key.clone(), value.as_json().clone());
254                }
255                serde_json::Value::Object(card_map)
256            })
257            .collect();
258
259        map.insert("$cards".to_string(), serde_json::Value::Array(cards_array));
260
261        for (key, value) in self.main.payload.iter() {
262            map.insert(key.clone(), value.as_json().clone());
263        }
264
265        serde_json::Value::Object(map)
266    }
267}