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.md](https://github.com/quillmark-org/quillmark/blob/main/prose/canon/MARKDOWN.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;
34
35pub use dto::{peek_schema_version, StorageError, StoredDocument, SCHEMA_V0_81_0, SCHEMA_V0_82_0};
36pub use edit::EditError;
37pub use meta::{is_valid_kind_name, validate_composable_kind, CardKindError};
38pub use payload::{Payload, PayloadItem};
39
40#[cfg(test)]
41mod tests;
42
43/// Parse result with the document and any non-fatal warnings.
44#[derive(Debug)]
45pub struct ParseOutput {
46    pub document: Document,
47    pub warnings: Vec<Diagnostic>,
48}
49
50/// A single card-yaml block (root or composable). `body` is `""` when no
51/// content follows the closing fence; check `card.body().is_empty()`.
52#[derive(Debug, Clone, PartialEq)]
53pub struct Card {
54    payload: Payload,
55    body: String,
56}
57
58impl Card {
59    /// Create a `Card` from its parts without validation. For user-facing
60    /// construction of composable cards use [`Card::new`].
61    pub fn from_parts(payload: Payload, body: String) -> Self {
62        Self { payload, body }
63    }
64
65    pub fn quill(&self) -> Option<&QuillReference> {
66        self.payload.quill()
67    }
68
69    pub fn kind(&self) -> Option<&str> {
70        self.payload.kind()
71    }
72
73    pub fn id(&self) -> Option<&str> {
74        self.payload.id()
75    }
76
77    /// Opaque `$ext` map for out-of-band extension data (UI editor state,
78    /// agent annotations, …). Carried through Markdown and storage DTO
79    /// round-trips; never emitted into the plate JSON consumed by
80    /// backends.
81    pub fn ext(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
82        self.payload.ext()
83    }
84
85    pub fn payload(&self) -> &Payload {
86        &self.payload
87    }
88
89    pub fn payload_mut(&mut self) -> &mut Payload {
90        &mut self.payload
91    }
92
93    pub fn body(&self) -> &str {
94        &self.body
95    }
96
97    pub(crate) fn overwrite_body(&mut self, body: String) {
98        self.body = body;
99    }
100}
101
102/// A fully-parsed Quillmark document. Serde routes through [`StoredDocument`];
103/// for the plate wire shape see [`Document::to_plate_json`].
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(into = "StoredDocument", try_from = "StoredDocument")]
106pub struct Document {
107    main: Card,
108    cards: Vec<Card>,
109    warnings: Vec<Diagnostic>,
110}
111
112// `warnings` are parse-time observations and vary on round-trips; equality
113// covers only structural content (`main` and `cards`).
114impl PartialEq for Document {
115    fn eq(&self, other: &Self) -> bool {
116        self.main == other.main && self.cards == other.cards
117    }
118}
119
120impl Document {
121    /// Create a `Document` from a pre-built main card and composable cards.
122    /// `main` must carry `$quill`; composable cards must not.
123    pub fn from_main_and_cards(main: Card, cards: Vec<Card>, warnings: Vec<Diagnostic>) -> Self {
124        debug_assert!(main.quill().is_some(), "main card must carry `$quill`");
125        debug_assert!(
126            cards.iter().all(|c| c.quill().is_none()),
127            "composable cards must not carry `$quill`"
128        );
129        Self {
130            main,
131            cards,
132            warnings,
133        }
134    }
135
136    pub fn from_markdown(markdown: &str) -> Result<Self, ParseError> {
137        assemble::decompose(markdown)
138    }
139
140    pub fn from_markdown_with_warnings(markdown: &str) -> Result<ParseOutput, ParseError> {
141        assemble::decompose_with_warnings(markdown)
142            .map(|(document, warnings)| ParseOutput { document, warnings })
143    }
144
145    pub fn main(&self) -> &Card {
146        &self.main
147    }
148
149    pub fn main_mut(&mut self) -> &mut Card {
150        &mut self.main
151    }
152
153    /// The `$quill` reference from the root block. Always present on parsed documents.
154    pub fn quill_reference(&self) -> QuillReference {
155        self.main
156            .quill()
157            .cloned()
158            .expect("root block's $quill is validated at parse time")
159    }
160
161    pub fn cards(&self) -> &[Card] {
162        &self.cards
163    }
164
165    pub fn cards_mut(&mut self) -> &mut [Card] {
166        &mut self.cards
167    }
168
169    /// Non-fatal warnings from the parse; empty for programmatically built documents.
170    pub fn warnings(&self) -> &[Diagnostic] {
171        &self.warnings
172    }
173
174    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
175        &mut self.cards
176    }
177
178    /// Serialize to the JSON wire shape consumed by backend plates. This is
179    /// the **only** place in `quillmark-core` that produces this shape:
180    ///
181    /// ```json
182    /// {
183    ///   "$quill": "<ref>",
184    ///   "$body": "<global-body>",
185    ///   "$cards": [{ "$kind": "<tag>", "$body": "<card-body>", "<field>": <value>, ... }],
186    ///   "<field>": <value>, ...
187    /// }
188    /// ```
189    ///
190    /// `$`-prefixed keys carry document-level metadata (quill ref, body
191    /// text, card list, card kind). User payload fields stay flat at the
192    /// root — they cannot collide with `$` keys because field names must
193    /// match `[a-z_][a-z0-9_]*`.
194    pub fn to_plate_json(&self) -> serde_json::Value {
195        let mut map = serde_json::Map::new();
196
197        map.insert(
198            "$quill".to_string(),
199            serde_json::Value::String(self.quill_reference().to_string()),
200        );
201
202        map.insert(
203            "$body".to_string(),
204            serde_json::Value::String(self.main.body.clone()),
205        );
206
207        let cards_array: Vec<serde_json::Value> = self
208            .cards
209            .iter()
210            .map(|card| {
211                let mut card_map = serde_json::Map::new();
212                card_map.insert(
213                    "$kind".to_string(),
214                    serde_json::Value::String(card.kind().unwrap_or("").to_string()),
215                );
216                card_map.insert(
217                    "$body".to_string(),
218                    serde_json::Value::String(card.body.clone()),
219                );
220                for (key, value) in card.payload.iter() {
221                    card_map.insert(key.clone(), value.as_json().clone());
222                }
223                serde_json::Value::Object(card_map)
224            })
225            .collect();
226
227        map.insert("$cards".to_string(), serde_json::Value::Array(cards_array));
228
229        for (key, value) in self.main.payload.iter() {
230            map.insert(key.clone(), value.as_json().clone());
231        }
232
233        serde_json::Value::Object(map)
234    }
235}