Skip to main content

quillmark_core/document/
dto.rs

1//! Versioned, storage-stable serialization for [`Document`].
2//!
3//! [`Document`] and its component types (`Card`, `Sentinel`, `Frontmatter`,
4//! …) track the evolving Quillmark model; their in-memory layout is an
5//! internal detail and is deliberately *not* serialized directly. To persist
6//! a document — e.g. in a database — it is converted to a [`StoredDocument`]:
7//! a versioned 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//! ```
14//! use quillmark_core::Document;
15//!
16//! let doc = Document::from_markdown(
17//!     "---\nQUILL: my_quill\ntitle: Hi\n---\n\nBody.\n"
18//! ).unwrap();
19//!
20//! let json = serde_json::to_string(&doc).unwrap();
21//! assert!(json.contains("\"schema\""));
22//!
23//! let restored: Document = serde_json::from_str(&json).unwrap();
24//! assert_eq!(doc, restored);
25//! ```
26//!
27//! ## Schema versioning
28//!
29//! The schema version tracks the crate version at which the `Document` model
30//! was last changed — currently `0.81.0` (see [`SCHEMA_V0_81_0`]). A model change
31//! adds a new [`StoredDocument`] variant with its own frozen type tree and a
32//! migration; older variants stay frozen so previously stored rows keep
33//! deserializing.
34//!
35//! The canonical design — including the step-by-step procedure for adding a
36//! schema version — is `prose/canon/DOCUMENT_STORAGE.md`.
37
38// Storage DTO types are deliberately named after the crate version that
39// fixed their shape (e.g. `DocumentV0_81_0`); the underscores are intentional.
40#![allow(non_camel_case_types)]
41
42use std::str::FromStr;
43
44use serde::{Deserialize, Serialize};
45
46use super::frontmatter::{Frontmatter, FrontmatterItem};
47use super::prescan::{CommentPathSegment, NestedComment};
48use super::{Card, Document, Sentinel};
49use crate::value::QuillValue;
50use crate::version::QuillReference;
51
52/// Schema version for the Document model as established in crate version
53/// `0.81.0`. Bumped only when the model itself changes.
54pub const SCHEMA_V0_81_0: &str = "quillmark/document@0.81.0";
55
56/// Read the `schema` field from a raw storage DTO payload without performing
57/// full deserialization.
58///
59/// Returns `None` if `json` is not valid JSON, is not an object, or has no
60/// `schema` field. The returned string is **not** validated against the set
61/// of supported schema versions — callers use this to distinguish "unknown
62/// future version" from "corrupt payload" when [`Document`] deserialization
63/// fails.
64pub fn peek_schema_version(json: &str) -> Option<String> {
65    #[derive(Deserialize)]
66    struct Peek {
67        schema: Option<String>,
68    }
69    serde_json::from_str::<Peek>(json).ok()?.schema
70}
71
72/// Versioned envelope for a persisted [`Document`].
73///
74/// The `schema` field selects the payload version. Deserialization
75/// dispatches on it; unknown values are rejected. New schema versions are
76/// added as new variants, leaving existing ones byte-stable.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(tag = "schema")]
79pub enum StoredDocument {
80    /// Document model as of crate version `0.81.0`.
81    #[serde(rename = "quillmark/document@0.81.0")]
82    V0_81_0(DocumentV0_81_0),
83}
84
85/// Frozen `0.81.0` representation of a [`Document`].
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct DocumentV0_81_0 {
88    /// The document entry card.
89    pub main: CardV0_81_0,
90    /// Composable cards, in order.
91    #[serde(default)]
92    pub cards: Vec<CardV0_81_0>,
93}
94
95/// Frozen `0.81.0` representation of a [`Card`].
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct CardV0_81_0 {
98    /// Card discriminator.
99    pub sentinel: SentinelV0_81_0,
100    /// Ordered frontmatter.
101    #[serde(default)]
102    pub frontmatter: FrontmatterV0_81_0,
103    /// Markdown body following the card fence.
104    #[serde(default)]
105    pub body: String,
106}
107
108/// Frozen `0.81.0` representation of a [`Sentinel`].
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(tag = "kind", rename_all = "lowercase")]
111pub enum SentinelV0_81_0 {
112    /// Document entry card. `quill` is the rendered quill reference string
113    /// (e.g. `usaf_memo@0.1`), parsed back via [`QuillReference::from_str`].
114    Main {
115        /// Quill reference string.
116        quill: String,
117    },
118    /// Composable card with a kind tag.
119    Card {
120        /// Card kind tag.
121        tag: String,
122    },
123}
124
125/// Frozen `0.81.0` representation of a [`Frontmatter`].
126#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
127pub struct FrontmatterV0_81_0 {
128    /// Ordered fields and top-level comments.
129    #[serde(default)]
130    pub items: Vec<FrontmatterItemV0_81_0>,
131    /// Comments captured inside nested mappings/sequences.
132    #[serde(default)]
133    pub nested_comments: Vec<NestedCommentV0_81_0>,
134}
135
136/// Frozen `0.81.0` representation of a [`FrontmatterItem`].
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "lowercase")]
139pub enum FrontmatterItemV0_81_0 {
140    /// A YAML field.
141    Field {
142        /// Field key.
143        key: String,
144        /// Field value as raw JSON.
145        value: serde_json::Value,
146        /// `true` when tagged `!fill` in source.
147        #[serde(default)]
148        fill: bool,
149    },
150    /// A YAML comment.
151    Comment {
152        /// Comment text (no leading `#`).
153        text: String,
154        /// `true` for trailing inline comments.
155        #[serde(default)]
156        inline: bool,
157    },
158}
159
160/// Frozen `0.81.0` representation of a [`NestedComment`].
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct NestedCommentV0_81_0 {
163    /// Path to the immediate parent container.
164    pub container_path: Vec<CommentPathSegmentV0_81_0>,
165    /// Slot or host index (see [`NestedComment`]).
166    pub position: usize,
167    /// Comment text.
168    pub text: String,
169    /// `true` for trailing inline comments.
170    pub inline: bool,
171}
172
173/// Frozen `0.81.0` representation of a [`CommentPathSegment`].
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub enum CommentPathSegmentV0_81_0 {
176    /// Mapping key.
177    Key(String),
178    /// Sequence index.
179    Index(usize),
180}
181
182/// Failure while reconstructing a [`Document`] from a [`StoredDocument`].
183#[derive(Debug, Clone, PartialEq)]
184pub enum StorageError {
185    /// A stored quill reference string could not be parsed.
186    InvalidQuillReference {
187        /// The offending string.
188        value: String,
189        /// Parser explanation.
190        reason: String,
191    },
192    /// The document's `main` card carried a non-`Main` sentinel. The entry
193    /// card must carry a `QUILL` reference.
194    MainCardNotMain,
195    /// A composable card carried a `Main` sentinel; only `main` may.
196    ComposableCardIsMain,
197    /// A composable card's tag was not a valid `[a-z_][a-z0-9_]*` identifier.
198    InvalidCardTag {
199        /// The offending tag.
200        tag: String,
201    },
202    /// A card carried more frontmatter fields than
203    /// [`MAX_FIELD_COUNT`](crate::error::MAX_FIELD_COUNT).
204    TooManyFields {
205        /// The field count found.
206        count: usize,
207    },
208    /// A frontmatter field used a reserved sentinel key
209    /// (`BODY`, `CARDS`, `QUILL`, `CARD`).
210    ReservedFieldName {
211        /// The offending key.
212        key: String,
213    },
214    /// Two frontmatter fields in the same card shared a key.
215    DuplicateFieldKey {
216        /// The duplicated key.
217        key: String,
218    },
219}
220
221impl std::fmt::Display for StorageError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            StorageError::InvalidQuillReference { value, reason } => {
225                write!(f, "invalid quill reference {value:?}: {reason}")
226            }
227            StorageError::MainCardNotMain => {
228                write!(f, "main card must carry a QUILL sentinel, not a card tag")
229            }
230            StorageError::ComposableCardIsMain => write!(
231                f,
232                "composable cards must carry a card tag, not a QUILL sentinel"
233            ),
234            StorageError::InvalidCardTag { tag } => {
235                write!(f, "invalid card tag {tag:?}: must match [a-z_][a-z0-9_]*")
236            }
237            StorageError::TooManyFields { count } => write!(
238                f,
239                "card has {count} frontmatter fields, exceeding the maximum of {}",
240                crate::error::MAX_FIELD_COUNT
241            ),
242            StorageError::ReservedFieldName { key } => {
243                write!(f, "reserved name {key:?} cannot be used as a field name")
244            }
245            StorageError::DuplicateFieldKey { key } => {
246                write!(f, "duplicate frontmatter field key {key:?}")
247            }
248        }
249    }
250}
251
252/// Reject a frontmatter no markdown-parsed `Document` could produce: too many
253/// fields, a reserved sentinel key, or a duplicate key. The markdown parser
254/// already rejects all three, so this only guards hand-crafted storage DTOs.
255fn validate_dto_frontmatter(fm: &Frontmatter) -> Result<(), StorageError> {
256    if fm.len() > crate::error::MAX_FIELD_COUNT {
257        return Err(StorageError::TooManyFields { count: fm.len() });
258    }
259    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
260    for key in fm.keys() {
261        if super::edit::is_reserved_name(key) {
262            return Err(StorageError::ReservedFieldName { key: key.clone() });
263        }
264        if !seen.insert(key.as_str()) {
265            return Err(StorageError::DuplicateFieldKey { key: key.clone() });
266        }
267    }
268    Ok(())
269}
270
271impl std::error::Error for StorageError {}
272
273// ── Document → StoredDocument (infallible) ────────────────────────────────────
274
275impl From<Document> for StoredDocument {
276    fn from(doc: Document) -> Self {
277        StoredDocument::V0_81_0(DocumentV0_81_0::from(&doc))
278    }
279}
280
281impl From<&Document> for DocumentV0_81_0 {
282    fn from(doc: &Document) -> Self {
283        DocumentV0_81_0 {
284            main: CardV0_81_0::from(doc.main()),
285            cards: doc.cards().iter().map(CardV0_81_0::from).collect(),
286        }
287    }
288}
289
290impl From<&Card> for CardV0_81_0 {
291    fn from(card: &Card) -> Self {
292        CardV0_81_0 {
293            sentinel: SentinelV0_81_0::from(card.sentinel()),
294            frontmatter: FrontmatterV0_81_0::from(card.frontmatter()),
295            body: card.body().to_string(),
296        }
297    }
298}
299
300impl From<&Sentinel> for SentinelV0_81_0 {
301    fn from(sentinel: &Sentinel) -> Self {
302        match sentinel {
303            Sentinel::Main(reference) => SentinelV0_81_0::Main {
304                quill: reference.to_string(),
305            },
306            Sentinel::Card(tag) => SentinelV0_81_0::Card { tag: tag.clone() },
307        }
308    }
309}
310
311impl From<&Frontmatter> for FrontmatterV0_81_0 {
312    fn from(fm: &Frontmatter) -> Self {
313        FrontmatterV0_81_0 {
314            items: fm
315                .items()
316                .iter()
317                .map(FrontmatterItemV0_81_0::from)
318                .collect(),
319            nested_comments: fm
320                .nested_comments()
321                .iter()
322                .map(NestedCommentV0_81_0::from)
323                .collect(),
324        }
325    }
326}
327
328impl From<&FrontmatterItem> for FrontmatterItemV0_81_0 {
329    fn from(item: &FrontmatterItem) -> Self {
330        match item {
331            FrontmatterItem::Field { key, value, fill } => FrontmatterItemV0_81_0::Field {
332                key: key.clone(),
333                value: value.as_json().clone(),
334                fill: *fill,
335            },
336            FrontmatterItem::Comment { text, inline } => FrontmatterItemV0_81_0::Comment {
337                text: text.clone(),
338                inline: *inline,
339            },
340        }
341    }
342}
343
344impl From<&NestedComment> for NestedCommentV0_81_0 {
345    fn from(nc: &NestedComment) -> Self {
346        NestedCommentV0_81_0 {
347            container_path: nc
348                .container_path
349                .iter()
350                .map(CommentPathSegmentV0_81_0::from)
351                .collect(),
352            position: nc.position,
353            text: nc.text.clone(),
354            inline: nc.inline,
355        }
356    }
357}
358
359impl From<&CommentPathSegment> for CommentPathSegmentV0_81_0 {
360    fn from(seg: &CommentPathSegment) -> Self {
361        match seg {
362            CommentPathSegment::Key(k) => CommentPathSegmentV0_81_0::Key(k.clone()),
363            CommentPathSegment::Index(i) => CommentPathSegmentV0_81_0::Index(*i),
364        }
365    }
366}
367
368// ── StoredDocument → Document (fallible) ──────────────────────────────────────
369
370impl TryFrom<StoredDocument> for Document {
371    type Error = StorageError;
372
373    fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
374        match stored {
375            StoredDocument::V0_81_0(payload) => Document::try_from(payload),
376        }
377    }
378}
379
380impl TryFrom<DocumentV0_81_0> for Document {
381    type Error = StorageError;
382
383    fn try_from(payload: DocumentV0_81_0) -> Result<Self, Self::Error> {
384        let main = Card::try_from(payload.main)?;
385        if !main.sentinel().is_main() {
386            return Err(StorageError::MainCardNotMain);
387        }
388        let cards = payload
389            .cards
390            .into_iter()
391            .map(Card::try_from)
392            .collect::<Result<Vec<_>, _>>()?;
393        if cards.iter().any(|c| c.sentinel().is_main()) {
394            return Err(StorageError::ComposableCardIsMain);
395        }
396        Ok(Document::from_main_and_cards(main, cards))
397    }
398}
399
400impl TryFrom<CardV0_81_0> for Card {
401    type Error = StorageError;
402
403    fn try_from(card: CardV0_81_0) -> Result<Self, Self::Error> {
404        let sentinel = Sentinel::try_from(card.sentinel)?;
405        let frontmatter = Frontmatter::from(card.frontmatter);
406        validate_dto_frontmatter(&frontmatter)?;
407        Ok(Card::new_with_sentinel(sentinel, frontmatter, card.body))
408    }
409}
410
411impl TryFrom<SentinelV0_81_0> for Sentinel {
412    type Error = StorageError;
413
414    fn try_from(sentinel: SentinelV0_81_0) -> Result<Self, Self::Error> {
415        match sentinel {
416            SentinelV0_81_0::Main { quill } => {
417                let reference = QuillReference::from_str(&quill).map_err(|reason| {
418                    StorageError::InvalidQuillReference {
419                        value: quill.clone(),
420                        reason,
421                    }
422                })?;
423                Ok(Sentinel::Main(reference))
424            }
425            SentinelV0_81_0::Card { tag } => {
426                if !super::sentinel::is_valid_tag_name(&tag) {
427                    return Err(StorageError::InvalidCardTag { tag });
428                }
429                Ok(Sentinel::Card(tag))
430            }
431        }
432    }
433}
434
435impl From<FrontmatterV0_81_0> for Frontmatter {
436    fn from(fm: FrontmatterV0_81_0) -> Self {
437        let items = fm.items.into_iter().map(FrontmatterItem::from).collect();
438        let nested = fm
439            .nested_comments
440            .into_iter()
441            .map(NestedComment::from)
442            .collect();
443        Frontmatter::from_items_with_nested(items, nested)
444    }
445}
446
447impl From<FrontmatterItemV0_81_0> for FrontmatterItem {
448    fn from(item: FrontmatterItemV0_81_0) -> Self {
449        match item {
450            FrontmatterItemV0_81_0::Field { key, value, fill } => FrontmatterItem::Field {
451                key,
452                value: QuillValue::from_json(value),
453                fill,
454            },
455            FrontmatterItemV0_81_0::Comment { text, inline } => {
456                FrontmatterItem::Comment { text, inline }
457            }
458        }
459    }
460}
461
462impl From<NestedCommentV0_81_0> for NestedComment {
463    fn from(nc: NestedCommentV0_81_0) -> Self {
464        NestedComment {
465            container_path: nc
466                .container_path
467                .into_iter()
468                .map(CommentPathSegment::from)
469                .collect(),
470            position: nc.position,
471            text: nc.text,
472            inline: nc.inline,
473        }
474    }
475}
476
477impl From<CommentPathSegmentV0_81_0> for CommentPathSegment {
478    fn from(seg: CommentPathSegmentV0_81_0) -> Self {
479        match seg {
480            CommentPathSegmentV0_81_0::Key(k) => CommentPathSegment::Key(k),
481            CommentPathSegmentV0_81_0::Index(i) => CommentPathSegment::Index(i),
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    fn sample() -> Document {
491        Document::from_markdown(
492            "\
493---
494QUILL: usaf_memo@0.1
495# a top-level comment
496memo_for:
497  - ORG/SYMBOL # inline comment inside a sequence
498date: 2504-10-05
499subject: !fill Subject of the Memorandum
500---
501
502The body of the memorandum.
503
504```card indorsement
505for: ORG/SYMBOL
506from: ORG/SYMBOL
507```
508
509This body and the metadata above are an indorsement card.
510",
511        )
512        .unwrap()
513    }
514
515    #[test]
516    fn round_trips_through_serde_json() {
517        let doc = sample();
518        let json = serde_json::to_string(&doc).unwrap();
519        let restored: Document = serde_json::from_str(&json).unwrap();
520        assert_eq!(doc, restored);
521        assert_eq!(doc.to_markdown(), restored.to_markdown());
522    }
523
524    #[test]
525    fn serialization_is_byte_deterministic() {
526        // The wasm `toJson` docstring promises byte-equal output for
527        // equal documents within a schema version; consumers content-hash
528        // the result for divergence detection. Three guarantees are
529        // checked: re-serialization stability, round-trip stability, and
530        // path-independence — a parsed document and a DTO-restored copy of
531        // it serialize to the same bytes.
532        let doc = sample();
533        let first = serde_json::to_string(&doc).unwrap();
534        let second = serde_json::to_string(&doc).unwrap();
535        assert_eq!(
536            first, second,
537            "to_string must be deterministic (byte-equal on repeated calls)"
538        );
539        let restored: Document = serde_json::from_str(&first).unwrap();
540        let third = serde_json::to_string(&restored).unwrap();
541        assert_eq!(
542            first, third,
543            "byte-equality must survive a round-trip through fromJson/toJson"
544        );
545
546        // Path independence: documents arrived at by different routes but
547        // value-equal must serialize identically.
548        let from_markdown = sample();
549        let from_dto: Document =
550            serde_json::from_str(&serde_json::to_string(&from_markdown).unwrap()).unwrap();
551        assert_eq!(from_markdown, from_dto);
552        assert_eq!(
553            serde_json::to_string(&from_markdown).unwrap(),
554            serde_json::to_string(&from_dto).unwrap(),
555            "value-equal documents must serialize to byte-equal strings regardless of construction path"
556        );
557    }
558
559    #[test]
560    fn serialized_form_carries_schema_version() {
561        let doc = sample();
562        let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
563        assert_eq!(value["schema"], SCHEMA_V0_81_0);
564    }
565
566    #[test]
567    fn rejects_unknown_schema_version() {
568        let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
569        assert!(serde_json::from_str::<Document>(json).is_err());
570    }
571
572    #[test]
573    fn peek_schema_version_reads_field_without_full_parse() {
574        let doc = sample();
575        let json = serde_json::to_string(&doc).unwrap();
576        assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_81_0));
577
578        // Unknown future version: peek still succeeds, even though full
579        // deserialization would reject it.
580        let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
581        assert_eq!(
582            peek_schema_version(future).as_deref(),
583            Some("quillmark/document@0.99.0")
584        );
585
586        // Not JSON, no schema field, wrong type — all None.
587        assert_eq!(peek_schema_version("not json"), None);
588        assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
589        assert_eq!(peek_schema_version(r#"{"schema":42}"#), None);
590        assert_eq!(peek_schema_version("[1,2,3]"), None);
591    }
592
593    #[test]
594    fn rejects_invalid_quill_reference() {
595        let stored = StoredDocument::V0_81_0(DocumentV0_81_0 {
596            main: CardV0_81_0 {
597                sentinel: SentinelV0_81_0::Main {
598                    quill: "not a valid ref!!".to_string(),
599                },
600                frontmatter: FrontmatterV0_81_0::default(),
601                body: String::new(),
602            },
603            cards: Vec::new(),
604        });
605        let err = Document::try_from(stored).unwrap_err();
606        assert!(matches!(err, StorageError::InvalidQuillReference { .. }));
607    }
608
609    #[test]
610    fn rejects_main_card_with_card_sentinel() {
611        // A crafted DTO whose `main` carries a card tag instead of a QUILL
612        // reference must be rejected — not built into a Document that later
613        // panics in `to_markdown()` / the render path.
614        let json = r#"{"schema":"quillmark/document@0.81.0",
615            "main":{"sentinel":{"kind":"card","tag":"x"},"frontmatter":{},"body":""}}"#;
616        let err = serde_json::from_str::<Document>(json).unwrap_err();
617        assert!(err.to_string().contains("main card must carry a QUILL sentinel"));
618    }
619
620    #[test]
621    fn rejects_composable_card_with_main_sentinel() {
622        let stored = StoredDocument::V0_81_0(DocumentV0_81_0 {
623            main: CardV0_81_0 {
624                sentinel: SentinelV0_81_0::Main {
625                    quill: "usaf_memo@0.1".to_string(),
626                },
627                frontmatter: FrontmatterV0_81_0::default(),
628                body: String::new(),
629            },
630            cards: vec![CardV0_81_0 {
631                sentinel: SentinelV0_81_0::Main {
632                    quill: "usaf_memo@0.1".to_string(),
633                },
634                frontmatter: FrontmatterV0_81_0::default(),
635                body: String::new(),
636            }],
637        });
638        let err = Document::try_from(stored).unwrap_err();
639        assert_eq!(err, StorageError::ComposableCardIsMain);
640    }
641
642    /// A minimal valid main card wrapping `fm`, with no composable cards.
643    fn doc_with_main_frontmatter(fm: FrontmatterV0_81_0) -> StoredDocument {
644        StoredDocument::V0_81_0(DocumentV0_81_0 {
645            main: CardV0_81_0 {
646                sentinel: SentinelV0_81_0::Main {
647                    quill: "usaf_memo@0.1".to_string(),
648                },
649                frontmatter: fm,
650                body: String::new(),
651            },
652            cards: Vec::new(),
653        })
654    }
655
656    fn dto_field(key: &str) -> FrontmatterItemV0_81_0 {
657        FrontmatterItemV0_81_0::Field {
658            key: key.to_string(),
659            value: serde_json::json!("x"),
660            fill: false,
661        }
662    }
663
664    #[test]
665    fn rejects_invalid_card_tag() {
666        let stored = StoredDocument::V0_81_0(DocumentV0_81_0 {
667            main: CardV0_81_0 {
668                sentinel: SentinelV0_81_0::Main {
669                    quill: "usaf_memo@0.1".to_string(),
670                },
671                frontmatter: FrontmatterV0_81_0::default(),
672                body: String::new(),
673            },
674            cards: vec![CardV0_81_0 {
675                sentinel: SentinelV0_81_0::Card {
676                    tag: "Bad Tag".to_string(),
677                },
678                frontmatter: FrontmatterV0_81_0::default(),
679                body: String::new(),
680            }],
681        });
682        let err = Document::try_from(stored).unwrap_err();
683        assert!(matches!(err, StorageError::InvalidCardTag { .. }));
684    }
685
686    #[test]
687    fn rejects_reserved_field_name() {
688        let fm = FrontmatterV0_81_0 {
689            items: vec![dto_field("BODY")],
690            nested_comments: Vec::new(),
691        };
692        let err = Document::try_from(doc_with_main_frontmatter(fm)).unwrap_err();
693        assert!(matches!(err, StorageError::ReservedFieldName { .. }));
694    }
695
696    #[test]
697    fn rejects_duplicate_field_key() {
698        let fm = FrontmatterV0_81_0 {
699            items: vec![dto_field("a"), dto_field("a")],
700            nested_comments: Vec::new(),
701        };
702        let err = Document::try_from(doc_with_main_frontmatter(fm)).unwrap_err();
703        assert!(matches!(err, StorageError::DuplicateFieldKey { .. }));
704    }
705
706    #[test]
707    fn rejects_too_many_fields() {
708        let items = (0..=crate::error::MAX_FIELD_COUNT)
709            .map(|i| dto_field(&format!("f{i}")))
710            .collect();
711        let fm = FrontmatterV0_81_0 {
712            items,
713            nested_comments: Vec::new(),
714        };
715        let err = Document::try_from(doc_with_main_frontmatter(fm)).unwrap_err();
716        assert!(matches!(err, StorageError::TooManyFields { .. }));
717    }
718
719    #[test]
720    fn accepts_non_identifier_field_keys() {
721        // The markdown parser produces keys like `memo-for`; the DTO must
722        // round-trip them rather than reject them on charset grounds.
723        let fm = FrontmatterV0_81_0 {
724            items: vec![dto_field("memo-for")],
725            nested_comments: Vec::new(),
726        };
727        assert!(Document::try_from(doc_with_main_frontmatter(fm)).is_ok());
728    }
729
730    #[test]
731    fn explicit_dto_conversion_round_trips() {
732        let doc = sample();
733        let dto = DocumentV0_81_0::from(&doc);
734        let restored = Document::try_from(dto).unwrap();
735        assert_eq!(doc, restored);
736    }
737}