Skip to main content

quillmark_core/document/
dto.rs

1//! Versioned, storage-stable serialization for [`Document`].
2//!
3//! [`Document`] and its component types (`Card`, `Payload`, …) track the
4//! evolving Quillmark model; their in-memory layout is an internal detail
5//! and is deliberately *not* serialized directly. To persist a document —
6//! e.g. in a database — it is converted to a [`StoredDocument`]: a versioned
7//! envelope whose wire format is frozen per schema version.
8//!
9//! `Document` itself serializes through this envelope via
10//! `#[serde(into / try_from)]`, so the ordinary serde entry points produce
11//! and consume the versioned form transparently.
12//!
13//! ## Schema versions
14//!
15//! - **`quillmark/document@0.82.0`** — current. Encodes the unified
16//!   [`Payload`] item list (typed `$` entries, user fields, and comments
17//!   interleaved in source order). This is the format newly serialized
18//!   documents use.
19//! - **`quillmark/document@0.81.0`** — legacy. Encodes the pre-unification
20//!   shape with a separate `sentinel` (the typed `$quill` / `$kind`) and a
21//!   `frontmatter` item list (user fields + comments only). Kept read-only
22//!   so documents written by `0.81.x` consumers still load; on
23//!   reconstruction it is migrated to the V0_82_0 in-memory shape.
24//!
25//! The canonical design — including the step-by-step procedure for adding
26//! a schema version — is `prose/canon/DOCUMENT_STORAGE.md`.
27
28// Storage DTO types are named after the crate version that fixed their shape
29// (e.g. `DocumentV0_81_0`); the underscores are intentional.
30#![allow(non_camel_case_types)]
31
32use std::str::FromStr;
33
34use serde::{Deserialize, Serialize};
35
36use super::meta::validate_composable_kind;
37use super::payload::{Payload, PayloadItem};
38use super::prescan::{CommentPathSegment, NestedComment};
39use super::{Card, Document};
40use crate::value::QuillValue;
41use crate::version::QuillReference;
42
43/// Schema version for the V0_81_0 wire format. Documents written by
44/// `quillmark-core` `0.81.x` carry this tag and are migrated forward on
45/// read.
46pub const SCHEMA_V0_81_0: &str = "quillmark/document@0.81.0";
47
48/// Schema version for the V0_82_0 wire format. Newly serialized documents
49/// carry this tag.
50pub const SCHEMA_V0_82_0: &str = "quillmark/document@0.82.0";
51
52/// Read the `schema` field from a raw storage DTO payload without
53/// performing full deserialization.
54///
55/// Returns `None` if `json` is not valid JSON, is not an object, or has no
56/// `schema` field. The returned string is **not** validated against the
57/// set of supported schema versions — callers use this to distinguish
58/// "unknown future version" from "corrupt payload" when [`Document`]
59/// deserialization fails.
60pub fn peek_schema_version(json: &str) -> Option<String> {
61    #[derive(Deserialize)]
62    struct Peek {
63        schema: Option<String>,
64    }
65    serde_json::from_str::<Peek>(json).ok()?.schema
66}
67
68/// Versioned envelope for a persisted [`Document`].
69///
70/// The `schema` field selects the payload version. Deserialization
71/// dispatches on it; unknown values are rejected. New schema versions are
72/// added as new variants, leaving existing ones byte-stable.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[serde(tag = "schema")]
75pub enum StoredDocument {
76    /// Current (V0_82_0) document model — unified payload items.
77    #[serde(rename = "quillmark/document@0.82.0")]
78    V0_82_0(DocumentV0_82_0),
79    /// Legacy (V0_81_0) document model — separate sentinel + frontmatter.
80    /// Read-only; migrated on reconstruction.
81    #[serde(rename = "quillmark/document@0.81.0")]
82    V0_81_0(DocumentV0_81_0),
83}
84
85/// Failure while reconstructing a [`Document`] from a [`StoredDocument`].
86///
87/// The taxonomy is intentionally minimal: only [`Self::InvalidQuillReference`]
88/// is typed, because that is the one error a non-malicious caller hits at
89/// the document/quill boundary. Every other defect — wrong-role card,
90/// invalid kind, duplicate key, too many fields — can only arise from a
91/// hand-crafted storage DTO (the markdown parser already rejects them)
92/// and is reported through [`Self::Malformed`] with a descriptive message.
93#[derive(Debug, Clone, PartialEq)]
94pub enum StorageError {
95    /// A stored quill reference string could not be parsed.
96    InvalidQuillReference {
97        /// The offending string.
98        value: String,
99        /// Parser explanation.
100        reason: String,
101    },
102    /// The stored document is structurally malformed in a way the markdown
103    /// parser would reject. The message describes the specific defect.
104    Malformed(String),
105}
106
107impl std::fmt::Display for StorageError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            StorageError::InvalidQuillReference { value, reason } => {
111                write!(f, "invalid quill reference {value:?}: {reason}")
112            }
113            StorageError::Malformed(msg) => f.write_str(msg),
114        }
115    }
116}
117
118impl std::error::Error for StorageError {}
119
120// ─── V0_82_0 wire format (current) ────────────────────────────────────────────
121
122/// Frozen `0.82.0` representation of a [`Document`].
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct DocumentV0_82_0 {
125    pub main: CardV0_82_0,
126    #[serde(default)]
127    pub cards: Vec<CardV0_82_0>,
128}
129
130/// Frozen `0.82.0` representation of a [`Card`].
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct CardV0_82_0 {
133    pub payload: PayloadV0_82_0,
134    #[serde(default)]
135    pub body: String,
136}
137
138/// Frozen `0.82.0` representation of a [`Payload`].
139#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
140pub struct PayloadV0_82_0 {
141    #[serde(default)]
142    pub items: Vec<PayloadItemV0_82_0>,
143    #[serde(default)]
144    pub nested_comments: Vec<NestedCommentV0_82_0>,
145}
146
147/// Frozen `0.82.0` representation of a unified payload item.
148///
149/// Discriminator field is `type` to keep it unambiguous next to the `$kind`
150/// metadata semantic (a `kind` discriminator would yield `{"kind":"kind"}`
151/// for `$kind` entries).
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "lowercase")]
154pub enum PayloadItemV0_82_0 {
155    /// `$quill` system metadata — the quill reference string.
156    Quill { value: String },
157    /// `$kind` system metadata.
158    Kind { value: String },
159    /// `$id` system metadata.
160    Id { value: String },
161    /// `$ext` system metadata — an opaque mapping carrying out-of-band
162    /// extension data (UI editor state, agent annotations, …). Never
163    /// emitted into the plate JSON; round-trips through the DTO unchanged.
164    Ext {
165        value: serde_json::Map<String, serde_json::Value>,
166    },
167    /// A user-defined field.
168    Field {
169        key: String,
170        value: serde_json::Value,
171        #[serde(default)]
172        fill: bool,
173    },
174    /// A YAML comment.
175    Comment {
176        text: String,
177        #[serde(default)]
178        inline: bool,
179    },
180}
181
182/// Frozen `0.82.0` representation of a [`NestedComment`].
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct NestedCommentV0_82_0 {
185    pub container_path: Vec<CommentPathSegmentV0_82_0>,
186    pub position: usize,
187    pub text: String,
188    pub inline: bool,
189}
190
191/// Frozen `0.82.0` representation of a [`CommentPathSegment`].
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub enum CommentPathSegmentV0_82_0 {
194    Key(String),
195    Index(usize),
196}
197
198// ─── Document ↔ V0_82_0 (live conversion) ─────────────────────────────────────
199
200impl From<Document> for StoredDocument {
201    fn from(doc: Document) -> Self {
202        StoredDocument::V0_82_0(DocumentV0_82_0::from(&doc))
203    }
204}
205
206impl From<&Document> for DocumentV0_82_0 {
207    fn from(doc: &Document) -> Self {
208        DocumentV0_82_0 {
209            main: CardV0_82_0::from(doc.main()),
210            cards: doc.cards().iter().map(CardV0_82_0::from).collect(),
211        }
212    }
213}
214
215impl From<&Card> for CardV0_82_0 {
216    fn from(card: &Card) -> Self {
217        CardV0_82_0 {
218            payload: PayloadV0_82_0::from(card.payload()),
219            body: card.body().to_string(),
220        }
221    }
222}
223
224impl From<&Payload> for PayloadV0_82_0 {
225    fn from(payload: &Payload) -> Self {
226        // The wire format keeps `nested_comments` as a flat sidecar at
227        // the payload level. The in-memory model carries them per-item
228        // with relative paths, so we re-prefix and flatten here.
229        let nested_comments = payload
230            .flat_nested_comments()
231            .iter()
232            .map(NestedCommentV0_82_0::from)
233            .collect();
234        PayloadV0_82_0 {
235            items: payload
236                .items()
237                .iter()
238                .map(PayloadItemV0_82_0::from)
239                .collect(),
240            nested_comments,
241        }
242    }
243}
244
245impl From<&PayloadItem> for PayloadItemV0_82_0 {
246    fn from(item: &PayloadItem) -> Self {
247        match item {
248            PayloadItem::Quill { reference } => PayloadItemV0_82_0::Quill {
249                value: reference.to_string(),
250            },
251            PayloadItem::Kind { value } => PayloadItemV0_82_0::Kind {
252                value: value.clone(),
253            },
254            PayloadItem::Id { value } => PayloadItemV0_82_0::Id {
255                value: value.clone(),
256            },
257            // The wire `Ext` variant has no `nested_comments` field —
258            // its comments live in the payload-level sidecar after
259            // `flat_nested_comments` re-prefixes them with `$ext`.
260            PayloadItem::Ext { value, .. } => PayloadItemV0_82_0::Ext {
261                value: value.clone(),
262            },
263            // Same story for `Field` — the per-item `nested_comments`
264            // are flattened onto the payload-level sidecar with the
265            // field's key as the leading path segment.
266            PayloadItem::Field {
267                key, value, fill, ..
268            } => PayloadItemV0_82_0::Field {
269                key: key.clone(),
270                value: value.as_json().clone(),
271                fill: *fill,
272            },
273            PayloadItem::Comment { text, inline } => PayloadItemV0_82_0::Comment {
274                text: text.clone(),
275                inline: *inline,
276            },
277        }
278    }
279}
280
281impl From<&NestedComment> for NestedCommentV0_82_0 {
282    fn from(nc: &NestedComment) -> Self {
283        NestedCommentV0_82_0 {
284            container_path: nc
285                .container_path
286                .iter()
287                .map(CommentPathSegmentV0_82_0::from)
288                .collect(),
289            position: nc.position,
290            text: nc.text.clone(),
291            inline: nc.inline,
292        }
293    }
294}
295
296impl From<&CommentPathSegment> for CommentPathSegmentV0_82_0 {
297    fn from(seg: &CommentPathSegment) -> Self {
298        match seg {
299            CommentPathSegment::Key(k) => CommentPathSegmentV0_82_0::Key(k.clone()),
300            CommentPathSegment::Index(i) => CommentPathSegmentV0_82_0::Index(*i),
301        }
302    }
303}
304
305impl TryFrom<StoredDocument> for Document {
306    type Error = StorageError;
307
308    fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
309        match stored {
310            StoredDocument::V0_82_0(payload) => Document::try_from(payload),
311            StoredDocument::V0_81_0(payload) => Document::try_from(DocumentV0_82_0::from(payload)),
312        }
313    }
314}
315
316impl TryFrom<DocumentV0_82_0> for Document {
317    type Error = StorageError;
318
319    fn try_from(payload: DocumentV0_82_0) -> Result<Self, Self::Error> {
320        let main = Card::try_from(payload.main)?;
321        if main.quill().is_none() {
322            return Err(StorageError::Malformed(
323                "main card must carry a $quill entry".into(),
324            ));
325        }
326        let cards = payload
327            .cards
328            .into_iter()
329            .map(Card::try_from)
330            .collect::<Result<Vec<_>, _>>()?;
331        for card in &cards {
332            if card.quill().is_some() {
333                return Err(StorageError::Malformed(
334                    "composable cards must not carry a $quill entry".into(),
335                ));
336            }
337            if let Some(kind) = card.kind() {
338                match validate_composable_kind(kind) {
339                    Ok(()) => {}
340                    Err(super::meta::CardKindError::InvalidName) => {
341                        return Err(StorageError::Malformed(format!(
342                            "invalid composable card kind {kind:?}: must match \
343                             [a-z_][a-z0-9_]*"
344                        )));
345                    }
346                    Err(super::meta::CardKindError::Reserved) => {
347                        return Err(StorageError::Malformed(format!(
348                            "composable card kind {kind:?} is reserved (root only)"
349                        )));
350                    }
351                }
352            }
353        }
354        Ok(Document::from_main_and_cards(main, cards, Vec::new()))
355    }
356}
357
358impl TryFrom<CardV0_82_0> for Card {
359    type Error = StorageError;
360
361    fn try_from(card: CardV0_82_0) -> Result<Self, Self::Error> {
362        let payload = Payload::try_from(card.payload)?;
363        validate_dto_payload(&payload)?;
364        Ok(Card::from_parts(payload, card.body))
365    }
366}
367
368impl TryFrom<PayloadV0_82_0> for Payload {
369    type Error = StorageError;
370
371    fn try_from(p: PayloadV0_82_0) -> Result<Self, Self::Error> {
372        let mut items = Vec::with_capacity(p.items.len());
373        for item in p.items {
374            items.push(PayloadItem::try_from(item)?);
375        }
376        let nested = p
377            .nested_comments
378            .into_iter()
379            .map(NestedComment::from)
380            .collect();
381        // Partition the flat wire-format sidecar onto the matching
382        // Field / Ext items (paths become relative to the owning value).
383        Ok(Payload::from_items_with_flat_nested(items, nested))
384    }
385}
386
387impl TryFrom<PayloadItemV0_82_0> for PayloadItem {
388    type Error = StorageError;
389
390    fn try_from(item: PayloadItemV0_82_0) -> Result<Self, Self::Error> {
391        Ok(match item {
392            PayloadItemV0_82_0::Quill { value } => {
393                let reference = QuillReference::from_str(&value).map_err(|reason| {
394                    StorageError::InvalidQuillReference {
395                        value: value.clone(),
396                        reason,
397                    }
398                })?;
399                PayloadItem::Quill { reference }
400            }
401            PayloadItemV0_82_0::Kind { value } => PayloadItem::Kind { value },
402            PayloadItemV0_82_0::Id { value } => PayloadItem::Id { value },
403            PayloadItemV0_82_0::Ext { value } => PayloadItem::Ext {
404                value,
405                nested_comments: Vec::new(),
406            },
407            PayloadItemV0_82_0::Field { key, value, fill } => PayloadItem::Field {
408                key,
409                value: QuillValue::from_json(value),
410                fill,
411                nested_comments: Vec::new(),
412            },
413            PayloadItemV0_82_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
414        })
415    }
416}
417
418impl From<NestedCommentV0_82_0> for NestedComment {
419    fn from(nc: NestedCommentV0_82_0) -> Self {
420        NestedComment {
421            container_path: nc
422                .container_path
423                .into_iter()
424                .map(CommentPathSegment::from)
425                .collect(),
426            position: nc.position,
427            text: nc.text,
428            inline: nc.inline,
429        }
430    }
431}
432
433impl From<CommentPathSegmentV0_82_0> for CommentPathSegment {
434    fn from(seg: CommentPathSegmentV0_82_0) -> Self {
435        match seg {
436            CommentPathSegmentV0_82_0::Key(k) => CommentPathSegment::Key(k),
437            CommentPathSegmentV0_82_0::Index(i) => CommentPathSegment::Index(i),
438        }
439    }
440}
441
442/// Reject a payload no markdown-parsed `Document` could produce: too many
443/// fields or a duplicate user-field key. The markdown parser already
444/// rejects both; this only guards hand-crafted storage DTOs.
445fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
446    if payload.len() > crate::error::MAX_FIELD_COUNT {
447        return Err(StorageError::Malformed(format!(
448            "card has {} user fields, exceeding the maximum of {}",
449            payload.len(),
450            crate::error::MAX_FIELD_COUNT
451        )));
452    }
453    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
454    for key in payload.keys() {
455        if !seen.insert(key.as_str()) {
456            return Err(StorageError::Malformed(format!(
457                "duplicate user-field key {key:?}"
458            )));
459        }
460    }
461    Ok(())
462}
463
464// ─── V0_81_0 wire format (legacy, read-only) ──────────────────────────────────
465
466/// Frozen `0.81.0` representation of a [`Document`].
467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
468pub struct DocumentV0_81_0 {
469    pub main: CardV0_81_0,
470    #[serde(default)]
471    pub cards: Vec<CardV0_81_0>,
472}
473
474/// Frozen `0.81.0` representation of a [`Card`].
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476pub struct CardV0_81_0 {
477    pub sentinel: SentinelV0_81_0,
478    #[serde(default)]
479    pub frontmatter: FrontmatterV0_81_0,
480    #[serde(default)]
481    pub body: String,
482}
483
484/// Frozen `0.81.0` representation of a card discriminator (sentinel).
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486#[serde(tag = "kind", rename_all = "lowercase")]
487pub enum SentinelV0_81_0 {
488    Main { quill: String },
489    Card { tag: String },
490}
491
492/// Frozen `0.81.0` representation of a card payload (user fields only).
493#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
494pub struct FrontmatterV0_81_0 {
495    #[serde(default)]
496    pub items: Vec<FrontmatterItemV0_81_0>,
497    #[serde(default)]
498    pub nested_comments: Vec<NestedCommentV0_81_0>,
499}
500
501/// Frozen `0.81.0` representation of a payload item (no `$` entries).
502#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
503#[serde(tag = "kind", rename_all = "lowercase")]
504pub enum FrontmatterItemV0_81_0 {
505    Field {
506        key: String,
507        value: serde_json::Value,
508        #[serde(default)]
509        fill: bool,
510    },
511    Comment {
512        text: String,
513        #[serde(default)]
514        inline: bool,
515    },
516}
517
518/// Frozen `0.81.0` representation of a [`NestedComment`].
519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
520pub struct NestedCommentV0_81_0 {
521    pub container_path: Vec<CommentPathSegmentV0_81_0>,
522    pub position: usize,
523    pub text: String,
524    pub inline: bool,
525}
526
527/// Frozen `0.81.0` representation of a [`CommentPathSegment`].
528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
529pub enum CommentPathSegmentV0_81_0 {
530    Key(String),
531    Index(usize),
532}
533
534// ─── V0_81_0 → V0_82_0 migration ──────────────────────────────────────────────
535//
536// The migration is purely structural — it converts the old separate
537// `sentinel + frontmatter` shape into a unified items list, then defers to
538// the V0_82_0 → Document path for typed validation. Quill-reference
539// validity is checked once, on the V0_82_0 side.
540
541impl From<DocumentV0_81_0> for DocumentV0_82_0 {
542    fn from(d: DocumentV0_81_0) -> Self {
543        DocumentV0_82_0 {
544            main: CardV0_82_0::from(d.main),
545            cards: d.cards.into_iter().map(CardV0_82_0::from).collect(),
546        }
547    }
548}
549
550impl From<CardV0_81_0> for CardV0_82_0 {
551    fn from(c: CardV0_81_0) -> Self {
552        let mut items: Vec<PayloadItemV0_82_0> = Vec::new();
553
554        // The sentinel migrates to a prelude of typed `$` entries. The
555        // `Main` variant implies `$kind: main` (spec §3.3); the
556        // reconstructed model carries the canonical kind so the markdown
557        // emit produces a parseable document.
558        match c.sentinel {
559            SentinelV0_81_0::Main { quill } => {
560                items.push(PayloadItemV0_82_0::Quill { value: quill });
561                items.push(PayloadItemV0_82_0::Kind {
562                    value: "main".into(),
563                });
564            }
565            SentinelV0_81_0::Card { tag } => {
566                items.push(PayloadItemV0_82_0::Kind { value: tag });
567            }
568        }
569
570        // Append user fields and comments in their original order. V0_81_0
571        // didn't track `$`-line comments separately, so the comment
572        // positions migrate as-is (after the `$` prelude).
573        for item in c.frontmatter.items {
574            items.push(match item {
575                FrontmatterItemV0_81_0::Field { key, value, fill } => {
576                    PayloadItemV0_82_0::Field { key, value, fill }
577                }
578                FrontmatterItemV0_81_0::Comment { text, inline } => {
579                    PayloadItemV0_82_0::Comment { text, inline }
580                }
581            });
582        }
583
584        let nested_comments = c
585            .frontmatter
586            .nested_comments
587            .into_iter()
588            .map(NestedCommentV0_82_0::from)
589            .collect();
590
591        CardV0_82_0 {
592            payload: PayloadV0_82_0 {
593                items,
594                nested_comments,
595            },
596            body: c.body,
597        }
598    }
599}
600
601impl From<NestedCommentV0_81_0> for NestedCommentV0_82_0 {
602    fn from(nc: NestedCommentV0_81_0) -> Self {
603        NestedCommentV0_82_0 {
604            container_path: nc
605                .container_path
606                .into_iter()
607                .map(CommentPathSegmentV0_82_0::from)
608                .collect(),
609            position: nc.position,
610            text: nc.text,
611            inline: nc.inline,
612        }
613    }
614}
615
616impl From<CommentPathSegmentV0_81_0> for CommentPathSegmentV0_82_0 {
617    fn from(seg: CommentPathSegmentV0_81_0) -> Self {
618        match seg {
619            CommentPathSegmentV0_81_0::Key(k) => CommentPathSegmentV0_82_0::Key(k),
620            CommentPathSegmentV0_81_0::Index(i) => CommentPathSegmentV0_82_0::Index(i),
621        }
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    fn sample() -> Document {
630        Document::from_markdown(
631            "\
632~~~card-yaml
633$quill: usaf_memo@0.1
634$kind: main
635# a top-level comment
636memo_for:
637  - ORG/SYMBOL # inline comment inside a sequence
638date: 2504-10-05
639subject: !fill Subject of the Memorandum
640~~~
641
642The body of the memorandum.
643
644~~~card-yaml
645$kind: indorsement
646for: ORG/SYMBOL
647from: ORG/SYMBOL
648~~~
649
650This body and the metadata above are an indorsement card.
651",
652        )
653        .unwrap()
654    }
655
656    #[test]
657    fn round_trips_through_serde_json() {
658        let doc = sample();
659        let json = serde_json::to_string(&doc).unwrap();
660        let restored: Document = serde_json::from_str(&json).unwrap();
661        assert_eq!(doc, restored);
662        assert_eq!(doc.to_markdown(), restored.to_markdown());
663    }
664
665    #[test]
666    fn serialization_uses_v0_82_0_schema() {
667        let doc = sample();
668        let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
669        assert_eq!(value["schema"], SCHEMA_V0_82_0);
670    }
671
672    #[test]
673    fn root_kind_is_main_through_round_trip() {
674        let doc = Document::from_markdown(
675            "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
676        )
677        .unwrap();
678        assert_eq!(doc.main().kind(), Some("main"));
679        let restored: Document =
680            serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
681        assert_eq!(doc, restored);
682        assert_eq!(restored.main().kind(), Some("main"));
683    }
684
685    #[test]
686    fn serialization_is_byte_deterministic() {
687        // Re-serialization stability, round-trip stability, and
688        // path-independence — checked together because consumers
689        // content-hash the result.
690        let doc = sample();
691        let first = serde_json::to_string(&doc).unwrap();
692        let second = serde_json::to_string(&doc).unwrap();
693        assert_eq!(first, second, "to_string must be deterministic");
694        let restored: Document = serde_json::from_str(&first).unwrap();
695        let third = serde_json::to_string(&restored).unwrap();
696        assert_eq!(first, third, "byte-equality must survive a round-trip");
697    }
698
699    #[test]
700    fn rejects_unknown_schema_version() {
701        let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
702        assert!(serde_json::from_str::<Document>(json).is_err());
703    }
704
705    #[test]
706    fn peek_schema_version_reads_field_without_full_parse() {
707        let doc = sample();
708        let json = serde_json::to_string(&doc).unwrap();
709        assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_82_0));
710
711        // Unknown future version: peek still succeeds.
712        let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
713        assert_eq!(
714            peek_schema_version(future).as_deref(),
715            Some("quillmark/document@0.99.0")
716        );
717        assert_eq!(peek_schema_version("not json"), None);
718        assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
719    }
720
721    #[test]
722    fn comment_on_dollar_line_round_trips() {
723        // The headline case the unification enables: a `$kind` line with an
724        // inline trailing comment survives a JSON round-trip.
725        let src = "\
726~~~card-yaml
727$quill: q@1.0
728$kind: main # required for root
729title: Hi
730~~~
731";
732        let doc = Document::from_markdown(src).unwrap();
733        let json = serde_json::to_string(&doc).unwrap();
734        let restored: Document = serde_json::from_str(&json).unwrap();
735        assert_eq!(doc, restored);
736        // And the emitted markdown carries the comment back on the `$kind` line.
737        assert!(restored
738            .to_markdown()
739            .contains("$kind: main # required for root"));
740    }
741
742    #[test]
743    fn v0_81_0_payload_loads_via_migration() {
744        let json = r#"{
745            "schema": "quillmark/document@0.81.0",
746            "main": {
747                "sentinel": {"kind": "main", "quill": "usaf_memo@0.1"},
748                "frontmatter": {
749                    "items": [{"kind": "field", "key": "title", "value": "Hello"}]
750                },
751                "body": "Body."
752            },
753            "cards": []
754        }"#;
755        let doc: Document = serde_json::from_str(json).unwrap();
756        assert_eq!(doc.main().kind(), Some("main"));
757        assert_eq!(doc.quill_reference().to_string(), "usaf_memo@0.1");
758        assert_eq!(
759            doc.main().payload().get("title").unwrap().as_str(),
760            Some("Hello")
761        );
762    }
763
764    #[test]
765    fn v0_81_0_with_composable_card_migrates() {
766        let json = r#"{
767            "schema": "quillmark/document@0.81.0",
768            "main": {
769                "sentinel": {"kind": "main", "quill": "q@1.0"},
770                "frontmatter": {"items": []},
771                "body": ""
772            },
773            "cards": [
774                {
775                    "sentinel": {"kind": "card", "tag": "indorsement"},
776                    "frontmatter": {"items": [{"kind": "field", "key": "for", "value": "X"}]},
777                    "body": "C body"
778                }
779            ]
780        }"#;
781        let doc: Document = serde_json::from_str(json).unwrap();
782        assert_eq!(doc.cards().len(), 1);
783        assert_eq!(doc.cards()[0].kind(), Some("indorsement"));
784        assert_eq!(
785            doc.cards()[0].payload().get("for").unwrap().as_str(),
786            Some("X")
787        );
788    }
789
790    #[test]
791    fn rejects_main_card_without_quill() {
792        let json = r#"{
793            "schema": "quillmark/document@0.82.0",
794            "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
795            "cards": []
796        }"#;
797        let err = serde_json::from_str::<Document>(json).unwrap_err();
798        assert!(err.to_string().contains("$quill"));
799    }
800
801    #[test]
802    fn rejects_composable_card_tagged_main() {
803        let json = r#"{
804            "schema": "quillmark/document@0.82.0",
805            "main": {
806                "payload": {"items": [
807                    {"type": "quill", "value": "q@1.0"},
808                    {"type": "kind", "value": "main"}
809                ]},
810                "body": ""
811            },
812            "cards": [
813                {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
814            ]
815        }"#;
816        let err = serde_json::from_str::<Document>(json).unwrap_err();
817        assert!(err.to_string().contains("reserved (root only)"));
818    }
819
820    #[test]
821    fn rejects_invalid_quill_reference() {
822        let json = r#"{
823            "schema": "quillmark/document@0.82.0",
824            "main": {
825                "payload": {"items": [
826                    {"type": "quill", "value": "not a valid ref!!"},
827                    {"type": "kind", "value": "main"}
828                ]},
829                "body": ""
830            },
831            "cards": []
832        }"#;
833        let err = serde_json::from_str::<Document>(json).unwrap_err();
834        assert!(err.to_string().contains("invalid quill reference"));
835    }
836
837}