1#![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
51pub const STORAGE_V0_93_0: &str = "quillmark/document@0.93.0";
61
62pub fn peek_storage_version(json: &str) -> Option<String> {
71 #[derive(Deserialize)]
72 struct Peek {
73 schema: Option<String>,
74 }
75 serde_json::from_str::<Peek>(json).ok()?.schema
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(tag = "schema")]
85#[non_exhaustive]
86pub enum StoredDocument {
87 #[serde(rename = "quillmark/document@0.93.0")]
90 V0_93_0(DocumentV0_93_0),
91 #[serde(rename = "quillmark/document@0.92.0")]
95 V0_92_0(DocumentV0_92_0),
96}
97
98#[derive(Debug, Clone, PartialEq)]
107#[non_exhaustive]
108pub enum StorageError {
109 InvalidQuillReference {
111 value: String,
113 reason: String,
115 },
116 Malformed(String),
119}
120
121impl std::fmt::Display for StorageError {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 match self {
124 StorageError::InvalidQuillReference { value, reason } => {
125 write!(f, "invalid quill reference {value:?}: {reason}")
126 }
127 StorageError::Malformed(msg) => f.write_str(msg),
128 }
129 }
130}
131
132impl std::error::Error for StorageError {}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct DocumentV0_93_0 {
140 pub main: CardV0_93_0,
141 #[serde(default)]
142 pub cards: Vec<CardV0_93_0>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct CardV0_93_0 {
150 pub payload: PayloadV0_93_0,
151 pub body: CanonicalContent,
152}
153
154pub type PayloadV0_93_0 = PayloadV0_92_0;
157
158#[derive(Debug, Clone, PartialEq)]
174pub struct CanonicalContent(pub Content);
175
176impl Serialize for CanonicalContent {
177 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178 where
179 S: serde::Serializer,
180 {
181 quillmark_content::serial::to_canonical_value(&self.0).serialize(serializer)
182 }
183}
184
185impl<'de> Deserialize<'de> for CanonicalContent {
186 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187 where
188 D: serde::Deserializer<'de>,
189 {
190 let value = serde_json::Value::deserialize(deserializer)?;
191 let rt = quillmark_content::serial::from_canonical_value(&value)
192 .map_err(serde::de::Error::custom)?;
193 Ok(CanonicalContent(rt))
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct DocumentV0_92_0 {
207 pub main: CardV0_92_0,
208 #[serde(default)]
209 pub cards: Vec<CardV0_92_0>,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct CardV0_92_0 {
215 pub payload: PayloadV0_92_0,
216 #[serde(default)]
217 pub body: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
222pub struct PayloadV0_92_0 {
223 #[serde(default)]
224 pub items: Vec<PayloadItemV0_92_0>,
225 #[serde(default)]
226 pub nested_comments: Vec<NestedCommentV0_92_0>,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(tag = "type", rename_all = "lowercase")]
239pub enum PayloadItemV0_92_0 {
240 Quill { value: String },
242 Kind { value: String },
244 Ext {
247 value: serde_json::Map<String, serde_json::Value>,
248 },
249 Seed {
252 value: serde_json::Map<String, serde_json::Value>,
253 },
254 Field {
256 key: String,
257 value: serde_json::Value,
258 #[serde(default)]
259 fill: bool,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 nested_fills: Vec<Vec<CommentPathSegmentV0_92_0>>,
262 },
263 Comment {
265 text: String,
266 #[serde(default)]
267 inline: bool,
268 },
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273pub struct NestedCommentV0_92_0 {
274 pub container_path: Vec<CommentPathSegmentV0_92_0>,
275 pub position: usize,
276 pub text: String,
277 pub inline: bool,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub enum CommentPathSegmentV0_92_0 {
284 Key(String),
285 Index(usize),
286}
287
288impl From<Document> for StoredDocument {
295 fn from(doc: Document) -> Self {
296 StoredDocument::V0_93_0(DocumentV0_93_0::from(&doc))
297 }
298}
299
300impl From<&Document> for DocumentV0_93_0 {
301 fn from(doc: &Document) -> Self {
302 DocumentV0_93_0 {
303 main: CardV0_93_0::from(doc.main()),
304 cards: doc.cards().iter().map(CardV0_93_0::from).collect(),
305 }
306 }
307}
308
309impl From<&Card> for CardV0_93_0 {
310 fn from(card: &Card) -> Self {
311 CardV0_93_0 {
314 payload: PayloadV0_92_0::from(card.payload()),
315 body: CanonicalContent(card.body().clone()),
316 }
317 }
318}
319
320impl From<&Payload> for PayloadV0_92_0 {
321 fn from(payload: &Payload) -> Self {
322 let nested_comments = payload
326 .flat_nested_comments()
327 .iter()
328 .map(NestedCommentV0_92_0::from)
329 .collect();
330 PayloadV0_92_0 {
331 items: payload
332 .items()
333 .iter()
334 .map(PayloadItemV0_92_0::from)
335 .collect(),
336 nested_comments,
337 }
338 }
339}
340
341impl From<&PayloadItem> for PayloadItemV0_92_0 {
342 fn from(item: &PayloadItem) -> Self {
343 match item {
344 PayloadItem::Quill { reference } => PayloadItemV0_92_0::Quill {
345 value: reference.to_string(),
346 },
347 PayloadItem::Kind { value } => PayloadItemV0_92_0::Kind {
348 value: value.clone(),
349 },
350 PayloadItem::Meta {
356 key: MetaKey::Ext,
357 value,
358 ..
359 } => PayloadItemV0_92_0::Ext {
360 value: value.clone(),
361 },
362 PayloadItem::Meta {
363 key: MetaKey::Seed,
364 value,
365 ..
366 } => PayloadItemV0_92_0::Seed {
367 value: value.clone(),
368 },
369 PayloadItem::Field {
373 key, value, fill, ..
374 } => PayloadItemV0_92_0::Field {
375 key: key.clone(),
376 value: value.as_json().clone(),
377 fill: *fill,
378 nested_fills: value
379 .nonroot_fill_paths()
380 .map(|p| p.iter().map(CommentPathSegmentV0_92_0::from).collect())
381 .collect(),
382 },
383 PayloadItem::Comment { text, inline } => PayloadItemV0_92_0::Comment {
384 text: text.clone(),
385 inline: *inline,
386 },
387 }
388 }
389}
390
391impl From<&NestedComment> for NestedCommentV0_92_0 {
392 fn from(nc: &NestedComment) -> Self {
393 NestedCommentV0_92_0 {
394 container_path: nc
395 .container_path
396 .iter()
397 .map(CommentPathSegmentV0_92_0::from)
398 .collect(),
399 position: nc.position,
400 text: nc.text.clone(),
401 inline: nc.inline,
402 }
403 }
404}
405
406impl From<&CommentPathSegment> for CommentPathSegmentV0_92_0 {
407 fn from(seg: &CommentPathSegment) -> Self {
408 match seg {
409 CommentPathSegment::Key(k) => CommentPathSegmentV0_92_0::Key(k.clone()),
410 CommentPathSegment::Index(i) => CommentPathSegmentV0_92_0::Index(*i),
411 }
412 }
413}
414
415impl TryFrom<StoredDocument> for Document {
416 type Error = StorageError;
417
418 fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
419 match stored {
423 StoredDocument::V0_93_0(payload) => Document::try_from(payload),
424 StoredDocument::V0_92_0(payload) => {
425 Document::try_from(DocumentV0_93_0::try_from(payload)?)
426 }
427 }
428 }
429}
430
431impl TryFrom<DocumentV0_93_0> for Document {
432 type Error = StorageError;
433
434 fn try_from(payload: DocumentV0_93_0) -> Result<Self, Self::Error> {
435 let main = Card::try_from(payload.main)?;
436 if main.quill().is_none() {
437 return Err(StorageError::Malformed(
438 "main card must carry a $quill entry".into(),
439 ));
440 }
441 let cards = payload
442 .cards
443 .into_iter()
444 .map(Card::try_from)
445 .collect::<Result<Vec<_>, _>>()?;
446 for card in &cards {
447 if card.quill().is_some() {
448 return Err(StorageError::Malformed(
449 "composable cards must not carry a $quill entry".into(),
450 ));
451 }
452 if card.seed().is_some() {
453 return Err(StorageError::Malformed(
454 "composable cards must not carry a $seed entry".into(),
455 ));
456 }
457 if let Some(kind) = card.kind() {
458 match validate_composable_kind(kind) {
459 Ok(()) => {}
460 Err(super::meta::CardKindError::InvalidName) => {
461 return Err(StorageError::Malformed(format!(
462 "invalid composable card kind {kind:?}: must match \
463 [a-z_][a-z0-9_]*"
464 )));
465 }
466 Err(super::meta::CardKindError::Reserved) => {
467 return Err(StorageError::Malformed(format!(
468 "composable card kind {kind:?} is reserved (root only)"
469 )));
470 }
471 }
472 }
473 }
474 Ok(Document::from_main_and_cards(main, cards))
475 }
476}
477
478impl TryFrom<CardV0_93_0> for Card {
479 type Error = StorageError;
480
481 fn try_from(card: CardV0_93_0) -> Result<Self, Self::Error> {
482 let payload = Payload::try_from(card.payload)?;
483 validate_dto_payload(&payload)?;
484 Ok(Card::from_parts(payload, card.body.0))
488 }
489}
490
491impl TryFrom<DocumentV0_92_0> for DocumentV0_93_0 {
501 type Error = StorageError;
502
503 fn try_from(d: DocumentV0_92_0) -> Result<Self, Self::Error> {
504 Ok(DocumentV0_93_0 {
505 main: CardV0_93_0::try_from(d.main)?,
506 cards: d
507 .cards
508 .into_iter()
509 .map(CardV0_93_0::try_from)
510 .collect::<Result<_, _>>()?,
511 })
512 }
513}
514
515impl TryFrom<CardV0_92_0> for CardV0_93_0 {
516 type Error = StorageError;
517
518 fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
519 let body = super::import_body(&card.body)
520 .map_err(|e| StorageError::Malformed(format!("card body: {e}")))?;
521 Ok(CardV0_93_0 {
522 payload: card.payload,
523 body: CanonicalContent(body),
524 })
525 }
526}
527
528impl TryFrom<PayloadV0_92_0> for Payload {
529 type Error = StorageError;
530
531 fn try_from(p: PayloadV0_92_0) -> Result<Self, Self::Error> {
532 let mut items = Vec::with_capacity(p.items.len());
533 for item in p.items {
534 items.push(PayloadItem::try_from(item)?);
535 }
536 let nested = p
537 .nested_comments
538 .into_iter()
539 .map(NestedComment::from)
540 .collect();
541 Ok(Payload::from_items_with_flat_nested(items, nested))
544 }
545}
546
547impl TryFrom<PayloadItemV0_92_0> for PayloadItem {
548 type Error = StorageError;
549
550 fn try_from(item: PayloadItemV0_92_0) -> Result<Self, Self::Error> {
551 Ok(match item {
552 PayloadItemV0_92_0::Quill { value } => {
553 let reference = QuillReference::from_str(&value).map_err(|reason| {
554 StorageError::InvalidQuillReference {
555 value: value.clone(),
556 reason,
557 }
558 })?;
559 PayloadItem::Quill { reference }
560 }
561 PayloadItemV0_92_0::Kind { value } => PayloadItem::Kind { value },
562 PayloadItemV0_92_0::Ext { value } => PayloadItem::Meta {
563 key: MetaKey::Ext,
564 value: depth_check_meta_map(value, "$ext")?,
565 nested_comments: Vec::new(),
566 },
567 PayloadItemV0_92_0::Seed { value } => PayloadItem::Meta {
568 key: MetaKey::Seed,
569 value: depth_check_meta_map(value, "$seed")?,
570 nested_comments: Vec::new(),
571 },
572 PayloadItemV0_92_0::Field {
573 key,
574 value,
575 fill,
576 nested_fills,
577 } => {
578 use super::edit::{validate_field, FieldViolation};
579 validate_field(&key, &value).map_err(|v| {
580 StorageError::Malformed(match v {
581 FieldViolation::InvalidName => {
582 format!("invalid field name {key:?}: must match [A-Za-z_][A-Za-z0-9_]*")
583 }
584 FieldViolation::TooDeep => format!(
585 "field {key:?} nests deeper than the maximum of {} levels",
586 crate::document::limits::MAX_YAML_DEPTH
587 ),
588 })
589 })?;
590 let mut qv = QuillValue::from_json(value);
591 for path in nested_fills {
592 let segs: Vec<CommentPathSegment> =
593 path.into_iter().map(CommentPathSegment::from).collect();
594 qv.set_fill_at(&segs);
595 }
596 PayloadItem::Field {
597 key,
598 value: qv,
599 fill,
600 nested_comments: Vec::new(),
601 }
602 }
603 PayloadItemV0_92_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
604 })
605 }
606}
607
608fn depth_check_meta_map(
611 value: serde_json::Map<String, serde_json::Value>,
612 key: &str,
613) -> Result<serde_json::Map<String, serde_json::Value>, StorageError> {
614 crate::value::depth_check_meta_map(value, |max| {
615 StorageError::Malformed(format!("{key} nests deeper than the maximum of {} levels", max))
616 })
617}
618
619impl From<NestedCommentV0_92_0> for NestedComment {
620 fn from(nc: NestedCommentV0_92_0) -> Self {
621 NestedComment {
622 container_path: nc
623 .container_path
624 .into_iter()
625 .map(CommentPathSegment::from)
626 .collect(),
627 position: nc.position,
628 text: nc.text,
629 inline: nc.inline,
630 }
631 }
632}
633
634impl From<CommentPathSegmentV0_92_0> for CommentPathSegment {
635 fn from(seg: CommentPathSegmentV0_92_0) -> Self {
636 match seg {
637 CommentPathSegmentV0_92_0::Key(k) => CommentPathSegment::Key(k),
638 CommentPathSegmentV0_92_0::Index(i) => CommentPathSegment::Index(i),
639 }
640 }
641}
642
643fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
647 if payload.len() > crate::error::MAX_FIELD_COUNT {
648 return Err(StorageError::Malformed(format!(
649 "card has {} user fields, exceeding the maximum of {}",
650 payload.len(),
651 crate::error::MAX_FIELD_COUNT
652 )));
653 }
654 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
655 for key in payload.keys() {
656 if !seen.insert(key.as_str()) {
657 return Err(StorageError::Malformed(format!(
658 "duplicate user-field key {key:?}"
659 )));
660 }
661 }
662 Ok(())
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 fn sample() -> Document {
670 Document::parse(
671 "\
672~~~card-yaml
673$quill: usaf_memo@0.1
674$kind: main
675# a top-level comment
676memo_for:
677 - ORG/SYMBOL # inline comment inside a sequence
678date: 2504-10-05
679subject: !must_fill Subject of the Memorandum
680~~~
681
682The body of the memorandum.
683
684~~~card-yaml
685$kind: indorsement
686for: ORG/SYMBOL
687from: ORG/SYMBOL
688~~~
689
690This body and the metadata above are an indorsement card.
691",
692 )
693 .unwrap()
694 .document
695 }
696
697 #[test]
698 fn round_trips_through_serde_json() {
699 let doc = sample();
700 let json = serde_json::to_string(&doc).unwrap();
701 let restored: Document = serde_json::from_str(&json).unwrap();
702 assert_eq!(doc, restored);
703 assert_eq!(doc.to_markdown(), restored.to_markdown());
704 }
705
706 #[test]
707 fn content_field_survives_storage_round_trip_losslessly() {
708 use quillmark_content::model::{Mark, MarkKind};
713
714 let mut doc = sample();
715 let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
716 content.marks.push(Mark::new(0, 10, MarkKind::Underline));
717 content.normalize();
718 let json = quillmark_content::serial::to_canonical_value(&content);
719 let schema = crate::quill::FieldSchema::new(
720 "intro".to_string(),
721 crate::quill::FieldType::RichText { inline: false },
722 None,
723 );
724 doc.main_mut()
725 .commit_field("intro", crate::QuillValue::from_json(json), &schema)
726 .unwrap();
727
728 let stored = serde_json::to_string(&doc).unwrap();
729 let restored: Document = serde_json::from_str(&stored).unwrap();
730 assert_eq!(doc, restored, "content field must survive storage round-trip");
731 let read = restored.main().field_richtext("intro").unwrap().unwrap();
732 assert!(
733 read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)),
734 "underline (content-only) must survive the DTO carrier"
735 );
736 }
737
738 #[test]
739 fn nested_fill_survives_storage_round_trip() {
740 let doc = Document::parse(
743 "~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n street: !must_fill\n city: Anytown\n~~~\n",
744 )
745 .unwrap()
746 .document;
747 let json = serde_json::to_string(&doc).unwrap();
748 let restored: Document = serde_json::from_str(&json).unwrap();
749 assert_eq!(doc, restored, "nested fill must survive storage round-trip");
750 assert!(
751 restored.to_markdown().contains("street: !must_fill"),
752 "Got:\n{}",
753 restored.to_markdown()
754 );
755 }
756
757 #[test]
758 fn root_kind_is_main_through_round_trip() {
759 let doc = Document::parse(
760 "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
761 )
762 .unwrap()
763 .document;
764 assert_eq!(doc.main().kind(), Some("main"));
765 let restored: Document =
766 serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
767 assert_eq!(doc, restored);
768 assert_eq!(restored.main().kind(), Some("main"));
769 }
770
771 #[test]
772 fn rejects_unknown_schema_version() {
773 let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
774 assert!(serde_json::from_str::<Document>(json).is_err());
775 }
776
777 #[test]
778 fn peek_storage_version_reads_field_without_full_parse() {
779 let doc = sample();
780 let json = serde_json::to_string(&doc).unwrap();
781 assert_eq!(peek_storage_version(&json).as_deref(), Some(STORAGE_V0_93_0));
782
783 let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
785 assert_eq!(
786 peek_storage_version(future).as_deref(),
787 Some("quillmark/document@0.99.0")
788 );
789 assert_eq!(peek_storage_version("not json"), None);
790 assert_eq!(peek_storage_version(r#"{"foo":"bar"}"#), None);
791 }
792
793 #[test]
794 fn comment_on_dollar_line_round_trips() {
795 let src = "\
798~~~card-yaml
799$quill: q@1.0
800$kind: main # required for root
801title: Hi
802~~~
803";
804 let doc = Document::parse(src).unwrap().document;
805 let json = serde_json::to_string(&doc).unwrap();
806 let restored: Document = serde_json::from_str(&json).unwrap();
807 assert_eq!(doc, restored);
808 assert!(restored
810 .to_markdown()
811 .contains("$kind: main # required for root"));
812 }
813
814 #[test]
815 fn retired_legacy_schema_tags_are_rejected() {
816 for tag in ["quillmark/document@0.81.0", "quillmark/document@0.82.0"] {
820 let json = format!(
821 r#"{{"schema":"{tag}","main":{{"payload":{{"items":[]}},"body":""}},"cards":[]}}"#
822 );
823 assert!(
824 serde_json::from_str::<Document>(&json).is_err(),
825 "expected {tag} to be rejected as an unknown schema"
826 );
827 }
828 }
829
830 #[test]
831 fn rejects_main_card_without_quill() {
832 let json = r#"{
833 "schema": "quillmark/document@0.92.0",
834 "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
835 "cards": []
836 }"#;
837 let err = serde_json::from_str::<Document>(json).unwrap_err();
838 assert!(err.to_string().contains("$quill"));
839 }
840
841 #[test]
842 fn rejects_composable_card_tagged_main() {
843 let json = r#"{
844 "schema": "quillmark/document@0.92.0",
845 "main": {
846 "payload": {"items": [
847 {"type": "quill", "value": "q@1.0"},
848 {"type": "kind", "value": "main"}
849 ]},
850 "body": ""
851 },
852 "cards": [
853 {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
854 ]
855 }"#;
856 let err = serde_json::from_str::<Document>(json).unwrap_err();
857 assert!(err.to_string().contains("reserved (root only)"));
858 }
859
860 #[test]
861 fn rejects_invalid_quill_reference() {
862 let json = r#"{
863 "schema": "quillmark/document@0.92.0",
864 "main": {
865 "payload": {"items": [
866 {"type": "quill", "value": "not a valid ref!!"},
867 {"type": "kind", "value": "main"}
868 ]},
869 "body": ""
870 },
871 "cards": []
872 }"#;
873 let err = serde_json::from_str::<Document>(json).unwrap_err();
874 assert!(err.to_string().contains("invalid quill reference"));
875 }
876
877 #[test]
878 fn rejects_composable_card_with_seed() {
879 let json = r#"{
882 "schema": "quillmark/document@0.92.0",
883 "main": {
884 "payload": {"items": [
885 {"type": "quill", "value": "q@1.0"},
886 {"type": "kind", "value": "main"}
887 ]},
888 "body": ""
889 },
890 "cards": [
891 {"payload": {"items": [
892 {"type": "kind", "value": "indorsement"},
893 {"type": "seed", "value": {"note": {"from": "X"}}}
894 ]}, "body": ""}
895 ]
896 }"#;
897 let err = serde_json::from_str::<Document>(json).unwrap_err();
898 assert!(err
899 .to_string()
900 .contains("composable cards must not carry a $seed entry"));
901 }
902
903 #[test]
904 fn v0_92_0_seed_item_round_trips() {
905 let json = r#"{
906 "schema": "quillmark/document@0.92.0",
907 "main": {
908 "payload": {"items": [
909 {"type": "quill", "value": "q@1.0"},
910 {"type": "kind", "value": "main"},
911 {"type": "seed", "value": {"indorsement": {"from": "49 FW/CC"}}}
912 ]},
913 "body": ""
914 },
915 "cards": []
916 }"#;
917 let doc: Document = serde_json::from_str(json).unwrap();
918 let overlay = doc
919 .main()
920 .seed()
921 .and_then(|m| m.get("indorsement"))
922 .and_then(crate::SeedOverlay::from_json)
923 .expect("overlay present");
924 assert_eq!(
925 overlay.fields.get("from").and_then(|v| v.as_str()),
926 Some("49 FW/CC")
927 );
928 let reser: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
929 assert_eq!(doc, reser);
930 }
931
932 fn locate_body_subtree(envelope: &str) -> &str {
938 const KEY: &str = "\"body\":";
939 let start = envelope.find(KEY).expect("body key present") + KEY.len();
940 let bytes = envelope.as_bytes();
941 assert_eq!(
942 bytes[start], b'{',
943 "body must embed as a nested object, not an escaped string"
944 );
945 let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
946 for (i, &b) in bytes[start..].iter().enumerate() {
947 if in_str {
948 match (escaped, b) {
949 (true, _) => escaped = false,
950 (false, b'\\') => escaped = true,
951 (false, b'"') => in_str = false,
952 _ => {}
953 }
954 continue;
955 }
956 match b {
957 b'"' => in_str = true,
958 b'{' => depth += 1,
959 b'}' => {
960 depth -= 1;
961 if depth == 0 {
962 return &envelope[start..start + i + 1];
963 }
964 }
965 _ => {}
966 }
967 }
968 panic!("unbalanced body object");
969 }
970
971 #[test]
972 fn body_subtree_is_byte_identical_to_canonical_json() {
973 let doc = Document::parse(
977 "~~~card-yaml\n$quill: q@0.1\n$kind: main\ntitle: Hi\n~~~\n\n\
978 A paragraph with **bold**, _emph_, and a [link](https://example.com).\n\n\
979 Second paragraph continues the content.\n",
980 )
981 .unwrap()
982 .document;
983 let rt = doc.main().body().clone();
984 assert!(
985 !rt.marks.is_empty(),
986 "test needs a non-trivial content (marks present)"
987 );
988 let expected = rt.to_canonical_json();
989 let envelope = serde_json::to_string(&doc).unwrap();
990 let body = locate_body_subtree(&envelope);
991 assert_eq!(
992 body, expected,
993 "the envelope body subtree must equal to_canonical_json byte-for-byte"
994 );
995 assert!(body.starts_with("{\"islands\":"));
997 }
998
999 #[test]
1000 fn v0_93_0_round_trips_as_fixed_point() {
1001 let doc = sample();
1002 let first = serde_json::to_string(&doc).unwrap();
1003 let restored: Document = serde_json::from_str(&first).unwrap();
1004 assert_eq!(doc, restored);
1005 let second = serde_json::to_string(&restored).unwrap();
1006 assert_eq!(
1007 first, second,
1008 "V0_93_0 serialize→deserialize is a byte-fixed point"
1009 );
1010 assert_eq!(peek_storage_version(&first).as_deref(), Some(STORAGE_V0_93_0));
1011 }
1012
1013 #[test]
1014 fn legacy_table_body_migrates_deterministically_with_islands() {
1015 let blob = r#"{
1019 "schema": "quillmark/document@0.92.0",
1020 "main": {
1021 "payload": {"items": [
1022 {"type": "quill", "value": "q@0.1"},
1023 {"type": "kind", "value": "main"}
1024 ]},
1025 "body": "| A | B |\n| - | - |\n| 1 | 2 |\n"
1026 },
1027 "cards": []
1028 }"#;
1029 let doc: Document = serde_json::from_str(blob).unwrap();
1030 let body = doc.main().body();
1031 assert_eq!(body.islands.len(), 1, "table imports as one island");
1032 assert_eq!(body.islands[0].id, "isl-0", "sequential island id");
1033 assert_eq!(body.islands[0].island_type, "table");
1034 let key = body.to_canonical_json();
1039 assert_eq!(
1040 key,
1041 "{\"islands\":[{\"id\":\"isl-0\",\"loss\":\"lossless\",\"props\":{\
1042 \"aligns\":[\"none\",\"none\"],\
1043 \"header\":[{\"marks\":[],\"text\":\"A\"},{\"marks\":[],\"text\":\"B\"}],\
1044 \"rows\":[[{\"marks\":[],\"text\":\"1\"},{\"marks\":[],\"text\":\"2\"}]]},\
1045 \"type\":\"table\"}],\
1046 \"lines\":[{\"containers\":[],\"kind\":\"island\"}],\
1047 \"marks\":[],\"text\":\"\u{FFFC}\"}",
1048 "regenerated @0.93.0 golden: cells are structured text+marks"
1049 );
1050
1051 let again: Document = serde_json::from_str(blob).unwrap();
1052 assert_eq!(
1053 serde_json::to_string(&doc).unwrap(),
1054 serde_json::to_string(&again).unwrap(),
1055 "same legacy input → same migrated bytes"
1056 );
1057 let reser = serde_json::to_string(&doc).unwrap();
1058 assert_eq!(peek_storage_version(&reser).as_deref(), Some(STORAGE_V0_93_0));
1059 }
1060
1061 #[test]
1062 fn over_nested_legacy_body_is_malformed() {
1063 let deep = ">".repeat(crate::error::MAX_NESTING_DEPTH + 5);
1067 let card = CardV0_92_0 {
1068 payload: PayloadV0_92_0::default(),
1069 body: format!("{deep} too deep"),
1070 };
1071 let err = CardV0_93_0::try_from(card).unwrap_err();
1072 assert!(matches!(err, StorageError::Malformed(_)), "got: {err:?}");
1073 assert!(err.to_string().contains("card body"));
1074 }
1075
1076 #[test]
1077 fn deserialize_rejects_invalid_content_body() {
1078 let blob = r#"{
1082 "schema": "quillmark/document@0.93.0",
1083 "main": {
1084 "payload": {"items": [
1085 {"type": "quill", "value": "q@0.1"},
1086 {"type": "kind", "value": "main"}
1087 ]},
1088 "body": {"text": "a\nb", "lines": [{"kind": "para", "containers": []}], "marks": [], "islands": []}
1089 },
1090 "cards": []
1091 }"#;
1092 assert!(serde_json::from_str::<Document>(blob).is_err());
1093 }
1094}