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