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