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