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        for card in &cards {
440            if card.quill().is_some() {
441                return Err(StorageError::Malformed(
442                    "composable cards must not carry a $quill entry".into(),
443                ));
444            }
445            if card.seed().is_some() {
446                return Err(StorageError::Malformed(
447                    "composable cards must not carry a $seed entry".into(),
448                ));
449            }
450            if let Some(kind) = card.kind() {
451                match validate_composable_kind(kind) {
452                    Ok(()) => {}
453                    Err(super::meta::CardKindError::InvalidName) => {
454                        return Err(StorageError::Malformed(format!(
455                            "invalid composable card kind {kind:?}: must match \
456                             [a-z_][a-z0-9_]*"
457                        )));
458                    }
459                    Err(super::meta::CardKindError::Reserved) => {
460                        return Err(StorageError::Malformed(format!(
461                            "composable card kind {kind:?} is reserved (root only)"
462                        )));
463                    }
464                }
465            }
466        }
467        Ok(Document::from_main_and_cards(main, cards))
468    }
469}
470
471impl TryFrom<CardV0_93_0> for Card {
472    type Error = StorageError;
473
474    fn try_from(card: CardV0_93_0) -> Result<Self, Self::Error> {
475        let payload = Payload::try_from(card.payload)?;
476        validate_dto_payload(&payload)?;
477        // `body` is already a normalized, validated content — `CanonicalContent`
478        // enforced that on deserialize (and the V0_92 → V0_93 migration produced
479        // it via cold import). Take it directly.
480        Ok(Card::from_parts(payload, card.body.0))
481    }
482}
483
484// ─── V0_92_0 → V0_93_0 migration (fallible cold import) ───────────────────────
485//
486// The one hop that can reject: the stored markdown body cold-imports to the
487// content (`import_body`, pure/deterministic). An over-nested body
488// (> MAX_NESTING_DEPTH, surfaced as `ImportError::NestingTooDeep`) never
489// rendered, so mapping it to `StorageError::Malformed` loses nothing
490// renderable. Cross-release byte-stability of a *migrated* row is therefore
491// conditional on `pulldown-cmark` (DOCUMENT_STORAGE.md § byte stability).
492
493impl TryFrom<DocumentV0_92_0> for DocumentV0_93_0 {
494    type Error = StorageError;
495
496    fn try_from(d: DocumentV0_92_0) -> Result<Self, Self::Error> {
497        Ok(DocumentV0_93_0 {
498            main: CardV0_93_0::try_from(d.main)?,
499            cards: d
500                .cards
501                .into_iter()
502                .map(CardV0_93_0::try_from)
503                .collect::<Result<_, _>>()?,
504        })
505    }
506}
507
508impl TryFrom<CardV0_92_0> for CardV0_93_0 {
509    type Error = StorageError;
510
511    fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
512        let body = super::import_body(&card.body)
513            .map_err(|e| StorageError::Malformed(format!("card body: {e}")))?;
514        Ok(CardV0_93_0 {
515            payload: card.payload,
516            body: CanonicalContent(body),
517        })
518    }
519}
520
521impl TryFrom<PayloadV0_92_0> for Payload {
522    type Error = StorageError;
523
524    fn try_from(p: PayloadV0_92_0) -> Result<Self, Self::Error> {
525        let mut items = Vec::with_capacity(p.items.len());
526        for item in p.items {
527            items.push(PayloadItem::try_from(item)?);
528        }
529        let nested = p
530            .nested_comments
531            .into_iter()
532            .map(NestedComment::from)
533            .collect();
534        // Partition the flat wire-format sidecar onto the matching
535        // Field / Ext / Seed items (paths become relative to the owning value).
536        Ok(Payload::from_items_with_flat_nested(items, nested))
537    }
538}
539
540impl TryFrom<PayloadItemV0_92_0> for PayloadItem {
541    type Error = StorageError;
542
543    fn try_from(item: PayloadItemV0_92_0) -> Result<Self, Self::Error> {
544        Ok(match item {
545            PayloadItemV0_92_0::Quill { value } => {
546                let reference = QuillReference::from_str(&value).map_err(|reason| {
547                    StorageError::InvalidQuillReference {
548                        value: value.clone(),
549                        reason,
550                    }
551                })?;
552                PayloadItem::Quill { reference }
553            }
554            PayloadItemV0_92_0::Kind { value } => PayloadItem::Kind { value },
555            PayloadItemV0_92_0::Id { value } => PayloadItem::Id { value },
556            PayloadItemV0_92_0::Ext { value } => PayloadItem::Meta {
557                key: MetaKey::Ext,
558                value: depth_check_meta_map(value, "$ext")?,
559                nested_comments: Vec::new(),
560            },
561            PayloadItemV0_92_0::Seed { value } => PayloadItem::Meta {
562                key: MetaKey::Seed,
563                value: depth_check_meta_map(value, "$seed")?,
564                nested_comments: Vec::new(),
565            },
566            PayloadItemV0_92_0::Field {
567                key,
568                value,
569                fill,
570                nested_fills,
571            } => {
572                use super::edit::{validate_field, FieldViolation};
573                validate_field(&key, &value).map_err(|v| {
574                    StorageError::Malformed(match v {
575                        FieldViolation::InvalidName => {
576                            format!("invalid field name {key:?}: must match [A-Za-z_][A-Za-z0-9_]*")
577                        }
578                        FieldViolation::TooDeep => format!(
579                            "field {key:?} nests deeper than the maximum of {} levels",
580                            crate::document::limits::MAX_YAML_DEPTH
581                        ),
582                    })
583                })?;
584                let mut qv = QuillValue::from_json(value);
585                for path in nested_fills {
586                    let segs: Vec<CommentPathSegment> =
587                        path.into_iter().map(CommentPathSegment::from).collect();
588                    qv.set_fill_at(&segs);
589                }
590                PayloadItem::Field {
591                    key,
592                    value: qv,
593                    fill,
594                    nested_comments: Vec::new(),
595                }
596            }
597            PayloadItemV0_92_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
598        })
599    }
600}
601
602/// Depth-bound a `$ext` / `$seed` mapping at the storage boundary; both flow
603/// through the recursive emit/DTO paths and carry the §8 value-depth limit.
604fn depth_check_meta_map(
605    value: serde_json::Map<String, serde_json::Value>,
606    key: &str,
607) -> Result<serde_json::Map<String, serde_json::Value>, StorageError> {
608    crate::value::depth_check_meta_map(value, |max| {
609        StorageError::Malformed(format!("{key} nests deeper than the maximum of {} levels", max))
610    })
611}
612
613impl From<NestedCommentV0_92_0> for NestedComment {
614    fn from(nc: NestedCommentV0_92_0) -> Self {
615        NestedComment {
616            container_path: nc
617                .container_path
618                .into_iter()
619                .map(CommentPathSegment::from)
620                .collect(),
621            position: nc.position,
622            text: nc.text,
623            inline: nc.inline,
624        }
625    }
626}
627
628impl From<CommentPathSegmentV0_92_0> for CommentPathSegment {
629    fn from(seg: CommentPathSegmentV0_92_0) -> Self {
630        match seg {
631            CommentPathSegmentV0_92_0::Key(k) => CommentPathSegment::Key(k),
632            CommentPathSegmentV0_92_0::Index(i) => CommentPathSegment::Index(i),
633        }
634    }
635}
636
637/// Reject a payload no markdown-parsed `Document` could produce: too many
638/// fields or a duplicate user-field key. The markdown parser already
639/// rejects both; this only guards hand-crafted storage DTOs.
640fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
641    if payload.len() > crate::error::MAX_FIELD_COUNT {
642        return Err(StorageError::Malformed(format!(
643            "card has {} user fields, exceeding the maximum of {}",
644            payload.len(),
645            crate::error::MAX_FIELD_COUNT
646        )));
647    }
648    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
649    for key in payload.keys() {
650        if !seen.insert(key.as_str()) {
651            return Err(StorageError::Malformed(format!(
652                "duplicate user-field key {key:?}"
653            )));
654        }
655    }
656    Ok(())
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    fn sample() -> Document {
664        Document::parse(
665            "\
666~~~card-yaml
667$quill: usaf_memo@0.1
668$kind: main
669# a top-level comment
670memo_for:
671  - ORG/SYMBOL # inline comment inside a sequence
672date: 2504-10-05
673subject: !must_fill Subject of the Memorandum
674~~~
675
676The body of the memorandum.
677
678~~~card-yaml
679$kind: indorsement
680for: ORG/SYMBOL
681from: ORG/SYMBOL
682~~~
683
684This body and the metadata above are an indorsement card.
685",
686        )
687        .unwrap()
688        .document
689    }
690
691    #[test]
692    fn round_trips_through_serde_json() {
693        let doc = sample();
694        let json = serde_json::to_string(&doc).unwrap();
695        let restored: Document = serde_json::from_str(&json).unwrap();
696        assert_eq!(doc, restored);
697        assert_eq!(doc.to_markdown(), restored.to_markdown());
698    }
699
700    #[test]
701    fn content_field_survives_storage_round_trip_losslessly() {
702        // A richtext field stored as a canonical content object is the case the
703        // card-yaml markdown projection is lossy for; the storage DTO is the
704        // lossless carrier, so identity marks (an `underline` with no markdown
705        // form) survive a serde-JSON round-trip that a markdown save would drop.
706        use quillmark_content::model::{Mark, MarkKind};
707
708        let mut doc = sample();
709        let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
710        content.marks.push(Mark {
711            start: 0,
712            end: 10,
713            kind: MarkKind::Underline,
714        });
715        content.normalize();
716        let json = quillmark_content::serial::to_canonical_value(&content);
717        let schema = crate::quill::FieldSchema::new(
718            "intro".to_string(),
719            crate::quill::FieldType::RichText { inline: false },
720            None,
721        );
722        doc.main_mut()
723            .commit_field("intro", crate::QuillValue::from_json(json), &schema)
724            .unwrap();
725
726        let stored = serde_json::to_string(&doc).unwrap();
727        let restored: Document = serde_json::from_str(&stored).unwrap();
728        assert_eq!(doc, restored, "content field must survive storage round-trip");
729        let read = restored.main().field_richtext("intro").unwrap().unwrap();
730        assert!(
731            read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)),
732            "underline (content-only) must survive the DTO carrier"
733        );
734    }
735
736    #[test]
737    fn nested_fill_survives_storage_round_trip() {
738        // A `!must_fill` marker on a nested object leaf rides the `nested_fills`
739        // path list (the JSON `value` projection is fill-free).
740        let doc = Document::parse(
741            "~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n  street: !must_fill\n  city: Anytown\n~~~\n",
742        )
743        .unwrap()
744        .document;
745        let json = serde_json::to_string(&doc).unwrap();
746        let restored: Document = serde_json::from_str(&json).unwrap();
747        assert_eq!(doc, restored, "nested fill must survive storage round-trip");
748        assert!(
749            restored.to_markdown().contains("street: !must_fill"),
750            "Got:\n{}",
751            restored.to_markdown()
752        );
753    }
754
755    #[test]
756    fn root_kind_is_main_through_round_trip() {
757        let doc = Document::parse(
758            "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
759        )
760        .unwrap()
761        .document;
762        assert_eq!(doc.main().kind(), Some("main"));
763        let restored: Document =
764            serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
765        assert_eq!(doc, restored);
766        assert_eq!(restored.main().kind(), Some("main"));
767    }
768
769    #[test]
770    fn rejects_unknown_schema_version() {
771        let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
772        assert!(serde_json::from_str::<Document>(json).is_err());
773    }
774
775    #[test]
776    fn peek_schema_version_reads_field_without_full_parse() {
777        let doc = sample();
778        let json = serde_json::to_string(&doc).unwrap();
779        assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_93_0));
780
781        // Unknown future version: peek still succeeds.
782        let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
783        assert_eq!(
784            peek_schema_version(future).as_deref(),
785            Some("quillmark/document@0.99.0")
786        );
787        assert_eq!(peek_schema_version("not json"), None);
788        assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
789    }
790
791    #[test]
792    fn comment_on_dollar_line_round_trips() {
793        // The headline case the unification enables: a `$kind` line with an
794        // inline trailing comment survives a JSON round-trip.
795        let src = "\
796~~~card-yaml
797$quill: q@1.0
798$kind: main # required for root
799title: Hi
800~~~
801";
802        let doc = Document::parse(src).unwrap().document;
803        let json = serde_json::to_string(&doc).unwrap();
804        let restored: Document = serde_json::from_str(&json).unwrap();
805        assert_eq!(doc, restored);
806        // And the emitted markdown carries the comment back on the `$kind` line.
807        assert!(restored
808            .to_markdown()
809            .contains("$kind: main # required for root"));
810    }
811
812    #[test]
813    fn retired_legacy_schema_tags_are_rejected() {
814        // The `@0.81.0` and `@0.82.0` schema tags have no reader: a blob
815        // carrying either is rejected as an unknown version, never migrated.
816        // (Everything persisted on this lineage is `@0.92.0` or newer.)
817        for tag in ["quillmark/document@0.81.0", "quillmark/document@0.82.0"] {
818            let json = format!(
819                r#"{{"schema":"{tag}","main":{{"payload":{{"items":[]}},"body":""}},"cards":[]}}"#
820            );
821            assert!(
822                serde_json::from_str::<Document>(&json).is_err(),
823                "expected {tag} to be rejected as an unknown schema"
824            );
825        }
826    }
827
828    #[test]
829    fn rejects_main_card_without_quill() {
830        let json = r#"{
831            "schema": "quillmark/document@0.92.0",
832            "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
833            "cards": []
834        }"#;
835        let err = serde_json::from_str::<Document>(json).unwrap_err();
836        assert!(err.to_string().contains("$quill"));
837    }
838
839    #[test]
840    fn rejects_composable_card_tagged_main() {
841        let json = r#"{
842            "schema": "quillmark/document@0.92.0",
843            "main": {
844                "payload": {"items": [
845                    {"type": "quill", "value": "q@1.0"},
846                    {"type": "kind", "value": "main"}
847                ]},
848                "body": ""
849            },
850            "cards": [
851                {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
852            ]
853        }"#;
854        let err = serde_json::from_str::<Document>(json).unwrap_err();
855        assert!(err.to_string().contains("reserved (root only)"));
856    }
857
858    #[test]
859    fn rejects_invalid_quill_reference() {
860        let json = r#"{
861            "schema": "quillmark/document@0.92.0",
862            "main": {
863                "payload": {"items": [
864                    {"type": "quill", "value": "not a valid ref!!"},
865                    {"type": "kind", "value": "main"}
866                ]},
867                "body": ""
868            },
869            "cards": []
870        }"#;
871        let err = serde_json::from_str::<Document>(json).unwrap_err();
872        assert!(err.to_string().contains("invalid quill reference"));
873    }
874
875    #[test]
876    fn rejects_composable_card_with_seed() {
877        // `$seed` is root-only (like `$quill`): a stored composable card
878        // carrying it fails to load.
879        let json = r#"{
880            "schema": "quillmark/document@0.92.0",
881            "main": {
882                "payload": {"items": [
883                    {"type": "quill", "value": "q@1.0"},
884                    {"type": "kind", "value": "main"}
885                ]},
886                "body": ""
887            },
888            "cards": [
889                {"payload": {"items": [
890                    {"type": "kind", "value": "indorsement"},
891                    {"type": "seed", "value": {"note": {"from": "X"}}}
892                ]}, "body": ""}
893            ]
894        }"#;
895        let err = serde_json::from_str::<Document>(json).unwrap_err();
896        assert!(err
897            .to_string()
898            .contains("composable cards must not carry a $seed entry"));
899    }
900
901    #[test]
902    fn v0_92_0_seed_item_round_trips() {
903        let json = r#"{
904            "schema": "quillmark/document@0.92.0",
905            "main": {
906                "payload": {"items": [
907                    {"type": "quill", "value": "q@1.0"},
908                    {"type": "kind", "value": "main"},
909                    {"type": "seed", "value": {"indorsement": {"from": "49 FW/CC"}}}
910                ]},
911                "body": ""
912            },
913            "cards": []
914        }"#;
915        let doc: Document = serde_json::from_str(json).unwrap();
916        let overlay = doc
917            .main()
918            .seed()
919            .and_then(|m| m.get("indorsement"))
920            .and_then(crate::SeedOverlay::from_json)
921            .expect("overlay present");
922        assert_eq!(
923            overlay.fields.get("from").and_then(|v| v.as_str()),
924            Some("49 FW/CC")
925        );
926        let reser: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
927        assert_eq!(doc, reser);
928    }
929
930    // ─── V0_93_0 storage cutover ──────────────────────────────────────────────
931
932    /// Slice the value of the first top-level `"body":` object out of a compact
933    /// `serde_json` envelope — the exact bytes embedded, balanced-brace and
934    /// string-aware. Used to prove the body subtree equals `to_canonical_json`.
935    fn locate_body_subtree(envelope: &str) -> &str {
936        const KEY: &str = "\"body\":";
937        let start = envelope.find(KEY).expect("body key present") + KEY.len();
938        let bytes = envelope.as_bytes();
939        assert_eq!(
940            bytes[start], b'{',
941            "body must embed as a nested object, not an escaped string"
942        );
943        let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
944        for (i, &b) in bytes[start..].iter().enumerate() {
945            if in_str {
946                match (escaped, b) {
947                    (true, _) => escaped = false,
948                    (false, b'\\') => escaped = true,
949                    (false, b'"') => in_str = false,
950                    _ => {}
951                }
952                continue;
953            }
954            match b {
955                b'"' => in_str = true,
956                b'{' => depth += 1,
957                b'}' => {
958                    depth -= 1;
959                    if depth == 0 {
960                        return &envelope[start..start + i + 1];
961                    }
962                }
963                _ => {}
964            }
965        }
966        panic!("unbalanced body object");
967    }
968
969    #[test]
970    fn body_subtree_is_byte_identical_to_canonical_json() {
971        // Two disciplines in one envelope: the outer structure is compact
972        // insertion-ordered serde_json, but the `body` subtree is the canonical
973        // richtext form, byte-identical to `rt.to_canonical_json()`.
974        let doc = Document::parse(
975            "~~~card-yaml\n$quill: q@0.1\n$kind: main\ntitle: Hi\n~~~\n\n\
976             A paragraph with **bold**, _emph_, and a [link](https://example.com).\n\n\
977             Second paragraph continues the content.\n",
978        )
979        .unwrap()
980        .document;
981        let rt = doc.main().body().clone();
982        assert!(
983            !rt.marks.is_empty(),
984            "test needs a non-trivial content (marks present)"
985        );
986        let expected = rt.to_canonical_json();
987        let envelope = serde_json::to_string(&doc).unwrap();
988        let body = locate_body_subtree(&envelope);
989        assert_eq!(
990            body, expected,
991            "the envelope body subtree must equal to_canonical_json byte-for-byte"
992        );
993        // A nested structure, not a double-encoded string.
994        assert!(body.starts_with("{\"islands\":"));
995    }
996
997    #[test]
998    fn v0_93_0_round_trips_as_fixed_point() {
999        let doc = sample();
1000        let first = serde_json::to_string(&doc).unwrap();
1001        let restored: Document = serde_json::from_str(&first).unwrap();
1002        assert_eq!(doc, restored);
1003        let second = serde_json::to_string(&restored).unwrap();
1004        assert_eq!(
1005            first, second,
1006            "V0_93_0 serialize→deserialize is a byte-fixed point"
1007        );
1008        assert_eq!(peek_schema_version(&first).as_deref(), Some(SCHEMA_V0_93_0));
1009    }
1010
1011    #[test]
1012    fn legacy_table_body_migrates_deterministically_with_islands() {
1013        // A table-bearing 0.92.0 body cold-imports on the 92→93 hop to a content
1014        // whose island ids are sequential (`isl-0`, …). Import is a pure
1015        // function, so the same legacy row migrates to byte-identical storage.
1016        let blob = r#"{
1017            "schema": "quillmark/document@0.92.0",
1018            "main": {
1019                "payload": {"items": [
1020                    {"type": "quill", "value": "q@0.1"},
1021                    {"type": "kind", "value": "main"}
1022                ]},
1023                "body": "| A | B |\n| - | - |\n| 1 | 2 |\n"
1024            },
1025            "cards": []
1026        }"#;
1027        let doc: Document = serde_json::from_str(blob).unwrap();
1028        let body = doc.main().body();
1029        assert_eq!(body.islands.len(), 1, "table imports as one island");
1030        assert_eq!(body.islands[0].id, "isl-0", "sequential island id");
1031        assert_eq!(body.islands[0].island_type, "table");
1032        // Option A: each cell is inline `{text, marks}`, not a raw markdown slice.
1033        // The @0.93.0 table-body canonical bytes changed with this; the freeze is
1034        // branch-private/unreleased, so amending this golden pre-release is
1035        // expected. Regenerated golden below.
1036        let key = body.to_canonical_json();
1037        assert_eq!(
1038            key,
1039            "{\"islands\":[{\"id\":\"isl-0\",\"loss\":\"lossless\",\"props\":{\
1040             \"aligns\":[\"none\",\"none\"],\
1041             \"header\":[{\"marks\":[],\"text\":\"A\"},{\"marks\":[],\"text\":\"B\"}],\
1042             \"rows\":[[{\"marks\":[],\"text\":\"1\"},{\"marks\":[],\"text\":\"2\"}]]},\
1043             \"type\":\"table\"}],\
1044             \"lines\":[{\"containers\":[],\"kind\":\"island\"}],\
1045             \"marks\":[],\"text\":\"\u{FFFC}\"}",
1046            "regenerated @0.93.0 golden: cells are structured text+marks"
1047        );
1048
1049        let again: Document = serde_json::from_str(blob).unwrap();
1050        assert_eq!(
1051            serde_json::to_string(&doc).unwrap(),
1052            serde_json::to_string(&again).unwrap(),
1053            "same legacy input → same migrated bytes"
1054        );
1055        let reser = serde_json::to_string(&doc).unwrap();
1056        assert_eq!(peek_schema_version(&reser).as_deref(), Some(SCHEMA_V0_93_0));
1057    }
1058
1059    #[test]
1060    fn over_nested_legacy_body_is_malformed() {
1061        // A legacy body whose container nesting exceeds MAX_NESTING_DEPTH never
1062        // rendered; the fallible 92→93 import hop maps `NestingTooDeep` to
1063        // `StorageError::Malformed` rather than silently dropping structure.
1064        let deep = ">".repeat(crate::error::MAX_NESTING_DEPTH + 5);
1065        let card = CardV0_92_0 {
1066            payload: PayloadV0_92_0::default(),
1067            body: format!("{deep} too deep"),
1068        };
1069        let err = CardV0_93_0::try_from(card).unwrap_err();
1070        assert!(matches!(err, StorageError::Malformed(_)), "got: {err:?}");
1071        assert!(err.to_string().contains("card body"));
1072    }
1073
1074    #[test]
1075    fn deserialize_rejects_invalid_content_body() {
1076        // `CanonicalContent`'s Deserialize validates: a structurally-embedded
1077        // body whose `lines` count disagrees with its text is rejected at load,
1078        // never silently round-tripped.
1079        let blob = r#"{
1080            "schema": "quillmark/document@0.93.0",
1081            "main": {
1082                "payload": {"items": [
1083                    {"type": "quill", "value": "q@0.1"},
1084                    {"type": "kind", "value": "main"}
1085                ]},
1086                "body": {"text": "a\nb", "lines": [{"kind": "para", "containers": []}], "marks": [], "islands": []}
1087            },
1088            "cards": []
1089        }"#;
1090        assert!(serde_json::from_str::<Document>(blob).is_err());
1091    }
1092}