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.93.0`**: current. The V0_92_0 payload model with
16//!   the card `body` stored as the **canonical content** embedded
17//!   structurally (a nested object byte-identical to `to_canonical_json`), not a
18//!   markdown string. The envelope carries two byte disciplines: the outer
19//!   structure stays compact `serde_json` in frozen struct + payload-insertion
20//!   order (`preserve_order`), while every `body` subtree is the recursively
21//!   key-sorted canonical form. This is the format newly serialized documents
22//!   use.
23//! - **`quillmark/document@0.92.0`**: legacy, and the oldest wire format still
24//!   read. The unified [`Payload`] item list (typed `$` entries, user fields,
25//!   and comments interleaved in source order) with a per-field `nested_fills`
26//!   list (so `!must_fill` markers nested inside a field value survive a storage
27//!   round-trip) and the `$seed` payload-item variant (per-card-kind seed
28//!   overlays), with the body as a markdown string. Kept read-only; the body
29//!   cold-imports to a content and it migrates forward to V0_93_0 on read.
30//!
31//! The canonical design (including the step-by-step procedure for adding
32//! a schema version) is `prose/canon/DOCUMENT_STORAGE.md`.
33
34// Storage DTO types are named after the crate version that fixed their shape
35// (e.g. `DocumentV0_92_0`); the underscores are intentional.
36#![allow(non_camel_case_types)]
37
38use std::str::FromStr;
39
40use serde::{Deserialize, Serialize};
41
42use quillmark_content::Content;
43
44use super::meta::validate_composable_kind;
45use super::payload::{MetaKey, Payload, PayloadItem};
46use super::prescan::{CommentPathSegment, NestedComment};
47use super::{Card, Document};
48use crate::value::QuillValue;
49use crate::version::QuillReference;
50
51/// Schema version for the V0_93_0 wire format. Newly serialized documents carry
52/// this tag. Stores the card `body` as the canonical content embedded
53/// structurally (byte-identical to `to_canonical_json`) rather than a markdown string;
54/// the payload shape is unchanged from V0_92_0.
55pub const SCHEMA_V0_93_0: &str = "quillmark/document@0.93.0";
56
57/// Read the `schema` field from a raw storage DTO payload without
58/// performing full deserialization.
59///
60/// Returns `None` if `json` is not valid JSON, is not an object, or has no
61/// `schema` field. The returned string is **not** validated against the
62/// set of supported schema versions: callers use this to distinguish
63/// "unknown future version" from "corrupt payload" when [`Document`]
64/// deserialization fails.
65pub fn peek_schema_version(json: &str) -> Option<String> {
66    #[derive(Deserialize)]
67    struct Peek {
68        schema: Option<String>,
69    }
70    serde_json::from_str::<Peek>(json).ok()?.schema
71}
72
73/// Versioned envelope for a persisted [`Document`].
74///
75/// The `schema` field selects the payload version. Deserialization
76/// dispatches on it; unknown values are rejected. New schema versions are
77/// added as new variants, leaving existing ones byte-stable.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(tag = "schema")]
80#[non_exhaustive]
81pub enum StoredDocument {
82    /// Current (V0_93_0) document model: the V0_92_0 payload with the card
83    /// `body` embedded as the canonical content (a nested object).
84    #[serde(rename = "quillmark/document@0.93.0")]
85    V0_93_0(DocumentV0_93_0),
86    /// Legacy (V0_92_0) document model: unified payload items with per-field
87    /// nested fill paths and `$seed`, body as a markdown string. Read-only;
88    /// migrated forward to V0_93_0 on reconstruction.
89    #[serde(rename = "quillmark/document@0.92.0")]
90    V0_92_0(DocumentV0_92_0),
91}
92
93/// Failure while reconstructing a [`Document`] from a [`StoredDocument`].
94///
95/// The taxonomy is intentionally minimal: only [`Self::InvalidQuillReference`]
96/// is typed, because that is the one error a non-malicious caller hits at
97/// the document/quill boundary. Every other defect (wrong-role card,
98/// invalid kind, duplicate key, too many fields) can only arise from a
99/// hand-crafted storage DTO (the markdown parser already rejects them)
100/// and is reported through [`Self::Malformed`] with a descriptive message.
101#[derive(Debug, Clone, PartialEq)]
102#[non_exhaustive]
103pub enum StorageError {
104    /// A stored quill reference string could not be parsed.
105    InvalidQuillReference {
106        /// The offending string.
107        value: String,
108        /// Parser explanation.
109        reason: String,
110    },
111    /// The stored document is structurally malformed in a way the markdown
112    /// parser would reject. The message describes the specific defect.
113    Malformed(String),
114}
115
116impl std::fmt::Display for StorageError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            StorageError::InvalidQuillReference { value, reason } => {
120                write!(f, "invalid quill reference {value:?}: {reason}")
121            }
122            StorageError::Malformed(msg) => f.write_str(msg),
123        }
124    }
125}
126
127impl std::error::Error for StorageError {}
128
129// ─── V0_93_0 wire format (current) ────────────────────────────────────────────
130
131/// Frozen `0.93.0` representation of a [`Document`]. Mirrors `DocumentV0_92_0`;
132/// the only structural change is `Card.body` (see [`CardV0_93_0`]).
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct DocumentV0_93_0 {
135    pub main: CardV0_93_0,
136    #[serde(default)]
137    pub cards: Vec<CardV0_93_0>,
138}
139
140/// Frozen `0.93.0` representation of a [`Card`]. The `body` is the canonical
141/// content embedded structurally (see [`CanonicalContent`]); the
142/// payload is not part of this freeze and reuses the V0_92_0 shape.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct CardV0_93_0 {
145    pub payload: PayloadV0_93_0,
146    pub body: CanonicalContent,
147}
148
149/// The V0_93_0 payload shape: identical to V0_92_0. Aliased rather than copied
150/// because payload is outside this freeze; a future payload change forks it.
151pub type PayloadV0_93_0 = PayloadV0_92_0;
152
153/// A card body embedded as the **canonical content**. Its serde *is* the
154/// frozen canonical serializer (`quillmark_content::serial`), delegated to, not
155/// a hand-mirrored DTO tree that could drift from the frozen wire format:
156///
157/// - `Serialize` emits the recursively key-sorted structure byte-identical to
158///   `self.0.to_canonical_json()` as a **nested JSON object**, never an escaped
159///   string. Embedded in the compact envelope, the `body` subtree bytes equal
160///   that canonical JSON, independent of `preserve_order`.
161/// - `Deserialize` parses that structure, normalizes, and validates, so an
162///   invalid content is rejected at load (a serde error) rather than silently
163///   round-tripped.
164///
165/// Byte-equality with `to_canonical_json` holds because every `Content` in a live
166/// [`Document`] is normalized at construction; the serializer normalizes a copy
167/// regardless, so a hand-built value cannot leak non-canonical bytes.
168#[derive(Debug, Clone, PartialEq)]
169pub struct CanonicalContent(pub Content);
170
171impl Serialize for CanonicalContent {
172    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
173    where
174        S: serde::Serializer,
175    {
176        quillmark_content::serial::to_canonical_value(&self.0).serialize(serializer)
177    }
178}
179
180impl<'de> Deserialize<'de> for CanonicalContent {
181    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182    where
183        D: serde::Deserializer<'de>,
184    {
185        let value = serde_json::Value::deserialize(deserializer)?;
186        let rt = quillmark_content::serial::from_canonical_value(&value)
187            .map_err(serde::de::Error::custom)?;
188        Ok(CanonicalContent(rt))
189    }
190}
191
192// ─── V0_92_0 wire format ──────────────────────────────────────────────────────
193//
194// Dual role: `DocumentV0_92_0` / `CardV0_92_0` are read + migrate-forward only
195// (a 0.92 blob migrates to V0_93_0 on read), while the payload types
196// (`PayloadV0_92_0`, `PayloadItemV0_92_0`, …) are also the *current* write path,
197// `PayloadV0_93_0` aliases them and `From<&Document>` builds them.
198
199/// Frozen `0.92.0` representation of a [`Document`].
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct DocumentV0_92_0 {
202    pub main: CardV0_92_0,
203    #[serde(default)]
204    pub cards: Vec<CardV0_92_0>,
205}
206
207/// Frozen `0.92.0` representation of a [`Card`].
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct CardV0_92_0 {
210    pub payload: PayloadV0_92_0,
211    #[serde(default)]
212    pub body: String,
213}
214
215/// Frozen `0.92.0` representation of a [`Payload`].
216#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
217pub struct PayloadV0_92_0 {
218    #[serde(default)]
219    pub items: Vec<PayloadItemV0_92_0>,
220    #[serde(default)]
221    pub nested_comments: Vec<NestedCommentV0_92_0>,
222}
223
224/// Frozen `0.92.0` representation of a unified payload item. Carries the `Seed`
225/// variant and a per-`Field` `nested_fills` list: the paths of `!must_fill`
226/// markers nested inside the field value (the JSON `value` is fill-free).
227///
228/// **Deliberately exhaustive**, like every `V0_92_0` type: a shipped schema
229/// version never changes, so there is no variant to leave room for. A new item
230/// kind is a new schema version with its own type tree: which is what
231/// [`StoredDocument`] being `#[non_exhaustive]` makes room for.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(tag = "type", rename_all = "lowercase")]
234pub enum PayloadItemV0_92_0 {
235    /// `$quill` system metadata: the quill reference string.
236    Quill { value: String },
237    /// `$kind` system metadata.
238    Kind { value: String },
239    /// `$id` system metadata.
240    Id { value: String },
241    /// `$ext` system metadata: an opaque mapping carrying out-of-band
242    /// extension data. Never emitted into the plate JSON.
243    Ext {
244        value: serde_json::Map<String, serde_json::Value>,
245    },
246    /// `$seed` system metadata: a mapping keyed by card-kind carrying the
247    /// per-kind seed overlays. Never emitted into the plate JSON.
248    Seed {
249        value: serde_json::Map<String, serde_json::Value>,
250    },
251    /// A user-defined field.
252    Field {
253        key: String,
254        value: serde_json::Value,
255        #[serde(default)]
256        fill: bool,
257        #[serde(default, skip_serializing_if = "Vec::is_empty")]
258        nested_fills: Vec<Vec<CommentPathSegmentV0_92_0>>,
259    },
260    /// A YAML comment.
261    Comment {
262        text: String,
263        #[serde(default)]
264        inline: bool,
265    },
266}
267
268/// Frozen `0.92.0` representation of a [`NestedComment`].
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct NestedCommentV0_92_0 {
271    pub container_path: Vec<CommentPathSegmentV0_92_0>,
272    pub position: usize,
273    pub text: String,
274    pub inline: bool,
275}
276
277/// Frozen `0.92.0` representation of a [`CommentPathSegment`]. Also used for
278/// `nested_fills` path segments.
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub enum CommentPathSegmentV0_92_0 {
281    Key(String),
282    Index(usize),
283}
284
285// ─── Document → V0_93_0 (write) ───────────────────────────────────────────────
286//
287// The write path targets the newest version only. Payload conversion still
288// runs through the V0_92_0 `PayloadItem` DTOs (`PayloadV0_93_0` aliases them);
289// the body is embedded as the canonical content.
290
291impl From<Document> for StoredDocument {
292    fn from(doc: Document) -> Self {
293        StoredDocument::V0_93_0(DocumentV0_93_0::from(&doc))
294    }
295}
296
297impl From<&Document> for DocumentV0_93_0 {
298    fn from(doc: &Document) -> Self {
299        DocumentV0_93_0 {
300            main: CardV0_93_0::from(doc.main()),
301            cards: doc.cards().iter().map(CardV0_93_0::from).collect(),
302        }
303    }
304}
305
306impl From<&Card> for CardV0_93_0 {
307    fn from(card: &Card) -> Self {
308        // The body is already a normalized content on the live model; embed it
309        // directly. `CanonicalContent`'s serializer emits the canonical form.
310        CardV0_93_0 {
311            payload: PayloadV0_92_0::from(card.payload()),
312            body: CanonicalContent(card.body().clone()),
313        }
314    }
315}
316
317impl From<&Payload> for PayloadV0_92_0 {
318    fn from(payload: &Payload) -> Self {
319        // The wire format keeps `nested_comments` as a flat sidecar at
320        // the payload level. The in-memory model carries them per-item
321        // with relative paths, so we re-prefix and flatten here.
322        let nested_comments = payload
323            .flat_nested_comments()
324            .iter()
325            .map(NestedCommentV0_92_0::from)
326            .collect();
327        PayloadV0_92_0 {
328            items: payload
329                .items()
330                .iter()
331                .map(PayloadItemV0_92_0::from)
332                .collect(),
333            nested_comments,
334        }
335    }
336}
337
338impl From<&PayloadItem> for PayloadItemV0_92_0 {
339    fn from(item: &PayloadItem) -> Self {
340        match item {
341            PayloadItem::Quill { reference } => PayloadItemV0_92_0::Quill {
342                value: reference.to_string(),
343            },
344            PayloadItem::Kind { value } => PayloadItemV0_92_0::Kind {
345                value: value.clone(),
346            },
347            PayloadItem::Id { value } => PayloadItemV0_92_0::Id {
348                value: value.clone(),
349            },
350            // The storage DTO keeps `$ext` / `$seed` as explicit, self-describing
351            // variants; the live model's unified `Meta` is split back out by key.
352            // Neither wire variant carries a `nested_comments` field: their
353            // comments live in the payload-level sidecar after
354            // `flat_nested_comments` re-prefixes them with `$ext` / `$seed`.
355            PayloadItem::Meta {
356                key: MetaKey::Ext,
357                value,
358                ..
359            } => PayloadItemV0_92_0::Ext {
360                value: value.clone(),
361            },
362            PayloadItem::Meta {
363                key: MetaKey::Seed,
364                value,
365                ..
366            } => PayloadItemV0_92_0::Seed {
367                value: value.clone(),
368            },
369            // The JSON `value` projection is fill-free; nested `!must_fill`
370            // markers ride alongside as `nested_fills` (root path omitted:
371            // a top-level marker is the `fill` flag).
372            PayloadItem::Field {
373                key, value, fill, ..
374            } => PayloadItemV0_92_0::Field {
375                key: key.clone(),
376                value: value.as_json().clone(),
377                fill: *fill,
378                nested_fills: value
379                    .nonroot_fill_paths()
380                    .map(|p| p.iter().map(CommentPathSegmentV0_92_0::from).collect())
381                    .collect(),
382            },
383            PayloadItem::Comment { text, inline } => PayloadItemV0_92_0::Comment {
384                text: text.clone(),
385                inline: *inline,
386            },
387        }
388    }
389}
390
391impl From<&NestedComment> for NestedCommentV0_92_0 {
392    fn from(nc: &NestedComment) -> Self {
393        NestedCommentV0_92_0 {
394            container_path: nc
395                .container_path
396                .iter()
397                .map(CommentPathSegmentV0_92_0::from)
398                .collect(),
399            position: nc.position,
400            text: nc.text.clone(),
401            inline: nc.inline,
402        }
403    }
404}
405
406impl From<&CommentPathSegment> for CommentPathSegmentV0_92_0 {
407    fn from(seg: &CommentPathSegment) -> Self {
408        match seg {
409            CommentPathSegment::Key(k) => CommentPathSegmentV0_92_0::Key(k.clone()),
410            CommentPathSegment::Index(i) => CommentPathSegmentV0_92_0::Index(*i),
411        }
412    }
413}
414
415impl TryFrom<StoredDocument> for Document {
416    type Error = StorageError;
417
418    fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
419        // Migrations chain: only the newest DTO converts to the live model;
420        // the older version migrates forward (V0_92 → V0_93). That hop
421        // cold-imports the markdown body, so the older arm is fallible (`?`).
422        match stored {
423            StoredDocument::V0_93_0(payload) => Document::try_from(payload),
424            StoredDocument::V0_92_0(payload) => {
425                Document::try_from(DocumentV0_93_0::try_from(payload)?)
426            }
427        }
428    }
429}
430
431impl TryFrom<DocumentV0_93_0> for Document {
432    type Error = StorageError;
433
434    fn try_from(payload: DocumentV0_93_0) -> Result<Self, Self::Error> {
435        let main = Card::try_from(payload.main)?;
436        if main.quill().is_none() {
437            return Err(StorageError::Malformed(
438                "main card must carry a $quill entry".into(),
439            ));
440        }
441        let cards = payload
442            .cards
443            .into_iter()
444            .map(Card::try_from)
445            .collect::<Result<Vec<_>, _>>()?;
446        // `$id` is unique per document and never empty; the writer cannot
447        // produce a violation (parse repairs, mutators reject), so a stored
448        // blob carrying one is malformed, not a repair candidate: storage is
449        // the strict machine boundary where parse is the lenient one
450        // (`DOCUMENT_STORAGE.md` §Card-id identity).
451        let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
452        for card in &cards {
453            if card.quill().is_some() {
454                return Err(StorageError::Malformed(
455                    "composable cards must not carry a $quill entry".into(),
456                ));
457            }
458            if card.seed().is_some() {
459                return Err(StorageError::Malformed(
460                    "composable cards must not carry a $seed entry".into(),
461                ));
462            }
463            if let Some(kind) = card.kind() {
464                match validate_composable_kind(kind) {
465                    Ok(()) => {}
466                    Err(super::meta::CardKindError::InvalidName) => {
467                        return Err(StorageError::Malformed(format!(
468                            "invalid composable card kind {kind:?}: must match \
469                             [a-z_][a-z0-9_]*"
470                        )));
471                    }
472                    Err(super::meta::CardKindError::Reserved) => {
473                        return Err(StorageError::Malformed(format!(
474                            "composable card kind {kind:?} is reserved (root only)"
475                        )));
476                    }
477                }
478            }
479            if let Some(id) = card.id() {
480                if id.is_empty() {
481                    return Err(StorageError::Malformed(
482                        "empty composable card $id: a card handle cannot be the \
483                         empty string"
484                            .into(),
485                    ));
486                }
487                if !seen_ids.insert(id) {
488                    return Err(StorageError::Malformed(format!(
489                        "duplicate composable card $id {id:?}: $id is unique per \
490                         document"
491                    )));
492                }
493            }
494        }
495        Ok(Document::from_main_and_cards(main, cards))
496    }
497}
498
499impl TryFrom<CardV0_93_0> for Card {
500    type Error = StorageError;
501
502    fn try_from(card: CardV0_93_0) -> Result<Self, Self::Error> {
503        let payload = Payload::try_from(card.payload)?;
504        validate_dto_payload(&payload)?;
505        // `body` is already a normalized, validated content: `CanonicalContent`
506        // enforced that on deserialize (and the V0_92 → V0_93 migration produced
507        // it via cold import). Take it directly.
508        Ok(Card::from_parts(payload, card.body.0))
509    }
510}
511
512// ─── V0_92_0 → V0_93_0 migration (fallible cold import) ───────────────────────
513//
514// The one hop that can reject: the stored markdown body cold-imports to the
515// content (`import_body`, pure/deterministic). An over-nested body
516// (> MAX_NESTING_DEPTH, surfaced as `ImportError::NestingTooDeep`) never
517// rendered, so mapping it to `StorageError::Malformed` loses nothing
518// renderable. Cross-release byte-stability of a *migrated* row is therefore
519// conditional on `pulldown-cmark` (DOCUMENT_STORAGE.md § byte stability).
520
521impl TryFrom<DocumentV0_92_0> for DocumentV0_93_0 {
522    type Error = StorageError;
523
524    fn try_from(d: DocumentV0_92_0) -> Result<Self, Self::Error> {
525        Ok(DocumentV0_93_0 {
526            main: CardV0_93_0::try_from(d.main)?,
527            cards: d
528                .cards
529                .into_iter()
530                .map(CardV0_93_0::try_from)
531                .collect::<Result<_, _>>()?,
532        })
533    }
534}
535
536impl TryFrom<CardV0_92_0> for CardV0_93_0 {
537    type Error = StorageError;
538
539    fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
540        let body = super::import_body(&card.body)
541            .map_err(|e| StorageError::Malformed(format!("card body: {e}")))?;
542        Ok(CardV0_93_0 {
543            payload: card.payload,
544            body: CanonicalContent(body),
545        })
546    }
547}
548
549impl TryFrom<PayloadV0_92_0> for Payload {
550    type Error = StorageError;
551
552    fn try_from(p: PayloadV0_92_0) -> Result<Self, Self::Error> {
553        let mut items = Vec::with_capacity(p.items.len());
554        for item in p.items {
555            items.push(PayloadItem::try_from(item)?);
556        }
557        let nested = p
558            .nested_comments
559            .into_iter()
560            .map(NestedComment::from)
561            .collect();
562        // Partition the flat wire-format sidecar onto the matching
563        // Field / Ext / Seed items (paths become relative to the owning value).
564        Ok(Payload::from_items_with_flat_nested(items, nested))
565    }
566}
567
568impl TryFrom<PayloadItemV0_92_0> for PayloadItem {
569    type Error = StorageError;
570
571    fn try_from(item: PayloadItemV0_92_0) -> Result<Self, Self::Error> {
572        Ok(match item {
573            PayloadItemV0_92_0::Quill { value } => {
574                let reference = QuillReference::from_str(&value).map_err(|reason| {
575                    StorageError::InvalidQuillReference {
576                        value: value.clone(),
577                        reason,
578                    }
579                })?;
580                PayloadItem::Quill { reference }
581            }
582            PayloadItemV0_92_0::Kind { value } => PayloadItem::Kind { value },
583            PayloadItemV0_92_0::Id { value } => PayloadItem::Id { value },
584            PayloadItemV0_92_0::Ext { value } => PayloadItem::Meta {
585                key: MetaKey::Ext,
586                value: depth_check_meta_map(value, "$ext")?,
587                nested_comments: Vec::new(),
588            },
589            PayloadItemV0_92_0::Seed { value } => PayloadItem::Meta {
590                key: MetaKey::Seed,
591                value: depth_check_meta_map(value, "$seed")?,
592                nested_comments: Vec::new(),
593            },
594            PayloadItemV0_92_0::Field {
595                key,
596                value,
597                fill,
598                nested_fills,
599            } => {
600                use super::edit::{validate_field, FieldViolation};
601                validate_field(&key, &value).map_err(|v| {
602                    StorageError::Malformed(match v {
603                        FieldViolation::InvalidName => {
604                            format!("invalid field name {key:?}: must match [A-Za-z_][A-Za-z0-9_]*")
605                        }
606                        FieldViolation::TooDeep => format!(
607                            "field {key:?} nests deeper than the maximum of {} levels",
608                            crate::document::limits::MAX_YAML_DEPTH
609                        ),
610                    })
611                })?;
612                let mut qv = QuillValue::from_json(value);
613                for path in nested_fills {
614                    let segs: Vec<CommentPathSegment> =
615                        path.into_iter().map(CommentPathSegment::from).collect();
616                    qv.set_fill_at(&segs);
617                }
618                PayloadItem::Field {
619                    key,
620                    value: qv,
621                    fill,
622                    nested_comments: Vec::new(),
623                }
624            }
625            PayloadItemV0_92_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
626        })
627    }
628}
629
630/// Depth-bound a `$ext` / `$seed` mapping at the storage boundary; both flow
631/// through the recursive emit/DTO paths and carry the §8 value-depth limit.
632fn depth_check_meta_map(
633    value: serde_json::Map<String, serde_json::Value>,
634    key: &str,
635) -> Result<serde_json::Map<String, serde_json::Value>, StorageError> {
636    crate::value::depth_check_meta_map(value, |max| {
637        StorageError::Malformed(format!("{key} nests deeper than the maximum of {} levels", max))
638    })
639}
640
641impl From<NestedCommentV0_92_0> for NestedComment {
642    fn from(nc: NestedCommentV0_92_0) -> Self {
643        NestedComment {
644            container_path: nc
645                .container_path
646                .into_iter()
647                .map(CommentPathSegment::from)
648                .collect(),
649            position: nc.position,
650            text: nc.text,
651            inline: nc.inline,
652        }
653    }
654}
655
656impl From<CommentPathSegmentV0_92_0> for CommentPathSegment {
657    fn from(seg: CommentPathSegmentV0_92_0) -> Self {
658        match seg {
659            CommentPathSegmentV0_92_0::Key(k) => CommentPathSegment::Key(k),
660            CommentPathSegmentV0_92_0::Index(i) => CommentPathSegment::Index(i),
661        }
662    }
663}
664
665/// Reject a payload no markdown-parsed `Document` could produce: too many
666/// fields or a duplicate user-field key. The markdown parser already
667/// rejects both; this only guards hand-crafted storage DTOs.
668fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
669    if payload.len() > crate::error::MAX_FIELD_COUNT {
670        return Err(StorageError::Malformed(format!(
671            "card has {} user fields, exceeding the maximum of {}",
672            payload.len(),
673            crate::error::MAX_FIELD_COUNT
674        )));
675    }
676    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
677    for key in payload.keys() {
678        if !seen.insert(key.as_str()) {
679            return Err(StorageError::Malformed(format!(
680                "duplicate user-field key {key:?}"
681            )));
682        }
683    }
684    Ok(())
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    fn sample() -> Document {
692        Document::parse(
693            "\
694~~~card-yaml
695$quill: usaf_memo@0.1
696$kind: main
697# a top-level comment
698memo_for:
699  - ORG/SYMBOL # inline comment inside a sequence
700date: 2504-10-05
701subject: !must_fill Subject of the Memorandum
702~~~
703
704The body of the memorandum.
705
706~~~card-yaml
707$kind: indorsement
708for: ORG/SYMBOL
709from: ORG/SYMBOL
710~~~
711
712This body and the metadata above are an indorsement card.
713",
714        )
715        .unwrap()
716        .document
717    }
718
719    #[test]
720    fn round_trips_through_serde_json() {
721        let doc = sample();
722        let json = serde_json::to_string(&doc).unwrap();
723        let restored: Document = serde_json::from_str(&json).unwrap();
724        assert_eq!(doc, restored);
725        assert_eq!(doc.to_markdown(), restored.to_markdown());
726    }
727
728    #[test]
729    fn card_id_round_trips_and_violations_are_malformed() {
730        // `$id` survives the storage round-trip verbatim (§Card-id identity).
731        let mut doc = sample();
732        doc.set_card_id(0, "id_a").unwrap();
733        let json = serde_json::to_string(&doc).unwrap();
734        let restored: Document = serde_json::from_str(&json).unwrap();
735        assert_eq!(restored.cards()[0].id(), Some("id_a"));
736        assert_eq!(doc, restored);
737
738        // The writer cannot produce a duplicate or empty `$id`, so a blob
739        // carrying one is malformed: storage rejects where parse repairs.
740        let mut two = sample();
741        two.set_card_id(0, "id_a").unwrap();
742        let second = crate::document::Card::new("indorsement").unwrap();
743        two.push_card(second).unwrap();
744        two.set_card_id(1, "id_b").unwrap();
745        let json = serde_json::to_string(&two).unwrap();
746
747        let dup = json.replace("id_b", "id_a");
748        let err = serde_json::from_str::<Document>(&dup).unwrap_err();
749        assert!(
750            err.to_string().contains("duplicate composable card $id"),
751            "got: {err}"
752        );
753
754        let empty = json.replace("id_b", "");
755        let err = serde_json::from_str::<Document>(&empty).unwrap_err();
756        assert!(
757            err.to_string().contains("empty composable card $id"),
758            "got: {err}"
759        );
760    }
761
762    #[test]
763    fn content_field_survives_storage_round_trip_losslessly() {
764        // A richtext field stored as a canonical content object is the case the
765        // card-yaml markdown projection is lossy for; the storage DTO is the
766        // lossless carrier, so identity marks (an `underline` with no markdown
767        // form) survive a serde-JSON round-trip that a markdown save would drop.
768        use quillmark_content::model::{Mark, MarkKind};
769
770        let mut doc = sample();
771        let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
772        content.marks.push(Mark {
773            start: 0,
774            end: 10,
775            kind: MarkKind::Underline,
776        });
777        content.normalize();
778        let json = quillmark_content::serial::to_canonical_value(&content);
779        let schema = crate::quill::FieldSchema::new(
780            "intro".to_string(),
781            crate::quill::FieldType::RichText { inline: false },
782            None,
783        );
784        doc.main_mut()
785            .commit_field("intro", crate::QuillValue::from_json(json), &schema)
786            .unwrap();
787
788        let stored = serde_json::to_string(&doc).unwrap();
789        let restored: Document = serde_json::from_str(&stored).unwrap();
790        assert_eq!(doc, restored, "content field must survive storage round-trip");
791        let read = restored.main().field_richtext("intro").unwrap().unwrap();
792        assert!(
793            read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)),
794            "underline (content-only) must survive the DTO carrier"
795        );
796    }
797
798    #[test]
799    fn nested_fill_survives_storage_round_trip() {
800        // A `!must_fill` marker on a nested object leaf rides the `nested_fills`
801        // path list (the JSON `value` projection is fill-free).
802        let doc = Document::parse(
803            "~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n  street: !must_fill\n  city: Anytown\n~~~\n",
804        )
805        .unwrap()
806        .document;
807        let json = serde_json::to_string(&doc).unwrap();
808        let restored: Document = serde_json::from_str(&json).unwrap();
809        assert_eq!(doc, restored, "nested fill must survive storage round-trip");
810        assert!(
811            restored.to_markdown().contains("street: !must_fill"),
812            "Got:\n{}",
813            restored.to_markdown()
814        );
815    }
816
817    #[test]
818    fn root_kind_is_main_through_round_trip() {
819        let doc = Document::parse(
820            "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
821        )
822        .unwrap()
823        .document;
824        assert_eq!(doc.main().kind(), Some("main"));
825        let restored: Document =
826            serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
827        assert_eq!(doc, restored);
828        assert_eq!(restored.main().kind(), Some("main"));
829    }
830
831    #[test]
832    fn rejects_unknown_schema_version() {
833        let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
834        assert!(serde_json::from_str::<Document>(json).is_err());
835    }
836
837    #[test]
838    fn peek_schema_version_reads_field_without_full_parse() {
839        let doc = sample();
840        let json = serde_json::to_string(&doc).unwrap();
841        assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_93_0));
842
843        // Unknown future version: peek still succeeds.
844        let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
845        assert_eq!(
846            peek_schema_version(future).as_deref(),
847            Some("quillmark/document@0.99.0")
848        );
849        assert_eq!(peek_schema_version("not json"), None);
850        assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
851    }
852
853    #[test]
854    fn comment_on_dollar_line_round_trips() {
855        // The headline case the unification enables: a `$kind` line with an
856        // inline trailing comment survives a JSON round-trip.
857        let src = "\
858~~~card-yaml
859$quill: q@1.0
860$kind: main # required for root
861title: Hi
862~~~
863";
864        let doc = Document::parse(src).unwrap().document;
865        let json = serde_json::to_string(&doc).unwrap();
866        let restored: Document = serde_json::from_str(&json).unwrap();
867        assert_eq!(doc, restored);
868        // And the emitted markdown carries the comment back on the `$kind` line.
869        assert!(restored
870            .to_markdown()
871            .contains("$kind: main # required for root"));
872    }
873
874    #[test]
875    fn retired_legacy_schema_tags_are_rejected() {
876        // The `@0.81.0` and `@0.82.0` schema tags have no reader: a blob
877        // carrying either is rejected as an unknown version, never migrated.
878        // (Everything persisted on this lineage is `@0.92.0` or newer.)
879        for tag in ["quillmark/document@0.81.0", "quillmark/document@0.82.0"] {
880            let json = format!(
881                r#"{{"schema":"{tag}","main":{{"payload":{{"items":[]}},"body":""}},"cards":[]}}"#
882            );
883            assert!(
884                serde_json::from_str::<Document>(&json).is_err(),
885                "expected {tag} to be rejected as an unknown schema"
886            );
887        }
888    }
889
890    #[test]
891    fn rejects_main_card_without_quill() {
892        let json = r#"{
893            "schema": "quillmark/document@0.92.0",
894            "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
895            "cards": []
896        }"#;
897        let err = serde_json::from_str::<Document>(json).unwrap_err();
898        assert!(err.to_string().contains("$quill"));
899    }
900
901    #[test]
902    fn rejects_composable_card_tagged_main() {
903        let json = r#"{
904            "schema": "quillmark/document@0.92.0",
905            "main": {
906                "payload": {"items": [
907                    {"type": "quill", "value": "q@1.0"},
908                    {"type": "kind", "value": "main"}
909                ]},
910                "body": ""
911            },
912            "cards": [
913                {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
914            ]
915        }"#;
916        let err = serde_json::from_str::<Document>(json).unwrap_err();
917        assert!(err.to_string().contains("reserved (root only)"));
918    }
919
920    #[test]
921    fn rejects_invalid_quill_reference() {
922        let json = r#"{
923            "schema": "quillmark/document@0.92.0",
924            "main": {
925                "payload": {"items": [
926                    {"type": "quill", "value": "not a valid ref!!"},
927                    {"type": "kind", "value": "main"}
928                ]},
929                "body": ""
930            },
931            "cards": []
932        }"#;
933        let err = serde_json::from_str::<Document>(json).unwrap_err();
934        assert!(err.to_string().contains("invalid quill reference"));
935    }
936
937    #[test]
938    fn rejects_composable_card_with_seed() {
939        // `$seed` is root-only (like `$quill`): a stored composable card
940        // carrying it fails to load.
941        let json = r#"{
942            "schema": "quillmark/document@0.92.0",
943            "main": {
944                "payload": {"items": [
945                    {"type": "quill", "value": "q@1.0"},
946                    {"type": "kind", "value": "main"}
947                ]},
948                "body": ""
949            },
950            "cards": [
951                {"payload": {"items": [
952                    {"type": "kind", "value": "indorsement"},
953                    {"type": "seed", "value": {"note": {"from": "X"}}}
954                ]}, "body": ""}
955            ]
956        }"#;
957        let err = serde_json::from_str::<Document>(json).unwrap_err();
958        assert!(err
959            .to_string()
960            .contains("composable cards must not carry a $seed entry"));
961    }
962
963    #[test]
964    fn v0_92_0_seed_item_round_trips() {
965        let json = r#"{
966            "schema": "quillmark/document@0.92.0",
967            "main": {
968                "payload": {"items": [
969                    {"type": "quill", "value": "q@1.0"},
970                    {"type": "kind", "value": "main"},
971                    {"type": "seed", "value": {"indorsement": {"from": "49 FW/CC"}}}
972                ]},
973                "body": ""
974            },
975            "cards": []
976        }"#;
977        let doc: Document = serde_json::from_str(json).unwrap();
978        let overlay = doc
979            .main()
980            .seed()
981            .and_then(|m| m.get("indorsement"))
982            .and_then(crate::SeedOverlay::from_json)
983            .expect("overlay present");
984        assert_eq!(
985            overlay.fields.get("from").and_then(|v| v.as_str()),
986            Some("49 FW/CC")
987        );
988        let reser: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
989        assert_eq!(doc, reser);
990    }
991
992    // ─── V0_93_0 storage cutover ──────────────────────────────────────────────
993
994    /// Slice the value of the first top-level `"body":` object out of a compact
995    /// `serde_json` envelope: the exact bytes embedded, balanced-brace and
996    /// string-aware. Used to prove the body subtree equals `to_canonical_json`.
997    fn locate_body_subtree(envelope: &str) -> &str {
998        const KEY: &str = "\"body\":";
999        let start = envelope.find(KEY).expect("body key present") + KEY.len();
1000        let bytes = envelope.as_bytes();
1001        assert_eq!(
1002            bytes[start], b'{',
1003            "body must embed as a nested object, not an escaped string"
1004        );
1005        let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
1006        for (i, &b) in bytes[start..].iter().enumerate() {
1007            if in_str {
1008                match (escaped, b) {
1009                    (true, _) => escaped = false,
1010                    (false, b'\\') => escaped = true,
1011                    (false, b'"') => in_str = false,
1012                    _ => {}
1013                }
1014                continue;
1015            }
1016            match b {
1017                b'"' => in_str = true,
1018                b'{' => depth += 1,
1019                b'}' => {
1020                    depth -= 1;
1021                    if depth == 0 {
1022                        return &envelope[start..start + i + 1];
1023                    }
1024                }
1025                _ => {}
1026            }
1027        }
1028        panic!("unbalanced body object");
1029    }
1030
1031    #[test]
1032    fn body_subtree_is_byte_identical_to_canonical_json() {
1033        // Two disciplines in one envelope: the outer structure is compact
1034        // insertion-ordered serde_json, but the `body` subtree is the canonical
1035        // richtext form, byte-identical to `rt.to_canonical_json()`.
1036        let doc = Document::parse(
1037            "~~~card-yaml\n$quill: q@0.1\n$kind: main\ntitle: Hi\n~~~\n\n\
1038             A paragraph with **bold**, _emph_, and a [link](https://example.com).\n\n\
1039             Second paragraph continues the content.\n",
1040        )
1041        .unwrap()
1042        .document;
1043        let rt = doc.main().body().clone();
1044        assert!(
1045            !rt.marks.is_empty(),
1046            "test needs a non-trivial content (marks present)"
1047        );
1048        let expected = rt.to_canonical_json();
1049        let envelope = serde_json::to_string(&doc).unwrap();
1050        let body = locate_body_subtree(&envelope);
1051        assert_eq!(
1052            body, expected,
1053            "the envelope body subtree must equal to_canonical_json byte-for-byte"
1054        );
1055        // A nested structure, not a double-encoded string.
1056        assert!(body.starts_with("{\"islands\":"));
1057    }
1058
1059    #[test]
1060    fn v0_93_0_round_trips_as_fixed_point() {
1061        let doc = sample();
1062        let first = serde_json::to_string(&doc).unwrap();
1063        let restored: Document = serde_json::from_str(&first).unwrap();
1064        assert_eq!(doc, restored);
1065        let second = serde_json::to_string(&restored).unwrap();
1066        assert_eq!(
1067            first, second,
1068            "V0_93_0 serialize→deserialize is a byte-fixed point"
1069        );
1070        assert_eq!(peek_schema_version(&first).as_deref(), Some(SCHEMA_V0_93_0));
1071    }
1072
1073    #[test]
1074    fn legacy_table_body_migrates_deterministically_with_islands() {
1075        // A table-bearing 0.92.0 body cold-imports on the 92→93 hop to a content
1076        // whose island ids are sequential (`isl-0`, …). Import is a pure
1077        // function, so the same legacy row migrates to byte-identical storage.
1078        let blob = r#"{
1079            "schema": "quillmark/document@0.92.0",
1080            "main": {
1081                "payload": {"items": [
1082                    {"type": "quill", "value": "q@0.1"},
1083                    {"type": "kind", "value": "main"}
1084                ]},
1085                "body": "| A | B |\n| - | - |\n| 1 | 2 |\n"
1086            },
1087            "cards": []
1088        }"#;
1089        let doc: Document = serde_json::from_str(blob).unwrap();
1090        let body = doc.main().body();
1091        assert_eq!(body.islands.len(), 1, "table imports as one island");
1092        assert_eq!(body.islands[0].id, "isl-0", "sequential island id");
1093        assert_eq!(body.islands[0].island_type, "table");
1094        // Option A: each cell is inline `{text, marks}`, not a raw markdown slice.
1095        // The @0.93.0 table-body canonical bytes changed with this; the freeze is
1096        // branch-private/unreleased, so amending this golden pre-release is
1097        // expected. Regenerated golden below.
1098        let key = body.to_canonical_json();
1099        assert_eq!(
1100            key,
1101            "{\"islands\":[{\"id\":\"isl-0\",\"loss\":\"lossless\",\"props\":{\
1102             \"aligns\":[\"none\",\"none\"],\
1103             \"header\":[{\"marks\":[],\"text\":\"A\"},{\"marks\":[],\"text\":\"B\"}],\
1104             \"rows\":[[{\"marks\":[],\"text\":\"1\"},{\"marks\":[],\"text\":\"2\"}]]},\
1105             \"type\":\"table\"}],\
1106             \"lines\":[{\"containers\":[],\"kind\":\"island\"}],\
1107             \"marks\":[],\"text\":\"\u{FFFC}\"}",
1108            "regenerated @0.93.0 golden: cells are structured text+marks"
1109        );
1110
1111        let again: Document = serde_json::from_str(blob).unwrap();
1112        assert_eq!(
1113            serde_json::to_string(&doc).unwrap(),
1114            serde_json::to_string(&again).unwrap(),
1115            "same legacy input → same migrated bytes"
1116        );
1117        let reser = serde_json::to_string(&doc).unwrap();
1118        assert_eq!(peek_schema_version(&reser).as_deref(), Some(SCHEMA_V0_93_0));
1119    }
1120
1121    #[test]
1122    fn over_nested_legacy_body_is_malformed() {
1123        // A legacy body whose container nesting exceeds MAX_NESTING_DEPTH never
1124        // rendered; the fallible 92→93 import hop maps `NestingTooDeep` to
1125        // `StorageError::Malformed` rather than silently dropping structure.
1126        let deep = ">".repeat(crate::error::MAX_NESTING_DEPTH + 5);
1127        let card = CardV0_92_0 {
1128            payload: PayloadV0_92_0::default(),
1129            body: format!("{deep} too deep"),
1130        };
1131        let err = CardV0_93_0::try_from(card).unwrap_err();
1132        assert!(matches!(err, StorageError::Malformed(_)), "got: {err:?}");
1133        assert!(err.to_string().contains("card body"));
1134    }
1135
1136    #[test]
1137    fn deserialize_rejects_invalid_content_body() {
1138        // `CanonicalContent`'s Deserialize validates: a structurally-embedded
1139        // body whose `lines` count disagrees with its text is rejected at load,
1140        // never silently round-tripped.
1141        let blob = r#"{
1142            "schema": "quillmark/document@0.93.0",
1143            "main": {
1144                "payload": {"items": [
1145                    {"type": "quill", "value": "q@0.1"},
1146                    {"type": "kind", "value": "main"}
1147                ]},
1148                "body": {"text": "a\nb", "lines": [{"kind": "para", "containers": []}], "marks": [], "islands": []}
1149            },
1150            "cards": []
1151        }"#;
1152        assert!(serde_json::from_str::<Document>(blob).is_err());
1153    }
1154}