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 let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
445 for card in &cards {
446 if card.quill().is_some() {
447 return Err(StorageError::Malformed(
448 "composable cards must not carry a $quill entry".into(),
449 ));
450 }
451 if card.seed().is_some() {
452 return Err(StorageError::Malformed(
453 "composable cards must not carry a $seed entry".into(),
454 ));
455 }
456 if let Some(kind) = card.kind() {
457 match validate_composable_kind(kind) {
458 Ok(()) => {}
459 Err(super::meta::CardKindError::InvalidName) => {
460 return Err(StorageError::Malformed(format!(
461 "invalid composable card kind {kind:?}: must match \
462 [a-z_][a-z0-9_]*"
463 )));
464 }
465 Err(super::meta::CardKindError::Reserved) => {
466 return Err(StorageError::Malformed(format!(
467 "composable card kind {kind:?} is reserved (root only)"
468 )));
469 }
470 }
471 }
472 if let Some(id) = card.id() {
473 if id.is_empty() {
474 return Err(StorageError::Malformed(
475 "empty composable card $id: a card handle cannot be the \
476 empty string"
477 .into(),
478 ));
479 }
480 if !seen_ids.insert(id) {
481 return Err(StorageError::Malformed(format!(
482 "duplicate composable card $id {id:?}: $id is unique per \
483 document"
484 )));
485 }
486 }
487 }
488 Ok(Document::from_main_and_cards(main, cards))
489 }
490}
491
492impl TryFrom<CardV0_93_0> for Card {
493 type Error = StorageError;
494
495 fn try_from(card: CardV0_93_0) -> Result<Self, Self::Error> {
496 let payload = Payload::try_from(card.payload)?;
497 validate_dto_payload(&payload)?;
498 Ok(Card::from_parts(payload, card.body.0))
502 }
503}
504
505impl TryFrom<DocumentV0_92_0> for DocumentV0_93_0 {
515 type Error = StorageError;
516
517 fn try_from(d: DocumentV0_92_0) -> Result<Self, Self::Error> {
518 Ok(DocumentV0_93_0 {
519 main: CardV0_93_0::try_from(d.main)?,
520 cards: d
521 .cards
522 .into_iter()
523 .map(CardV0_93_0::try_from)
524 .collect::<Result<_, _>>()?,
525 })
526 }
527}
528
529impl TryFrom<CardV0_92_0> for CardV0_93_0 {
530 type Error = StorageError;
531
532 fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
533 let body = super::import_body(&card.body)
534 .map_err(|e| StorageError::Malformed(format!("card body: {e}")))?;
535 Ok(CardV0_93_0 {
536 payload: card.payload,
537 body: CanonicalContent(body),
538 })
539 }
540}
541
542impl TryFrom<PayloadV0_92_0> for Payload {
543 type Error = StorageError;
544
545 fn try_from(p: PayloadV0_92_0) -> Result<Self, Self::Error> {
546 let mut items = Vec::with_capacity(p.items.len());
547 for item in p.items {
548 items.push(PayloadItem::try_from(item)?);
549 }
550 let nested = p
551 .nested_comments
552 .into_iter()
553 .map(NestedComment::from)
554 .collect();
555 Ok(Payload::from_items_with_flat_nested(items, nested))
558 }
559}
560
561impl TryFrom<PayloadItemV0_92_0> for PayloadItem {
562 type Error = StorageError;
563
564 fn try_from(item: PayloadItemV0_92_0) -> Result<Self, Self::Error> {
565 Ok(match item {
566 PayloadItemV0_92_0::Quill { value } => {
567 let reference = QuillReference::from_str(&value).map_err(|reason| {
568 StorageError::InvalidQuillReference {
569 value: value.clone(),
570 reason,
571 }
572 })?;
573 PayloadItem::Quill { reference }
574 }
575 PayloadItemV0_92_0::Kind { value } => PayloadItem::Kind { value },
576 PayloadItemV0_92_0::Id { value } => PayloadItem::Id { value },
577 PayloadItemV0_92_0::Ext { value } => PayloadItem::Meta {
578 key: MetaKey::Ext,
579 value: depth_check_meta_map(value, "$ext")?,
580 nested_comments: Vec::new(),
581 },
582 PayloadItemV0_92_0::Seed { value } => PayloadItem::Meta {
583 key: MetaKey::Seed,
584 value: depth_check_meta_map(value, "$seed")?,
585 nested_comments: Vec::new(),
586 },
587 PayloadItemV0_92_0::Field {
588 key,
589 value,
590 fill,
591 nested_fills,
592 } => {
593 use super::edit::{validate_field, FieldViolation};
594 validate_field(&key, &value).map_err(|v| {
595 StorageError::Malformed(match v {
596 FieldViolation::InvalidName => {
597 format!("invalid field name {key:?}: must match [A-Za-z_][A-Za-z0-9_]*")
598 }
599 FieldViolation::TooDeep => format!(
600 "field {key:?} nests deeper than the maximum of {} levels",
601 crate::document::limits::MAX_YAML_DEPTH
602 ),
603 })
604 })?;
605 let mut qv = QuillValue::from_json(value);
606 for path in nested_fills {
607 let segs: Vec<CommentPathSegment> =
608 path.into_iter().map(CommentPathSegment::from).collect();
609 qv.set_fill_at(&segs);
610 }
611 PayloadItem::Field {
612 key,
613 value: qv,
614 fill,
615 nested_comments: Vec::new(),
616 }
617 }
618 PayloadItemV0_92_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
619 })
620 }
621}
622
623fn depth_check_meta_map(
626 value: serde_json::Map<String, serde_json::Value>,
627 key: &str,
628) -> Result<serde_json::Map<String, serde_json::Value>, StorageError> {
629 crate::value::depth_check_meta_map(value, |max| {
630 StorageError::Malformed(format!("{key} nests deeper than the maximum of {} levels", max))
631 })
632}
633
634impl From<NestedCommentV0_92_0> for NestedComment {
635 fn from(nc: NestedCommentV0_92_0) -> Self {
636 NestedComment {
637 container_path: nc
638 .container_path
639 .into_iter()
640 .map(CommentPathSegment::from)
641 .collect(),
642 position: nc.position,
643 text: nc.text,
644 inline: nc.inline,
645 }
646 }
647}
648
649impl From<CommentPathSegmentV0_92_0> for CommentPathSegment {
650 fn from(seg: CommentPathSegmentV0_92_0) -> Self {
651 match seg {
652 CommentPathSegmentV0_92_0::Key(k) => CommentPathSegment::Key(k),
653 CommentPathSegmentV0_92_0::Index(i) => CommentPathSegment::Index(i),
654 }
655 }
656}
657
658fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
662 if payload.len() > crate::error::MAX_FIELD_COUNT {
663 return Err(StorageError::Malformed(format!(
664 "card has {} user fields, exceeding the maximum of {}",
665 payload.len(),
666 crate::error::MAX_FIELD_COUNT
667 )));
668 }
669 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
670 for key in payload.keys() {
671 if !seen.insert(key.as_str()) {
672 return Err(StorageError::Malformed(format!(
673 "duplicate user-field key {key:?}"
674 )));
675 }
676 }
677 Ok(())
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 fn sample() -> Document {
685 Document::parse(
686 "\
687~~~card-yaml
688$quill: usaf_memo@0.1
689$kind: main
690# a top-level comment
691memo_for:
692 - ORG/SYMBOL # inline comment inside a sequence
693date: 2504-10-05
694subject: !must_fill Subject of the Memorandum
695~~~
696
697The body of the memorandum.
698
699~~~card-yaml
700$kind: indorsement
701for: ORG/SYMBOL
702from: ORG/SYMBOL
703~~~
704
705This body and the metadata above are an indorsement card.
706",
707 )
708 .unwrap()
709 .document
710 }
711
712 #[test]
713 fn round_trips_through_serde_json() {
714 let doc = sample();
715 let json = serde_json::to_string(&doc).unwrap();
716 let restored: Document = serde_json::from_str(&json).unwrap();
717 assert_eq!(doc, restored);
718 assert_eq!(doc.to_markdown(), restored.to_markdown());
719 }
720
721 #[test]
722 fn card_id_round_trips_and_violations_are_malformed() {
723 let mut doc = sample();
725 doc.set_card_id(0, "id_a").unwrap();
726 let json = serde_json::to_string(&doc).unwrap();
727 let restored: Document = serde_json::from_str(&json).unwrap();
728 assert_eq!(restored.cards()[0].id(), Some("id_a"));
729 assert_eq!(doc, restored);
730
731 let mut two = sample();
734 two.set_card_id(0, "id_a").unwrap();
735 let second = crate::document::Card::new("indorsement").unwrap();
736 two.push_card(second).unwrap();
737 two.set_card_id(1, "id_b").unwrap();
738 let json = serde_json::to_string(&two).unwrap();
739
740 let dup = json.replace("id_b", "id_a");
741 let err = serde_json::from_str::<Document>(&dup).unwrap_err();
742 assert!(
743 err.to_string().contains("duplicate composable card $id"),
744 "got: {err}"
745 );
746
747 let empty = json.replace("id_b", "");
748 let err = serde_json::from_str::<Document>(&empty).unwrap_err();
749 assert!(
750 err.to_string().contains("empty composable card $id"),
751 "got: {err}"
752 );
753 }
754
755 #[test]
756 fn content_field_survives_storage_round_trip_losslessly() {
757 use quillmark_content::model::{Mark, MarkKind};
762
763 let mut doc = sample();
764 let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
765 content.marks.push(Mark {
766 start: 0,
767 end: 10,
768 kind: MarkKind::Underline,
769 });
770 content.normalize();
771 let json = quillmark_content::serial::to_canonical_value(&content);
772 let schema = crate::quill::FieldSchema::new(
773 "intro".to_string(),
774 crate::quill::FieldType::RichText { inline: false },
775 None,
776 );
777 doc.main_mut()
778 .commit_field("intro", crate::QuillValue::from_json(json), &schema)
779 .unwrap();
780
781 let stored = serde_json::to_string(&doc).unwrap();
782 let restored: Document = serde_json::from_str(&stored).unwrap();
783 assert_eq!(doc, restored, "content field must survive storage round-trip");
784 let read = restored.main().field_richtext("intro").unwrap().unwrap();
785 assert!(
786 read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)),
787 "underline (content-only) must survive the DTO carrier"
788 );
789 }
790
791 #[test]
792 fn nested_fill_survives_storage_round_trip() {
793 let doc = Document::parse(
796 "~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n street: !must_fill\n city: Anytown\n~~~\n",
797 )
798 .unwrap()
799 .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, "nested fill must survive storage round-trip");
803 assert!(
804 restored.to_markdown().contains("street: !must_fill"),
805 "Got:\n{}",
806 restored.to_markdown()
807 );
808 }
809
810 #[test]
811 fn root_kind_is_main_through_round_trip() {
812 let doc = Document::parse(
813 "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
814 )
815 .unwrap()
816 .document;
817 assert_eq!(doc.main().kind(), Some("main"));
818 let restored: Document =
819 serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
820 assert_eq!(doc, restored);
821 assert_eq!(restored.main().kind(), Some("main"));
822 }
823
824 #[test]
825 fn rejects_unknown_schema_version() {
826 let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
827 assert!(serde_json::from_str::<Document>(json).is_err());
828 }
829
830 #[test]
831 fn peek_schema_version_reads_field_without_full_parse() {
832 let doc = sample();
833 let json = serde_json::to_string(&doc).unwrap();
834 assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_93_0));
835
836 let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
838 assert_eq!(
839 peek_schema_version(future).as_deref(),
840 Some("quillmark/document@0.99.0")
841 );
842 assert_eq!(peek_schema_version("not json"), None);
843 assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
844 }
845
846 #[test]
847 fn comment_on_dollar_line_round_trips() {
848 let src = "\
851~~~card-yaml
852$quill: q@1.0
853$kind: main # required for root
854title: Hi
855~~~
856";
857 let doc = Document::parse(src).unwrap().document;
858 let json = serde_json::to_string(&doc).unwrap();
859 let restored: Document = serde_json::from_str(&json).unwrap();
860 assert_eq!(doc, restored);
861 assert!(restored
863 .to_markdown()
864 .contains("$kind: main # required for root"));
865 }
866
867 #[test]
868 fn retired_legacy_schema_tags_are_rejected() {
869 for tag in ["quillmark/document@0.81.0", "quillmark/document@0.82.0"] {
873 let json = format!(
874 r#"{{"schema":"{tag}","main":{{"payload":{{"items":[]}},"body":""}},"cards":[]}}"#
875 );
876 assert!(
877 serde_json::from_str::<Document>(&json).is_err(),
878 "expected {tag} to be rejected as an unknown schema"
879 );
880 }
881 }
882
883 #[test]
884 fn rejects_main_card_without_quill() {
885 let json = r#"{
886 "schema": "quillmark/document@0.92.0",
887 "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
888 "cards": []
889 }"#;
890 let err = serde_json::from_str::<Document>(json).unwrap_err();
891 assert!(err.to_string().contains("$quill"));
892 }
893
894 #[test]
895 fn rejects_composable_card_tagged_main() {
896 let json = r#"{
897 "schema": "quillmark/document@0.92.0",
898 "main": {
899 "payload": {"items": [
900 {"type": "quill", "value": "q@1.0"},
901 {"type": "kind", "value": "main"}
902 ]},
903 "body": ""
904 },
905 "cards": [
906 {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
907 ]
908 }"#;
909 let err = serde_json::from_str::<Document>(json).unwrap_err();
910 assert!(err.to_string().contains("reserved (root only)"));
911 }
912
913 #[test]
914 fn rejects_invalid_quill_reference() {
915 let json = r#"{
916 "schema": "quillmark/document@0.92.0",
917 "main": {
918 "payload": {"items": [
919 {"type": "quill", "value": "not a valid ref!!"},
920 {"type": "kind", "value": "main"}
921 ]},
922 "body": ""
923 },
924 "cards": []
925 }"#;
926 let err = serde_json::from_str::<Document>(json).unwrap_err();
927 assert!(err.to_string().contains("invalid quill reference"));
928 }
929
930 #[test]
931 fn rejects_composable_card_with_seed() {
932 let json = r#"{
935 "schema": "quillmark/document@0.92.0",
936 "main": {
937 "payload": {"items": [
938 {"type": "quill", "value": "q@1.0"},
939 {"type": "kind", "value": "main"}
940 ]},
941 "body": ""
942 },
943 "cards": [
944 {"payload": {"items": [
945 {"type": "kind", "value": "indorsement"},
946 {"type": "seed", "value": {"note": {"from": "X"}}}
947 ]}, "body": ""}
948 ]
949 }"#;
950 let err = serde_json::from_str::<Document>(json).unwrap_err();
951 assert!(err
952 .to_string()
953 .contains("composable cards must not carry a $seed entry"));
954 }
955
956 #[test]
957 fn v0_92_0_seed_item_round_trips() {
958 let json = r#"{
959 "schema": "quillmark/document@0.92.0",
960 "main": {
961 "payload": {"items": [
962 {"type": "quill", "value": "q@1.0"},
963 {"type": "kind", "value": "main"},
964 {"type": "seed", "value": {"indorsement": {"from": "49 FW/CC"}}}
965 ]},
966 "body": ""
967 },
968 "cards": []
969 }"#;
970 let doc: Document = serde_json::from_str(json).unwrap();
971 let overlay = doc
972 .main()
973 .seed()
974 .and_then(|m| m.get("indorsement"))
975 .and_then(crate::SeedOverlay::from_json)
976 .expect("overlay present");
977 assert_eq!(
978 overlay.fields.get("from").and_then(|v| v.as_str()),
979 Some("49 FW/CC")
980 );
981 let reser: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
982 assert_eq!(doc, reser);
983 }
984
985 fn locate_body_subtree(envelope: &str) -> &str {
991 const KEY: &str = "\"body\":";
992 let start = envelope.find(KEY).expect("body key present") + KEY.len();
993 let bytes = envelope.as_bytes();
994 assert_eq!(
995 bytes[start], b'{',
996 "body must embed as a nested object, not an escaped string"
997 );
998 let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
999 for (i, &b) in bytes[start..].iter().enumerate() {
1000 if in_str {
1001 match (escaped, b) {
1002 (true, _) => escaped = false,
1003 (false, b'\\') => escaped = true,
1004 (false, b'"') => in_str = false,
1005 _ => {}
1006 }
1007 continue;
1008 }
1009 match b {
1010 b'"' => in_str = true,
1011 b'{' => depth += 1,
1012 b'}' => {
1013 depth -= 1;
1014 if depth == 0 {
1015 return &envelope[start..start + i + 1];
1016 }
1017 }
1018 _ => {}
1019 }
1020 }
1021 panic!("unbalanced body object");
1022 }
1023
1024 #[test]
1025 fn body_subtree_is_byte_identical_to_canonical_json() {
1026 let doc = Document::parse(
1030 "~~~card-yaml\n$quill: q@0.1\n$kind: main\ntitle: Hi\n~~~\n\n\
1031 A paragraph with **bold**, _emph_, and a [link](https://example.com).\n\n\
1032 Second paragraph continues the content.\n",
1033 )
1034 .unwrap()
1035 .document;
1036 let rt = doc.main().body().clone();
1037 assert!(
1038 !rt.marks.is_empty(),
1039 "test needs a non-trivial content (marks present)"
1040 );
1041 let expected = rt.to_canonical_json();
1042 let envelope = serde_json::to_string(&doc).unwrap();
1043 let body = locate_body_subtree(&envelope);
1044 assert_eq!(
1045 body, expected,
1046 "the envelope body subtree must equal to_canonical_json byte-for-byte"
1047 );
1048 assert!(body.starts_with("{\"islands\":"));
1050 }
1051
1052 #[test]
1053 fn v0_93_0_round_trips_as_fixed_point() {
1054 let doc = sample();
1055 let first = serde_json::to_string(&doc).unwrap();
1056 let restored: Document = serde_json::from_str(&first).unwrap();
1057 assert_eq!(doc, restored);
1058 let second = serde_json::to_string(&restored).unwrap();
1059 assert_eq!(
1060 first, second,
1061 "V0_93_0 serialize→deserialize is a byte-fixed point"
1062 );
1063 assert_eq!(peek_schema_version(&first).as_deref(), Some(SCHEMA_V0_93_0));
1064 }
1065
1066 #[test]
1067 fn legacy_table_body_migrates_deterministically_with_islands() {
1068 let blob = r#"{
1072 "schema": "quillmark/document@0.92.0",
1073 "main": {
1074 "payload": {"items": [
1075 {"type": "quill", "value": "q@0.1"},
1076 {"type": "kind", "value": "main"}
1077 ]},
1078 "body": "| A | B |\n| - | - |\n| 1 | 2 |\n"
1079 },
1080 "cards": []
1081 }"#;
1082 let doc: Document = serde_json::from_str(blob).unwrap();
1083 let body = doc.main().body();
1084 assert_eq!(body.islands.len(), 1, "table imports as one island");
1085 assert_eq!(body.islands[0].id, "isl-0", "sequential island id");
1086 assert_eq!(body.islands[0].island_type, "table");
1087 let key = body.to_canonical_json();
1092 assert_eq!(
1093 key,
1094 "{\"islands\":[{\"id\":\"isl-0\",\"loss\":\"lossless\",\"props\":{\
1095 \"aligns\":[\"none\",\"none\"],\
1096 \"header\":[{\"marks\":[],\"text\":\"A\"},{\"marks\":[],\"text\":\"B\"}],\
1097 \"rows\":[[{\"marks\":[],\"text\":\"1\"},{\"marks\":[],\"text\":\"2\"}]]},\
1098 \"type\":\"table\"}],\
1099 \"lines\":[{\"containers\":[],\"kind\":\"island\"}],\
1100 \"marks\":[],\"text\":\"\u{FFFC}\"}",
1101 "regenerated @0.93.0 golden: cells are structured text+marks"
1102 );
1103
1104 let again: Document = serde_json::from_str(blob).unwrap();
1105 assert_eq!(
1106 serde_json::to_string(&doc).unwrap(),
1107 serde_json::to_string(&again).unwrap(),
1108 "same legacy input → same migrated bytes"
1109 );
1110 let reser = serde_json::to_string(&doc).unwrap();
1111 assert_eq!(peek_schema_version(&reser).as_deref(), Some(SCHEMA_V0_93_0));
1112 }
1113
1114 #[test]
1115 fn over_nested_legacy_body_is_malformed() {
1116 let deep = ">".repeat(crate::error::MAX_NESTING_DEPTH + 5);
1120 let card = CardV0_92_0 {
1121 payload: PayloadV0_92_0::default(),
1122 body: format!("{deep} too deep"),
1123 };
1124 let err = CardV0_93_0::try_from(card).unwrap_err();
1125 assert!(matches!(err, StorageError::Malformed(_)), "got: {err:?}");
1126 assert!(err.to_string().contains("card body"));
1127 }
1128
1129 #[test]
1130 fn deserialize_rejects_invalid_content_body() {
1131 let blob = r#"{
1135 "schema": "quillmark/document@0.93.0",
1136 "main": {
1137 "payload": {"items": [
1138 {"type": "quill", "value": "q@0.1"},
1139 {"type": "kind", "value": "main"}
1140 ]},
1141 "body": {"text": "a\nb", "lines": [{"kind": "para", "containers": []}], "marks": [], "islands": []}
1142 },
1143 "cards": []
1144 }"#;
1145 assert!(serde_json::from_str::<Document>(blob).is_err());
1146 }
1147}