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 } => {
404                let as_value = serde_json::Value::Object(value);
405                if crate::value::json_depth_exceeds(
406                    &as_value,
407                    crate::document::limits::MAX_YAML_DEPTH,
408                ) {
409                    return Err(StorageError::Malformed(format!(
410                        "$ext nests deeper than the maximum of {} levels",
411                        crate::document::limits::MAX_YAML_DEPTH
412                    )));
413                }
414                let serde_json::Value::Object(value) = as_value else {
415                    unreachable!("constructed as Object above")
416                };
417                PayloadItem::Ext {
418                    value,
419                    nested_comments: Vec::new(),
420                }
421            }
422            PayloadItemV0_82_0::Field { key, value, fill } => {
423                use super::edit::{validate_field, FieldViolation};
424                validate_field(&key, &value).map_err(|v| {
425                    StorageError::Malformed(match v {
426                        FieldViolation::InvalidName => format!(
427                            "invalid field name {key:?}: must match [a-z_][a-z0-9_]*"
428                        ),
429                        FieldViolation::TooDeep => format!(
430                            "field {key:?} nests deeper than the maximum of {} levels",
431                            crate::document::limits::MAX_YAML_DEPTH
432                        ),
433                    })
434                })?;
435                PayloadItem::Field {
436                    key,
437                    value: QuillValue::from_json(value),
438                    fill,
439                    nested_comments: Vec::new(),
440                }
441            }
442            PayloadItemV0_82_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
443        })
444    }
445}
446
447impl From<NestedCommentV0_82_0> for NestedComment {
448    fn from(nc: NestedCommentV0_82_0) -> Self {
449        NestedComment {
450            container_path: nc
451                .container_path
452                .into_iter()
453                .map(CommentPathSegment::from)
454                .collect(),
455            position: nc.position,
456            text: nc.text,
457            inline: nc.inline,
458        }
459    }
460}
461
462impl From<CommentPathSegmentV0_82_0> for CommentPathSegment {
463    fn from(seg: CommentPathSegmentV0_82_0) -> Self {
464        match seg {
465            CommentPathSegmentV0_82_0::Key(k) => CommentPathSegment::Key(k),
466            CommentPathSegmentV0_82_0::Index(i) => CommentPathSegment::Index(i),
467        }
468    }
469}
470
471/// Reject a payload no markdown-parsed `Document` could produce: too many
472/// fields or a duplicate user-field key. The markdown parser already
473/// rejects both; this only guards hand-crafted storage DTOs.
474fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
475    if payload.len() > crate::error::MAX_FIELD_COUNT {
476        return Err(StorageError::Malformed(format!(
477            "card has {} user fields, exceeding the maximum of {}",
478            payload.len(),
479            crate::error::MAX_FIELD_COUNT
480        )));
481    }
482    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
483    for key in payload.keys() {
484        if !seen.insert(key.as_str()) {
485            return Err(StorageError::Malformed(format!(
486                "duplicate user-field key {key:?}"
487            )));
488        }
489    }
490    Ok(())
491}
492
493// ─── V0_81_0 wire format (legacy, read-only) ──────────────────────────────────
494
495/// Frozen `0.81.0` representation of a [`Document`].
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497pub struct DocumentV0_81_0 {
498    pub main: CardV0_81_0,
499    #[serde(default)]
500    pub cards: Vec<CardV0_81_0>,
501}
502
503/// Frozen `0.81.0` representation of a [`Card`].
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct CardV0_81_0 {
506    pub sentinel: SentinelV0_81_0,
507    #[serde(default)]
508    pub frontmatter: FrontmatterV0_81_0,
509    #[serde(default)]
510    pub body: String,
511}
512
513/// Frozen `0.81.0` representation of a card discriminator (sentinel).
514#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515#[serde(tag = "kind", rename_all = "lowercase")]
516pub enum SentinelV0_81_0 {
517    Main { quill: String },
518    Card { tag: String },
519}
520
521/// Frozen `0.81.0` representation of a card payload (user fields only).
522#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
523pub struct FrontmatterV0_81_0 {
524    #[serde(default)]
525    pub items: Vec<FrontmatterItemV0_81_0>,
526    #[serde(default)]
527    pub nested_comments: Vec<NestedCommentV0_81_0>,
528}
529
530/// Frozen `0.81.0` representation of a payload item (no `$` entries).
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(tag = "kind", rename_all = "lowercase")]
533pub enum FrontmatterItemV0_81_0 {
534    Field {
535        key: String,
536        value: serde_json::Value,
537        #[serde(default)]
538        fill: bool,
539    },
540    Comment {
541        text: String,
542        #[serde(default)]
543        inline: bool,
544    },
545}
546
547/// Frozen `0.81.0` representation of a [`NestedComment`].
548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
549pub struct NestedCommentV0_81_0 {
550    pub container_path: Vec<CommentPathSegmentV0_81_0>,
551    pub position: usize,
552    pub text: String,
553    pub inline: bool,
554}
555
556/// Frozen `0.81.0` representation of a [`CommentPathSegment`].
557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
558pub enum CommentPathSegmentV0_81_0 {
559    Key(String),
560    Index(usize),
561}
562
563// ─── V0_81_0 → V0_82_0 migration ──────────────────────────────────────────────
564//
565// The migration is purely structural — it converts the old separate
566// `sentinel + frontmatter` shape into a unified items list, then defers to
567// the V0_82_0 → Document path for typed validation. Quill-reference
568// validity is checked once, on the V0_82_0 side.
569
570impl From<DocumentV0_81_0> for DocumentV0_82_0 {
571    fn from(d: DocumentV0_81_0) -> Self {
572        DocumentV0_82_0 {
573            main: CardV0_82_0::from(d.main),
574            cards: d.cards.into_iter().map(CardV0_82_0::from).collect(),
575        }
576    }
577}
578
579impl From<CardV0_81_0> for CardV0_82_0 {
580    fn from(c: CardV0_81_0) -> Self {
581        let mut items: Vec<PayloadItemV0_82_0> = Vec::new();
582
583        // The sentinel migrates to a prelude of typed `$` entries. The
584        // `Main` variant implies `$kind: main` (spec §3.3); the
585        // reconstructed model carries the canonical kind so the markdown
586        // emit produces a parseable document.
587        match c.sentinel {
588            SentinelV0_81_0::Main { quill } => {
589                items.push(PayloadItemV0_82_0::Quill { value: quill });
590                items.push(PayloadItemV0_82_0::Kind {
591                    value: "main".into(),
592                });
593            }
594            SentinelV0_81_0::Card { tag } => {
595                items.push(PayloadItemV0_82_0::Kind { value: tag });
596            }
597        }
598
599        // Append user fields and comments in their original order. V0_81_0
600        // didn't track `$`-line comments separately, so the comment
601        // positions migrate as-is (after the `$` prelude).
602        for item in c.frontmatter.items {
603            items.push(match item {
604                FrontmatterItemV0_81_0::Field { key, value, fill } => {
605                    PayloadItemV0_82_0::Field { key, value, fill }
606                }
607                FrontmatterItemV0_81_0::Comment { text, inline } => {
608                    PayloadItemV0_82_0::Comment { text, inline }
609                }
610            });
611        }
612
613        let nested_comments = c
614            .frontmatter
615            .nested_comments
616            .into_iter()
617            .map(NestedCommentV0_82_0::from)
618            .collect();
619
620        CardV0_82_0 {
621            payload: PayloadV0_82_0 {
622                items,
623                nested_comments,
624            },
625            body: c.body,
626        }
627    }
628}
629
630impl From<NestedCommentV0_81_0> for NestedCommentV0_82_0 {
631    fn from(nc: NestedCommentV0_81_0) -> Self {
632        NestedCommentV0_82_0 {
633            container_path: nc
634                .container_path
635                .into_iter()
636                .map(CommentPathSegmentV0_82_0::from)
637                .collect(),
638            position: nc.position,
639            text: nc.text,
640            inline: nc.inline,
641        }
642    }
643}
644
645impl From<CommentPathSegmentV0_81_0> for CommentPathSegmentV0_82_0 {
646    fn from(seg: CommentPathSegmentV0_81_0) -> Self {
647        match seg {
648            CommentPathSegmentV0_81_0::Key(k) => CommentPathSegmentV0_82_0::Key(k),
649            CommentPathSegmentV0_81_0::Index(i) => CommentPathSegmentV0_82_0::Index(i),
650        }
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    fn sample() -> Document {
659        Document::from_markdown(
660            "\
661~~~card-yaml
662$quill: usaf_memo@0.1
663$kind: main
664# a top-level comment
665memo_for:
666  - ORG/SYMBOL # inline comment inside a sequence
667date: 2504-10-05
668subject: !fill Subject of the Memorandum
669~~~
670
671The body of the memorandum.
672
673~~~card-yaml
674$kind: indorsement
675for: ORG/SYMBOL
676from: ORG/SYMBOL
677~~~
678
679This body and the metadata above are an indorsement card.
680",
681        )
682        .unwrap()
683    }
684
685    #[test]
686    fn round_trips_through_serde_json() {
687        let doc = sample();
688        let json = serde_json::to_string(&doc).unwrap();
689        let restored: Document = serde_json::from_str(&json).unwrap();
690        assert_eq!(doc, restored);
691        assert_eq!(doc.to_markdown(), restored.to_markdown());
692    }
693
694    #[test]
695    fn serialization_uses_v0_82_0_schema() {
696        let doc = sample();
697        let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
698        assert_eq!(value["schema"], SCHEMA_V0_82_0);
699    }
700
701    #[test]
702    fn root_kind_is_main_through_round_trip() {
703        let doc = Document::from_markdown(
704            "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
705        )
706        .unwrap();
707        assert_eq!(doc.main().kind(), Some("main"));
708        let restored: Document =
709            serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
710        assert_eq!(doc, restored);
711        assert_eq!(restored.main().kind(), Some("main"));
712    }
713
714    #[test]
715    fn serialization_is_byte_deterministic() {
716        // Re-serialization stability, round-trip stability, and
717        // path-independence — checked together because consumers
718        // content-hash the result.
719        let doc = sample();
720        let first = serde_json::to_string(&doc).unwrap();
721        let second = serde_json::to_string(&doc).unwrap();
722        assert_eq!(first, second, "to_string must be deterministic");
723        let restored: Document = serde_json::from_str(&first).unwrap();
724        let third = serde_json::to_string(&restored).unwrap();
725        assert_eq!(first, third, "byte-equality must survive a round-trip");
726    }
727
728    #[test]
729    fn rejects_unknown_schema_version() {
730        let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
731        assert!(serde_json::from_str::<Document>(json).is_err());
732    }
733
734    #[test]
735    fn peek_schema_version_reads_field_without_full_parse() {
736        let doc = sample();
737        let json = serde_json::to_string(&doc).unwrap();
738        assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_82_0));
739
740        // Unknown future version: peek still succeeds.
741        let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
742        assert_eq!(
743            peek_schema_version(future).as_deref(),
744            Some("quillmark/document@0.99.0")
745        );
746        assert_eq!(peek_schema_version("not json"), None);
747        assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
748    }
749
750    #[test]
751    fn comment_on_dollar_line_round_trips() {
752        // The headline case the unification enables: a `$kind` line with an
753        // inline trailing comment survives a JSON round-trip.
754        let src = "\
755~~~card-yaml
756$quill: q@1.0
757$kind: main # required for root
758title: Hi
759~~~
760";
761        let doc = Document::from_markdown(src).unwrap();
762        let json = serde_json::to_string(&doc).unwrap();
763        let restored: Document = serde_json::from_str(&json).unwrap();
764        assert_eq!(doc, restored);
765        // And the emitted markdown carries the comment back on the `$kind` line.
766        assert!(restored
767            .to_markdown()
768            .contains("$kind: main # required for root"));
769    }
770
771    #[test]
772    fn v0_81_0_payload_loads_via_migration() {
773        let json = r#"{
774            "schema": "quillmark/document@0.81.0",
775            "main": {
776                "sentinel": {"kind": "main", "quill": "usaf_memo@0.1"},
777                "frontmatter": {
778                    "items": [{"kind": "field", "key": "title", "value": "Hello"}]
779                },
780                "body": "Body."
781            },
782            "cards": []
783        }"#;
784        let doc: Document = serde_json::from_str(json).unwrap();
785        assert_eq!(doc.main().kind(), Some("main"));
786        assert_eq!(doc.quill_reference().to_string(), "usaf_memo@0.1");
787        assert_eq!(
788            doc.main().payload().get("title").unwrap().as_str(),
789            Some("Hello")
790        );
791    }
792
793    #[test]
794    fn v0_81_0_with_composable_card_migrates() {
795        let json = r#"{
796            "schema": "quillmark/document@0.81.0",
797            "main": {
798                "sentinel": {"kind": "main", "quill": "q@1.0"},
799                "frontmatter": {"items": []},
800                "body": ""
801            },
802            "cards": [
803                {
804                    "sentinel": {"kind": "card", "tag": "indorsement"},
805                    "frontmatter": {"items": [{"kind": "field", "key": "for", "value": "X"}]},
806                    "body": "C body"
807                }
808            ]
809        }"#;
810        let doc: Document = serde_json::from_str(json).unwrap();
811        assert_eq!(doc.cards().len(), 1);
812        assert_eq!(doc.cards()[0].kind(), Some("indorsement"));
813        assert_eq!(
814            doc.cards()[0].payload().get("for").unwrap().as_str(),
815            Some("X")
816        );
817    }
818
819    #[test]
820    fn rejects_main_card_without_quill() {
821        let json = r#"{
822            "schema": "quillmark/document@0.82.0",
823            "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
824            "cards": []
825        }"#;
826        let err = serde_json::from_str::<Document>(json).unwrap_err();
827        assert!(err.to_string().contains("$quill"));
828    }
829
830    #[test]
831    fn rejects_composable_card_tagged_main() {
832        let json = r#"{
833            "schema": "quillmark/document@0.82.0",
834            "main": {
835                "payload": {"items": [
836                    {"type": "quill", "value": "q@1.0"},
837                    {"type": "kind", "value": "main"}
838                ]},
839                "body": ""
840            },
841            "cards": [
842                {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
843            ]
844        }"#;
845        let err = serde_json::from_str::<Document>(json).unwrap_err();
846        assert!(err.to_string().contains("reserved (root only)"));
847    }
848
849    #[test]
850    fn rejects_invalid_quill_reference() {
851        let json = r#"{
852            "schema": "quillmark/document@0.82.0",
853            "main": {
854                "payload": {"items": [
855                    {"type": "quill", "value": "not a valid ref!!"},
856                    {"type": "kind", "value": "main"}
857                ]},
858                "body": ""
859            },
860            "cards": []
861        }"#;
862        let err = serde_json::from_str::<Document>(json).unwrap_err();
863        assert!(err.to_string().contains("invalid quill reference"));
864    }
865
866}