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 SCHEMA_V0_93_0: &str = "quillmark/document@0.93.0";
56
57pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(tag = "schema")]
80#[non_exhaustive]
81pub enum StoredDocument {
82 #[serde(rename = "quillmark/document@0.93.0")]
85 V0_93_0(DocumentV0_93_0),
86 #[serde(rename = "quillmark/document@0.92.0")]
90 V0_92_0(DocumentV0_92_0),
91}
92
93#[derive(Debug, Clone, PartialEq)]
102#[non_exhaustive]
103pub enum StorageError {
104 InvalidQuillReference {
106 value: String,
108 reason: String,
110 },
111 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct CardV0_93_0 {
145 pub payload: PayloadV0_93_0,
146 pub body: CanonicalContent,
147}
148
149pub type PayloadV0_93_0 = PayloadV0_92_0;
152
153#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(tag = "type", rename_all = "lowercase")]
234pub enum PayloadItemV0_92_0 {
235 Quill { value: String },
237 Kind { value: String },
239 Ext {
242 value: serde_json::Map<String, serde_json::Value>,
243 },
244 Seed {
247 value: serde_json::Map<String, serde_json::Value>,
248 },
249 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 Comment {
260 text: String,
261 #[serde(default)]
262 inline: bool,
263 },
264}
265
266#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub enum CommentPathSegmentV0_92_0 {
279 Key(String),
280 Index(usize),
281}
282
283impl 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 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 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 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 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 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 Ok(Card::from_parts(payload, card.body.0))
483 }
484}
485
486impl 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 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
603fn 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
638fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}