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