Skip to main content

quillmark_core/document/
dto.rs

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