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