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.92.0`** — current. The V0_82_0 model plus a
16//!   per-field `nested_fills` list (so `!must_fill` markers nested inside a
17//!   field value survive a storage round-trip) and the `$seed` payload-item
18//!   variant (per-card-kind seed overlays). This is the format newly
19//!   serialized documents use.
20//! - **`quillmark/document@0.82.0`** — legacy. Encodes the unified
21//!   [`Payload`] item list (typed `$` entries, user fields, and comments
22//!   interleaved in source order) but carries top-level fill only and no
23//!   `$seed`. Kept read-only; migrated forward to V0_92_0 on read.
24//! - **`quillmark/document@0.81.0`** — legacy. Encodes the pre-unification
25//!   shape with a separate `sentinel` (the typed `$quill` / `$kind`) and a
26//!   `frontmatter` item list (user fields + comments only). Kept read-only
27//!   so documents written by `0.81.x` consumers still load; on
28//!   reconstruction it is migrated forward (V0_81_0 → V0_82_0 → V0_92_0).
29//!
30//! The canonical design — including the step-by-step procedure for adding
31//! a schema version — is `prose/canon/DOCUMENT_STORAGE.md`.
32
33// Storage DTO types are named after the crate version that fixed their shape
34// (e.g. `DocumentV0_81_0`); the underscores are intentional.
35#![allow(non_camel_case_types)]
36
37use std::str::FromStr;
38
39use serde::{Deserialize, Serialize};
40
41use super::meta::validate_composable_kind;
42use super::payload::{MetaKey, Payload, PayloadItem};
43use super::prescan::{CommentPathSegment, NestedComment};
44use super::{Card, Document};
45use crate::value::QuillValue;
46use crate::version::QuillReference;
47
48/// Schema version for the V0_92_0 wire format. Newly serialized documents
49/// carry this tag. Adds per-field `nested_fills` (so `!must_fill` markers
50/// nested inside a field value survive a storage round-trip) and the `$seed`
51/// payload-item variant.
52pub const SCHEMA_V0_92_0: &str = "quillmark/document@0.92.0";
53
54/// Read the `schema` field from a raw storage DTO payload without
55/// performing full deserialization.
56///
57/// Returns `None` if `json` is not valid JSON, is not an object, or has no
58/// `schema` field. The returned string is **not** validated against the
59/// set of supported schema versions — callers use this to distinguish
60/// "unknown future version" from "corrupt payload" when [`Document`]
61/// deserialization fails.
62pub fn peek_schema_version(json: &str) -> Option<String> {
63    #[derive(Deserialize)]
64    struct Peek {
65        schema: Option<String>,
66    }
67    serde_json::from_str::<Peek>(json).ok()?.schema
68}
69
70/// Versioned envelope for a persisted [`Document`].
71///
72/// The `schema` field selects the payload version. Deserialization
73/// dispatches on it; unknown values are rejected. New schema versions are
74/// added as new variants, leaving existing ones byte-stable.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(tag = "schema")]
77pub enum StoredDocument {
78    /// Current (V0_92_0) document model — unified payload items with
79    /// per-field nested fill paths and `$seed`.
80    #[serde(rename = "quillmark/document@0.92.0")]
81    V0_92_0(DocumentV0_92_0),
82    /// Legacy (V0_82_0) document model — unified payload items, top-level
83    /// fill only and no `$seed`. Read-only; migrated on reconstruction.
84    #[serde(rename = "quillmark/document@0.82.0")]
85    V0_82_0(DocumentV0_82_0),
86    /// Legacy (V0_81_0) document model — separate sentinel + frontmatter.
87    /// Read-only; migrated on reconstruction.
88    #[serde(rename = "quillmark/document@0.81.0")]
89    V0_81_0(DocumentV0_81_0),
90}
91
92/// Failure while reconstructing a [`Document`] from a [`StoredDocument`].
93///
94/// The taxonomy is intentionally minimal: only [`Self::InvalidQuillReference`]
95/// is typed, because that is the one error a non-malicious caller hits at
96/// the document/quill boundary. Every other defect — wrong-role card,
97/// invalid kind, duplicate key, too many fields — can only arise from a
98/// hand-crafted storage DTO (the markdown parser already rejects them)
99/// and is reported through [`Self::Malformed`] with a descriptive message.
100#[derive(Debug, Clone, PartialEq)]
101pub enum StorageError {
102    /// A stored quill reference string could not be parsed.
103    InvalidQuillReference {
104        /// The offending string.
105        value: String,
106        /// Parser explanation.
107        reason: String,
108    },
109    /// The stored document is structurally malformed in a way the markdown
110    /// parser would reject. The message describes the specific defect.
111    Malformed(String),
112}
113
114impl std::fmt::Display for StorageError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            StorageError::InvalidQuillReference { value, reason } => {
118                write!(f, "invalid quill reference {value:?}: {reason}")
119            }
120            StorageError::Malformed(msg) => f.write_str(msg),
121        }
122    }
123}
124
125impl std::error::Error for StorageError {}
126
127// ─── V0_82_0 wire format (legacy; read + migrate forward only) ────────────────
128
129/// Frozen `0.82.0` representation of a [`Document`].
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct DocumentV0_82_0 {
132    pub main: CardV0_82_0,
133    #[serde(default)]
134    pub cards: Vec<CardV0_82_0>,
135}
136
137/// Frozen `0.82.0` representation of a [`Card`].
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct CardV0_82_0 {
140    pub payload: PayloadV0_82_0,
141    #[serde(default)]
142    pub body: String,
143}
144
145/// Frozen `0.82.0` representation of a [`Payload`].
146#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
147pub struct PayloadV0_82_0 {
148    #[serde(default)]
149    pub items: Vec<PayloadItemV0_82_0>,
150    #[serde(default)]
151    pub nested_comments: Vec<NestedCommentV0_82_0>,
152}
153
154/// Frozen `0.82.0` representation of a unified payload item.
155///
156/// Discriminator field is `type` to keep it unambiguous next to the `$kind`
157/// metadata semantic (a `kind` discriminator would yield `{"kind":"kind"}`
158/// for `$kind` entries).
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "lowercase")]
161pub enum PayloadItemV0_82_0 {
162    /// `$quill` system metadata — the quill reference string.
163    Quill { value: String },
164    /// `$kind` system metadata.
165    Kind { value: String },
166    /// `$id` system metadata.
167    Id { value: String },
168    /// `$ext` system metadata — an opaque mapping carrying out-of-band
169    /// extension data (UI editor state, agent annotations, …). Never
170    /// emitted into the plate JSON; round-trips through the DTO unchanged.
171    Ext {
172        value: serde_json::Map<String, serde_json::Value>,
173    },
174    /// A user-defined field.
175    Field {
176        key: String,
177        value: serde_json::Value,
178        #[serde(default)]
179        fill: bool,
180    },
181    /// A YAML comment.
182    Comment {
183        text: String,
184        #[serde(default)]
185        inline: bool,
186    },
187}
188
189/// Frozen `0.82.0` representation of a [`NestedComment`].
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct NestedCommentV0_82_0 {
192    pub container_path: Vec<CommentPathSegmentV0_82_0>,
193    pub position: usize,
194    pub text: String,
195    pub inline: bool,
196}
197
198/// Frozen `0.82.0` representation of a [`CommentPathSegment`].
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub enum CommentPathSegmentV0_82_0 {
201    Key(String),
202    Index(usize),
203}
204
205// ─── V0_92_0 wire format (current) ────────────────────────────────────────────
206
207/// Frozen `0.92.0` representation of a [`Document`].
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct DocumentV0_92_0 {
210    pub main: CardV0_92_0,
211    #[serde(default)]
212    pub cards: Vec<CardV0_92_0>,
213}
214
215/// Frozen `0.92.0` representation of a [`Card`].
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct CardV0_92_0 {
218    pub payload: PayloadV0_92_0,
219    #[serde(default)]
220    pub body: String,
221}
222
223/// Frozen `0.92.0` representation of a [`Payload`].
224#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
225pub struct PayloadV0_92_0 {
226    #[serde(default)]
227    pub items: Vec<PayloadItemV0_92_0>,
228    #[serde(default)]
229    pub nested_comments: Vec<NestedCommentV0_92_0>,
230}
231
232/// Frozen `0.92.0` representation of a unified payload item. Extends
233/// `V0_82_0` with the `Seed` variant and a per-`Field` `nested_fills` list:
234/// the paths of `!must_fill` markers nested inside the field value (the JSON
235/// `value` is fill-free).
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237#[serde(tag = "type", rename_all = "lowercase")]
238pub enum PayloadItemV0_92_0 {
239    /// `$quill` system metadata — the quill reference string.
240    Quill { value: String },
241    /// `$kind` system metadata.
242    Kind { value: String },
243    /// `$id` system metadata.
244    Id { value: String },
245    /// `$ext` system metadata — an opaque mapping carrying out-of-band
246    /// extension data. Never emitted into the plate JSON.
247    Ext {
248        value: serde_json::Map<String, serde_json::Value>,
249    },
250    /// `$seed` system metadata — a mapping keyed by card-kind carrying the
251    /// per-kind seed overlays. Never emitted into the plate JSON.
252    Seed {
253        value: serde_json::Map<String, serde_json::Value>,
254    },
255    /// A user-defined field.
256    Field {
257        key: String,
258        value: serde_json::Value,
259        #[serde(default)]
260        fill: bool,
261        #[serde(default, skip_serializing_if = "Vec::is_empty")]
262        nested_fills: Vec<Vec<CommentPathSegmentV0_92_0>>,
263    },
264    /// A YAML comment.
265    Comment {
266        text: String,
267        #[serde(default)]
268        inline: bool,
269    },
270}
271
272/// Frozen `0.92.0` representation of a [`NestedComment`].
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct NestedCommentV0_92_0 {
275    pub container_path: Vec<CommentPathSegmentV0_92_0>,
276    pub position: usize,
277    pub text: String,
278    pub inline: bool,
279}
280
281/// Frozen `0.92.0` representation of a [`CommentPathSegment`]. Also used for
282/// `nested_fills` path segments.
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub enum CommentPathSegmentV0_92_0 {
285    Key(String),
286    Index(usize),
287}
288
289// ─── Document ↔ V0_92_0 (live conversion) ─────────────────────────────────────
290
291impl From<Document> for StoredDocument {
292    fn from(doc: Document) -> Self {
293        StoredDocument::V0_92_0(DocumentV0_92_0::from(&doc))
294    }
295}
296
297impl From<&Document> for DocumentV0_92_0 {
298    fn from(doc: &Document) -> Self {
299        DocumentV0_92_0 {
300            main: CardV0_92_0::from(doc.main()),
301            cards: doc.cards().iter().map(CardV0_92_0::from).collect(),
302        }
303    }
304}
305
306impl From<&Card> for CardV0_92_0 {
307    fn from(card: &Card) -> Self {
308        CardV0_92_0 {
309            payload: PayloadV0_92_0::from(card.payload()),
310            body: card.body().to_string(),
311        }
312    }
313}
314
315impl From<&Payload> for PayloadV0_92_0 {
316    fn from(payload: &Payload) -> Self {
317        // The wire format keeps `nested_comments` as a flat sidecar at
318        // the payload level. The in-memory model carries them per-item
319        // with relative paths, so we re-prefix and flatten here.
320        let nested_comments = payload
321            .flat_nested_comments()
322            .iter()
323            .map(NestedCommentV0_92_0::from)
324            .collect();
325        PayloadV0_92_0 {
326            items: payload
327                .items()
328                .iter()
329                .map(PayloadItemV0_92_0::from)
330                .collect(),
331            nested_comments,
332        }
333    }
334}
335
336impl From<&PayloadItem> for PayloadItemV0_92_0 {
337    fn from(item: &PayloadItem) -> Self {
338        match item {
339            PayloadItem::Quill { reference } => PayloadItemV0_92_0::Quill {
340                value: reference.to_string(),
341            },
342            PayloadItem::Kind { value } => PayloadItemV0_92_0::Kind {
343                value: value.clone(),
344            },
345            PayloadItem::Id { value } => PayloadItemV0_92_0::Id {
346                value: value.clone(),
347            },
348            // The storage DTO keeps `$ext` / `$seed` as explicit, self-describing
349            // variants; the live model's unified `Meta` is split back out by key.
350            // Neither wire variant carries a `nested_comments` field — their
351            // comments live in the payload-level sidecar after
352            // `flat_nested_comments` re-prefixes them with `$ext` / `$seed`.
353            PayloadItem::Meta {
354                key: MetaKey::Ext,
355                value,
356                ..
357            } => PayloadItemV0_92_0::Ext {
358                value: value.clone(),
359            },
360            PayloadItem::Meta {
361                key: MetaKey::Seed,
362                value,
363                ..
364            } => PayloadItemV0_92_0::Seed {
365                value: value.clone(),
366            },
367            // The JSON `value` projection is fill-free; nested `!must_fill`
368            // markers ride alongside as `nested_fills` (root path omitted —
369            // a top-level marker is the `fill` flag).
370            PayloadItem::Field {
371                key, value, fill, ..
372            } => PayloadItemV0_92_0::Field {
373                key: key.clone(),
374                value: value.as_json().clone(),
375                fill: *fill,
376                nested_fills: value
377                    .nonroot_fill_paths()
378                    .map(|p| p.iter().map(CommentPathSegmentV0_92_0::from).collect())
379                    .collect(),
380            },
381            PayloadItem::Comment { text, inline } => PayloadItemV0_92_0::Comment {
382                text: text.clone(),
383                inline: *inline,
384            },
385        }
386    }
387}
388
389impl From<&NestedComment> for NestedCommentV0_92_0 {
390    fn from(nc: &NestedComment) -> Self {
391        NestedCommentV0_92_0 {
392            container_path: nc
393                .container_path
394                .iter()
395                .map(CommentPathSegmentV0_92_0::from)
396                .collect(),
397            position: nc.position,
398            text: nc.text.clone(),
399            inline: nc.inline,
400        }
401    }
402}
403
404impl From<&CommentPathSegment> for CommentPathSegmentV0_92_0 {
405    fn from(seg: &CommentPathSegment) -> Self {
406        match seg {
407            CommentPathSegment::Key(k) => CommentPathSegmentV0_92_0::Key(k.clone()),
408            CommentPathSegment::Index(i) => CommentPathSegmentV0_92_0::Index(*i),
409        }
410    }
411}
412
413impl TryFrom<StoredDocument> for Document {
414    type Error = StorageError;
415
416    fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
417        // Migrations chain: only the newest DTO converts to the live model;
418        // older versions migrate forward (V0_81 → V0_82 → V0_92).
419        match stored {
420            StoredDocument::V0_92_0(payload) => Document::try_from(payload),
421            StoredDocument::V0_82_0(payload) => Document::try_from(DocumentV0_92_0::from(payload)),
422            StoredDocument::V0_81_0(payload) => {
423                Document::try_from(DocumentV0_92_0::from(DocumentV0_82_0::from(payload)))
424            }
425        }
426    }
427}
428
429impl TryFrom<DocumentV0_92_0> for Document {
430    type Error = StorageError;
431
432    fn try_from(payload: DocumentV0_92_0) -> Result<Self, Self::Error> {
433        let main = Card::try_from(payload.main)?;
434        if main.quill().is_none() {
435            return Err(StorageError::Malformed(
436                "main card must carry a $quill entry".into(),
437            ));
438        }
439        let cards = payload
440            .cards
441            .into_iter()
442            .map(Card::try_from)
443            .collect::<Result<Vec<_>, _>>()?;
444        for card in &cards {
445            if card.quill().is_some() {
446                return Err(StorageError::Malformed(
447                    "composable cards must not carry a $quill entry".into(),
448                ));
449            }
450            if card.seed().is_some() {
451                return Err(StorageError::Malformed(
452                    "composable cards must not carry a $seed entry".into(),
453                ));
454            }
455            if let Some(kind) = card.kind() {
456                match validate_composable_kind(kind) {
457                    Ok(()) => {}
458                    Err(super::meta::CardKindError::InvalidName) => {
459                        return Err(StorageError::Malformed(format!(
460                            "invalid composable card kind {kind:?}: must match \
461                             [a-z_][a-z0-9_]*"
462                        )));
463                    }
464                    Err(super::meta::CardKindError::Reserved) => {
465                        return Err(StorageError::Malformed(format!(
466                            "composable card kind {kind:?} is reserved (root only)"
467                        )));
468                    }
469                }
470            }
471        }
472        Ok(Document::from_main_and_cards(main, cards, Vec::new()))
473    }
474}
475
476impl TryFrom<CardV0_92_0> for Card {
477    type Error = StorageError;
478
479    fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
480        let payload = Payload::try_from(card.payload)?;
481        validate_dto_payload(&payload)?;
482        Ok(Card::from_parts(payload, card.body))
483    }
484}
485
486impl TryFrom<PayloadV0_92_0> for Payload {
487    type Error = StorageError;
488
489    fn try_from(p: PayloadV0_92_0) -> Result<Self, Self::Error> {
490        let mut items = Vec::with_capacity(p.items.len());
491        for item in p.items {
492            items.push(PayloadItem::try_from(item)?);
493        }
494        let nested = p
495            .nested_comments
496            .into_iter()
497            .map(NestedComment::from)
498            .collect();
499        // Partition the flat wire-format sidecar onto the matching
500        // Field / Ext / Seed items (paths become relative to the owning value).
501        Ok(Payload::from_items_with_flat_nested(items, nested))
502    }
503}
504
505impl TryFrom<PayloadItemV0_92_0> for PayloadItem {
506    type Error = StorageError;
507
508    fn try_from(item: PayloadItemV0_92_0) -> Result<Self, Self::Error> {
509        Ok(match item {
510            PayloadItemV0_92_0::Quill { value } => {
511                let reference = QuillReference::from_str(&value).map_err(|reason| {
512                    StorageError::InvalidQuillReference {
513                        value: value.clone(),
514                        reason,
515                    }
516                })?;
517                PayloadItem::Quill { reference }
518            }
519            PayloadItemV0_92_0::Kind { value } => PayloadItem::Kind { value },
520            PayloadItemV0_92_0::Id { value } => PayloadItem::Id { value },
521            PayloadItemV0_92_0::Ext { value } => PayloadItem::Meta {
522                key: MetaKey::Ext,
523                value: depth_check_meta_map(value, "$ext")?,
524                nested_comments: Vec::new(),
525            },
526            PayloadItemV0_92_0::Seed { value } => PayloadItem::Meta {
527                key: MetaKey::Seed,
528                value: depth_check_meta_map(value, "$seed")?,
529                nested_comments: Vec::new(),
530            },
531            PayloadItemV0_92_0::Field {
532                key,
533                value,
534                fill,
535                nested_fills,
536            } => {
537                use super::edit::{validate_field, FieldViolation};
538                validate_field(&key, &value).map_err(|v| {
539                    StorageError::Malformed(match v {
540                        FieldViolation::InvalidName => {
541                            format!("invalid field name {key:?}: must match [A-Za-z_][A-Za-z0-9_]*")
542                        }
543                        FieldViolation::TooDeep => format!(
544                            "field {key:?} nests deeper than the maximum of {} levels",
545                            crate::document::limits::MAX_YAML_DEPTH
546                        ),
547                    })
548                })?;
549                let mut qv = QuillValue::from_json(value);
550                for path in nested_fills {
551                    let segs: Vec<CommentPathSegment> =
552                        path.into_iter().map(CommentPathSegment::from).collect();
553                    qv.set_fill_at(&segs);
554                }
555                PayloadItem::Field {
556                    key,
557                    value: qv,
558                    fill,
559                    nested_comments: Vec::new(),
560                }
561            }
562            PayloadItemV0_92_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
563        })
564    }
565}
566
567/// Depth-bound a `$ext` / `$seed` mapping at the storage boundary; both flow
568/// through the recursive emit/DTO paths and carry the §8 value-depth limit.
569fn depth_check_meta_map(
570    value: serde_json::Map<String, serde_json::Value>,
571    key: &str,
572) -> Result<serde_json::Map<String, serde_json::Value>, StorageError> {
573    let as_value = serde_json::Value::Object(value);
574    if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH) {
575        return Err(StorageError::Malformed(format!(
576            "{key} nests deeper than the maximum of {} levels",
577            crate::document::limits::MAX_YAML_DEPTH
578        )));
579    }
580    let serde_json::Value::Object(value) = as_value else {
581        unreachable!("constructed as Object above")
582    };
583    Ok(value)
584}
585
586impl From<NestedCommentV0_92_0> for NestedComment {
587    fn from(nc: NestedCommentV0_92_0) -> Self {
588        NestedComment {
589            container_path: nc
590                .container_path
591                .into_iter()
592                .map(CommentPathSegment::from)
593                .collect(),
594            position: nc.position,
595            text: nc.text,
596            inline: nc.inline,
597        }
598    }
599}
600
601impl From<CommentPathSegmentV0_92_0> for CommentPathSegment {
602    fn from(seg: CommentPathSegmentV0_92_0) -> Self {
603        match seg {
604            CommentPathSegmentV0_92_0::Key(k) => CommentPathSegment::Key(k),
605            CommentPathSegmentV0_92_0::Index(i) => CommentPathSegment::Index(i),
606        }
607    }
608}
609
610// ─── V0_82_0 → V0_92_0 migration ──────────────────────────────────────────────
611//
612// Purely structural: V0_82_0 has neither `$seed` nor `Field.nested_fills`, so
613// every variant maps 1:1 — the new `Seed` variant is never produced and
614// `nested_fills` defaults to empty (that format never carried nested markers).
615
616impl From<DocumentV0_82_0> for DocumentV0_92_0 {
617    fn from(d: DocumentV0_82_0) -> Self {
618        DocumentV0_92_0 {
619            main: CardV0_92_0::from(d.main),
620            cards: d.cards.into_iter().map(CardV0_92_0::from).collect(),
621        }
622    }
623}
624
625impl From<CardV0_82_0> for CardV0_92_0 {
626    fn from(c: CardV0_82_0) -> Self {
627        CardV0_92_0 {
628            payload: PayloadV0_92_0::from(c.payload),
629            body: c.body,
630        }
631    }
632}
633
634impl From<PayloadV0_82_0> for PayloadV0_92_0 {
635    fn from(p: PayloadV0_82_0) -> Self {
636        PayloadV0_92_0 {
637            items: p.items.into_iter().map(PayloadItemV0_92_0::from).collect(),
638            nested_comments: p
639                .nested_comments
640                .into_iter()
641                .map(NestedCommentV0_92_0::from)
642                .collect(),
643        }
644    }
645}
646
647impl From<PayloadItemV0_82_0> for PayloadItemV0_92_0 {
648    fn from(item: PayloadItemV0_82_0) -> Self {
649        match item {
650            PayloadItemV0_82_0::Quill { value } => PayloadItemV0_92_0::Quill { value },
651            PayloadItemV0_82_0::Kind { value } => PayloadItemV0_92_0::Kind { value },
652            PayloadItemV0_82_0::Id { value } => PayloadItemV0_92_0::Id { value },
653            PayloadItemV0_82_0::Ext { value } => PayloadItemV0_92_0::Ext { value },
654            PayloadItemV0_82_0::Field { key, value, fill } => PayloadItemV0_92_0::Field {
655                key,
656                value,
657                fill,
658                nested_fills: Vec::new(),
659            },
660            PayloadItemV0_82_0::Comment { text, inline } => {
661                PayloadItemV0_92_0::Comment { text, inline }
662            }
663        }
664    }
665}
666
667impl From<NestedCommentV0_82_0> for NestedCommentV0_92_0 {
668    fn from(nc: NestedCommentV0_82_0) -> Self {
669        NestedCommentV0_92_0 {
670            container_path: nc
671                .container_path
672                .into_iter()
673                .map(CommentPathSegmentV0_92_0::from)
674                .collect(),
675            position: nc.position,
676            text: nc.text,
677            inline: nc.inline,
678        }
679    }
680}
681
682impl From<CommentPathSegmentV0_82_0> for CommentPathSegmentV0_92_0 {
683    fn from(seg: CommentPathSegmentV0_82_0) -> Self {
684        match seg {
685            CommentPathSegmentV0_82_0::Key(k) => CommentPathSegmentV0_92_0::Key(k),
686            CommentPathSegmentV0_82_0::Index(i) => CommentPathSegmentV0_92_0::Index(i),
687        }
688    }
689}
690
691/// Reject a payload no markdown-parsed `Document` could produce: too many
692/// fields or a duplicate user-field key. The markdown parser already
693/// rejects both; this only guards hand-crafted storage DTOs.
694fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
695    if payload.len() > crate::error::MAX_FIELD_COUNT {
696        return Err(StorageError::Malformed(format!(
697            "card has {} user fields, exceeding the maximum of {}",
698            payload.len(),
699            crate::error::MAX_FIELD_COUNT
700        )));
701    }
702    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
703    for key in payload.keys() {
704        if !seen.insert(key.as_str()) {
705            return Err(StorageError::Malformed(format!(
706                "duplicate user-field key {key:?}"
707            )));
708        }
709    }
710    Ok(())
711}
712
713// ─── V0_81_0 wire format (legacy, read-only) ──────────────────────────────────
714
715/// Frozen `0.81.0` representation of a [`Document`].
716#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
717pub struct DocumentV0_81_0 {
718    pub main: CardV0_81_0,
719    #[serde(default)]
720    pub cards: Vec<CardV0_81_0>,
721}
722
723/// Frozen `0.81.0` representation of a [`Card`].
724#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
725pub struct CardV0_81_0 {
726    pub sentinel: SentinelV0_81_0,
727    #[serde(default)]
728    pub frontmatter: FrontmatterV0_81_0,
729    #[serde(default)]
730    pub body: String,
731}
732
733/// Frozen `0.81.0` representation of a card discriminator (sentinel).
734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
735#[serde(tag = "kind", rename_all = "lowercase")]
736pub enum SentinelV0_81_0 {
737    Main { quill: String },
738    Card { tag: String },
739}
740
741/// Frozen `0.81.0` representation of a card payload (user fields only).
742#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
743pub struct FrontmatterV0_81_0 {
744    #[serde(default)]
745    pub items: Vec<FrontmatterItemV0_81_0>,
746    #[serde(default)]
747    pub nested_comments: Vec<NestedCommentV0_81_0>,
748}
749
750/// Frozen `0.81.0` representation of a payload item (no `$` entries).
751#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
752#[serde(tag = "kind", rename_all = "lowercase")]
753pub enum FrontmatterItemV0_81_0 {
754    Field {
755        key: String,
756        value: serde_json::Value,
757        #[serde(default)]
758        fill: bool,
759    },
760    Comment {
761        text: String,
762        #[serde(default)]
763        inline: bool,
764    },
765}
766
767/// Frozen `0.81.0` representation of a [`NestedComment`].
768#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
769pub struct NestedCommentV0_81_0 {
770    pub container_path: Vec<CommentPathSegmentV0_81_0>,
771    pub position: usize,
772    pub text: String,
773    pub inline: bool,
774}
775
776/// Frozen `0.81.0` representation of a [`CommentPathSegment`].
777#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
778pub enum CommentPathSegmentV0_81_0 {
779    Key(String),
780    Index(usize),
781}
782
783// ─── V0_81_0 → V0_82_0 migration ──────────────────────────────────────────────
784//
785// The migration is purely structural — it converts the old separate
786// `sentinel + frontmatter` shape into a unified items list, then defers to
787// the V0_82_0 → Document path for typed validation. Quill-reference
788// validity is checked once, on the V0_82_0 side.
789
790impl From<DocumentV0_81_0> for DocumentV0_82_0 {
791    fn from(d: DocumentV0_81_0) -> Self {
792        DocumentV0_82_0 {
793            main: CardV0_82_0::from(d.main),
794            cards: d.cards.into_iter().map(CardV0_82_0::from).collect(),
795        }
796    }
797}
798
799impl From<CardV0_81_0> for CardV0_82_0 {
800    fn from(c: CardV0_81_0) -> Self {
801        let mut items: Vec<PayloadItemV0_82_0> = Vec::new();
802
803        // The sentinel migrates to a prelude of typed `$` entries. The
804        // `Main` variant implies `$kind: main` (spec §3.3); the
805        // reconstructed model carries the canonical kind so the markdown
806        // emit produces a parseable document.
807        match c.sentinel {
808            SentinelV0_81_0::Main { quill } => {
809                items.push(PayloadItemV0_82_0::Quill { value: quill });
810                items.push(PayloadItemV0_82_0::Kind {
811                    value: "main".into(),
812                });
813            }
814            SentinelV0_81_0::Card { tag } => {
815                items.push(PayloadItemV0_82_0::Kind { value: tag });
816            }
817        }
818
819        // Append user fields and comments in their original order. V0_81_0
820        // didn't track `$`-line comments separately, so the comment
821        // positions migrate as-is (after the `$` prelude).
822        for item in c.frontmatter.items {
823            items.push(match item {
824                FrontmatterItemV0_81_0::Field { key, value, fill } => {
825                    PayloadItemV0_82_0::Field { key, value, fill }
826                }
827                FrontmatterItemV0_81_0::Comment { text, inline } => {
828                    PayloadItemV0_82_0::Comment { text, inline }
829                }
830            });
831        }
832
833        let nested_comments = c
834            .frontmatter
835            .nested_comments
836            .into_iter()
837            .map(NestedCommentV0_82_0::from)
838            .collect();
839
840        CardV0_82_0 {
841            payload: PayloadV0_82_0 {
842                items,
843                nested_comments,
844            },
845            body: c.body,
846        }
847    }
848}
849
850impl From<NestedCommentV0_81_0> for NestedCommentV0_82_0 {
851    fn from(nc: NestedCommentV0_81_0) -> Self {
852        NestedCommentV0_82_0 {
853            container_path: nc
854                .container_path
855                .into_iter()
856                .map(CommentPathSegmentV0_82_0::from)
857                .collect(),
858            position: nc.position,
859            text: nc.text,
860            inline: nc.inline,
861        }
862    }
863}
864
865impl From<CommentPathSegmentV0_81_0> for CommentPathSegmentV0_82_0 {
866    fn from(seg: CommentPathSegmentV0_81_0) -> Self {
867        match seg {
868            CommentPathSegmentV0_81_0::Key(k) => CommentPathSegmentV0_82_0::Key(k),
869            CommentPathSegmentV0_81_0::Index(i) => CommentPathSegmentV0_82_0::Index(i),
870        }
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    fn sample() -> Document {
879        Document::from_markdown(
880            "\
881~~~card-yaml
882$quill: usaf_memo@0.1
883$kind: main
884# a top-level comment
885memo_for:
886  - ORG/SYMBOL # inline comment inside a sequence
887date: 2504-10-05
888subject: !must_fill Subject of the Memorandum
889~~~
890
891The body of the memorandum.
892
893~~~card-yaml
894$kind: indorsement
895for: ORG/SYMBOL
896from: ORG/SYMBOL
897~~~
898
899This body and the metadata above are an indorsement card.
900",
901        )
902        .unwrap()
903    }
904
905    #[test]
906    fn round_trips_through_serde_json() {
907        let doc = sample();
908        let json = serde_json::to_string(&doc).unwrap();
909        let restored: Document = serde_json::from_str(&json).unwrap();
910        assert_eq!(doc, restored);
911        assert_eq!(doc.to_markdown(), restored.to_markdown());
912    }
913
914    #[test]
915    fn serialization_uses_current_schema() {
916        let doc = sample();
917        let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
918        assert_eq!(value["schema"], SCHEMA_V0_92_0);
919    }
920
921    #[test]
922    fn nested_fill_survives_storage_round_trip() {
923        // A `!must_fill` marker on a nested object leaf rides the `nested_fills`
924        // path list (the JSON `value` projection is fill-free).
925        let doc = Document::from_markdown(
926            "~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n  street: !must_fill\n  city: Anytown\n~~~\n",
927        )
928        .unwrap();
929        let json = serde_json::to_string(&doc).unwrap();
930        let restored: Document = serde_json::from_str(&json).unwrap();
931        assert_eq!(doc, restored, "nested fill must survive storage round-trip");
932        assert!(
933            restored.to_markdown().contains("street: !must_fill"),
934            "Got:\n{}",
935            restored.to_markdown()
936        );
937    }
938
939    #[test]
940    fn v0_82_0_payload_migrates_forward() {
941        // A 0.82.0 row (no `nested_fills` on its field) loads via the
942        // V0_82_0 → V0_92_0 migration, defaulting nested_fills to empty.
943        let json = r#"{
944            "schema": "quillmark/document@0.82.0",
945            "main": {
946                "payload": {
947                    "items": [
948                        {"type": "quill", "value": "usaf_memo@0.1"},
949                        {"type": "kind", "value": "main"},
950                        {"type": "field", "key": "title", "value": "Hello", "fill": false}
951                    ]
952                },
953                "body": "Body."
954            },
955            "cards": []
956        }"#;
957        let doc: Document = serde_json::from_str(json).unwrap();
958        assert_eq!(doc.main().kind(), Some("main"));
959        assert_eq!(
960            doc.main().payload().get("title").unwrap().as_str(),
961            Some("Hello")
962        );
963    }
964
965    #[test]
966    fn root_kind_is_main_through_round_trip() {
967        let doc = Document::from_markdown(
968            "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
969        )
970        .unwrap();
971        assert_eq!(doc.main().kind(), Some("main"));
972        let restored: Document =
973            serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
974        assert_eq!(doc, restored);
975        assert_eq!(restored.main().kind(), Some("main"));
976    }
977
978    #[test]
979    fn serialization_is_byte_deterministic() {
980        // Re-serialization stability, round-trip stability, and
981        // path-independence — checked together because consumers
982        // content-hash the result.
983        let doc = sample();
984        let first = serde_json::to_string(&doc).unwrap();
985        let second = serde_json::to_string(&doc).unwrap();
986        assert_eq!(first, second, "to_string must be deterministic");
987        let restored: Document = serde_json::from_str(&first).unwrap();
988        let third = serde_json::to_string(&restored).unwrap();
989        assert_eq!(first, third, "byte-equality must survive a round-trip");
990    }
991
992    #[test]
993    fn rejects_unknown_schema_version() {
994        let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
995        assert!(serde_json::from_str::<Document>(json).is_err());
996    }
997
998    #[test]
999    fn peek_schema_version_reads_field_without_full_parse() {
1000        let doc = sample();
1001        let json = serde_json::to_string(&doc).unwrap();
1002        assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_92_0));
1003
1004        // Unknown future version: peek still succeeds.
1005        let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
1006        assert_eq!(
1007            peek_schema_version(future).as_deref(),
1008            Some("quillmark/document@0.99.0")
1009        );
1010        assert_eq!(peek_schema_version("not json"), None);
1011        assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
1012    }
1013
1014    #[test]
1015    fn comment_on_dollar_line_round_trips() {
1016        // The headline case the unification enables: a `$kind` line with an
1017        // inline trailing comment survives a JSON round-trip.
1018        let src = "\
1019~~~card-yaml
1020$quill: q@1.0
1021$kind: main # required for root
1022title: Hi
1023~~~
1024";
1025        let doc = Document::from_markdown(src).unwrap();
1026        let json = serde_json::to_string(&doc).unwrap();
1027        let restored: Document = serde_json::from_str(&json).unwrap();
1028        assert_eq!(doc, restored);
1029        // And the emitted markdown carries the comment back on the `$kind` line.
1030        assert!(restored
1031            .to_markdown()
1032            .contains("$kind: main # required for root"));
1033    }
1034
1035    #[test]
1036    fn v0_81_0_payload_loads_via_migration() {
1037        let json = r#"{
1038            "schema": "quillmark/document@0.81.0",
1039            "main": {
1040                "sentinel": {"kind": "main", "quill": "usaf_memo@0.1"},
1041                "frontmatter": {
1042                    "items": [{"kind": "field", "key": "title", "value": "Hello"}]
1043                },
1044                "body": "Body."
1045            },
1046            "cards": []
1047        }"#;
1048        let doc: Document = serde_json::from_str(json).unwrap();
1049        assert_eq!(doc.main().kind(), Some("main"));
1050        assert_eq!(doc.quill_reference().to_string(), "usaf_memo@0.1");
1051        assert_eq!(
1052            doc.main().payload().get("title").unwrap().as_str(),
1053            Some("Hello")
1054        );
1055    }
1056
1057    #[test]
1058    fn v0_81_0_with_composable_card_migrates() {
1059        let json = r#"{
1060            "schema": "quillmark/document@0.81.0",
1061            "main": {
1062                "sentinel": {"kind": "main", "quill": "q@1.0"},
1063                "frontmatter": {"items": []},
1064                "body": ""
1065            },
1066            "cards": [
1067                {
1068                    "sentinel": {"kind": "card", "tag": "indorsement"},
1069                    "frontmatter": {"items": [{"kind": "field", "key": "for", "value": "X"}]},
1070                    "body": "C body"
1071                }
1072            ]
1073        }"#;
1074        let doc: Document = serde_json::from_str(json).unwrap();
1075        assert_eq!(doc.cards().len(), 1);
1076        assert_eq!(doc.cards()[0].kind(), Some("indorsement"));
1077        assert_eq!(
1078            doc.cards()[0].payload().get("for").unwrap().as_str(),
1079            Some("X")
1080        );
1081    }
1082
1083    #[test]
1084    fn rejects_main_card_without_quill() {
1085        let json = r#"{
1086            "schema": "quillmark/document@0.82.0",
1087            "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
1088            "cards": []
1089        }"#;
1090        let err = serde_json::from_str::<Document>(json).unwrap_err();
1091        assert!(err.to_string().contains("$quill"));
1092    }
1093
1094    #[test]
1095    fn rejects_composable_card_tagged_main() {
1096        let json = r#"{
1097            "schema": "quillmark/document@0.82.0",
1098            "main": {
1099                "payload": {"items": [
1100                    {"type": "quill", "value": "q@1.0"},
1101                    {"type": "kind", "value": "main"}
1102                ]},
1103                "body": ""
1104            },
1105            "cards": [
1106                {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
1107            ]
1108        }"#;
1109        let err = serde_json::from_str::<Document>(json).unwrap_err();
1110        assert!(err.to_string().contains("reserved (root only)"));
1111    }
1112
1113    #[test]
1114    fn rejects_invalid_quill_reference() {
1115        let json = r#"{
1116            "schema": "quillmark/document@0.82.0",
1117            "main": {
1118                "payload": {"items": [
1119                    {"type": "quill", "value": "not a valid ref!!"},
1120                    {"type": "kind", "value": "main"}
1121                ]},
1122                "body": ""
1123            },
1124            "cards": []
1125        }"#;
1126        let err = serde_json::from_str::<Document>(json).unwrap_err();
1127        assert!(err.to_string().contains("invalid quill reference"));
1128    }
1129
1130    #[test]
1131    fn v0_82_0_payload_loads_via_migration() {
1132        // A 0.82.0 blob (no `$seed`) migrates forward (0.82 → 0.92) to the live
1133        // model, then re-serializes under the current tag.
1134        let json = r#"{
1135            "schema": "quillmark/document@0.82.0",
1136            "main": {
1137                "payload": {"items": [
1138                    {"type": "quill", "value": "usaf_memo@0.1"},
1139                    {"type": "kind", "value": "main"},
1140                    {"type": "field", "key": "title", "value": "Hello"}
1141                ]},
1142                "body": "Body."
1143            },
1144            "cards": []
1145        }"#;
1146        let doc: Document = serde_json::from_str(json).unwrap();
1147        assert_eq!(doc.main().kind(), Some("main"));
1148        assert_eq!(
1149            doc.main().payload().get("title").unwrap().as_str(),
1150            Some("Hello")
1151        );
1152        let reser = serde_json::to_string(&doc).unwrap();
1153        assert_eq!(peek_schema_version(&reser).as_deref(), Some(SCHEMA_V0_92_0));
1154    }
1155
1156    #[test]
1157    fn v0_82_0_blob_with_seed_item_is_rejected() {
1158        // Proves the schema bump was necessary: `{"type":"seed"}` is not a legal
1159        // V0_82_0 payload item, so a blob claiming the 0.82.0 tag must fail
1160        // rather than silently load.
1161        let json = r#"{
1162            "schema": "quillmark/document@0.82.0",
1163            "main": {
1164                "payload": {"items": [
1165                    {"type": "quill", "value": "q@1.0"},
1166                    {"type": "kind", "value": "main"},
1167                    {"type": "seed", "value": {"indorsement": {"from": "X"}}}
1168                ]},
1169                "body": ""
1170            },
1171            "cards": []
1172        }"#;
1173        assert!(serde_json::from_str::<Document>(json).is_err());
1174    }
1175
1176    #[test]
1177    fn rejects_composable_card_with_seed() {
1178        // `$seed` is root-only (like `$quill`): a stored composable card
1179        // carrying it fails to load.
1180        let json = r#"{
1181            "schema": "quillmark/document@0.92.0",
1182            "main": {
1183                "payload": {"items": [
1184                    {"type": "quill", "value": "q@1.0"},
1185                    {"type": "kind", "value": "main"}
1186                ]},
1187                "body": ""
1188            },
1189            "cards": [
1190                {"payload": {"items": [
1191                    {"type": "kind", "value": "indorsement"},
1192                    {"type": "seed", "value": {"note": {"from": "X"}}}
1193                ]}, "body": ""}
1194            ]
1195        }"#;
1196        let err = serde_json::from_str::<Document>(json).unwrap_err();
1197        assert!(err
1198            .to_string()
1199            .contains("composable cards must not carry a $seed entry"));
1200    }
1201
1202    #[test]
1203    fn v0_92_0_seed_item_round_trips() {
1204        let json = r#"{
1205            "schema": "quillmark/document@0.92.0",
1206            "main": {
1207                "payload": {"items": [
1208                    {"type": "quill", "value": "q@1.0"},
1209                    {"type": "kind", "value": "main"},
1210                    {"type": "seed", "value": {"indorsement": {"from": "49 FW/CC"}}}
1211                ]},
1212                "body": ""
1213            },
1214            "cards": []
1215        }"#;
1216        let doc: Document = serde_json::from_str(json).unwrap();
1217        let overlay = doc
1218            .main()
1219            .seed()
1220            .and_then(|m| m.get("indorsement"))
1221            .and_then(crate::SeedOverlay::from_json)
1222            .expect("overlay present");
1223        assert_eq!(
1224            overlay.fields.get("from").and_then(|v| v.as_str()),
1225            Some("49 FW/CC")
1226        );
1227        let reser: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
1228        assert_eq!(doc, reser);
1229    }
1230}