1#![allow(non_camel_case_types)]
31
32use std::str::FromStr;
33
34use serde::{Deserialize, Serialize};
35
36use super::meta::validate_composable_kind;
37use super::payload::{Payload, PayloadItem};
38use super::prescan::{CommentPathSegment, NestedComment};
39use super::{Card, Document};
40use crate::value::QuillValue;
41use crate::version::QuillReference;
42
43pub const SCHEMA_V0_81_0: &str = "quillmark/document@0.81.0";
47
48pub const SCHEMA_V0_82_0: &str = "quillmark/document@0.82.0";
51
52pub fn peek_schema_version(json: &str) -> Option<String> {
61 #[derive(Deserialize)]
62 struct Peek {
63 schema: Option<String>,
64 }
65 serde_json::from_str::<Peek>(json).ok()?.schema
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[serde(tag = "schema")]
75pub enum StoredDocument {
76 #[serde(rename = "quillmark/document@0.82.0")]
78 V0_82_0(DocumentV0_82_0),
79 #[serde(rename = "quillmark/document@0.81.0")]
82 V0_81_0(DocumentV0_81_0),
83}
84
85#[derive(Debug, Clone, PartialEq)]
94pub enum StorageError {
95 InvalidQuillReference {
97 value: String,
99 reason: String,
101 },
102 Malformed(String),
105}
106
107impl std::fmt::Display for StorageError {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 StorageError::InvalidQuillReference { value, reason } => {
111 write!(f, "invalid quill reference {value:?}: {reason}")
112 }
113 StorageError::Malformed(msg) => f.write_str(msg),
114 }
115 }
116}
117
118impl std::error::Error for StorageError {}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct DocumentV0_82_0 {
125 pub main: CardV0_82_0,
126 #[serde(default)]
127 pub cards: Vec<CardV0_82_0>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct CardV0_82_0 {
133 pub payload: PayloadV0_82_0,
134 #[serde(default)]
135 pub body: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
140pub struct PayloadV0_82_0 {
141 #[serde(default)]
142 pub items: Vec<PayloadItemV0_82_0>,
143 #[serde(default)]
144 pub nested_comments: Vec<NestedCommentV0_82_0>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "lowercase")]
154pub enum PayloadItemV0_82_0 {
155 Quill { value: String },
157 Kind { value: String },
159 Id { value: String },
161 Ext {
165 value: serde_json::Map<String, serde_json::Value>,
166 },
167 Field {
169 key: String,
170 value: serde_json::Value,
171 #[serde(default)]
172 fill: bool,
173 },
174 Comment {
176 text: String,
177 #[serde(default)]
178 inline: bool,
179 },
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct NestedCommentV0_82_0 {
185 pub container_path: Vec<CommentPathSegmentV0_82_0>,
186 pub position: usize,
187 pub text: String,
188 pub inline: bool,
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub enum CommentPathSegmentV0_82_0 {
194 Key(String),
195 Index(usize),
196}
197
198impl From<Document> for StoredDocument {
201 fn from(doc: Document) -> Self {
202 StoredDocument::V0_82_0(DocumentV0_82_0::from(&doc))
203 }
204}
205
206impl From<&Document> for DocumentV0_82_0 {
207 fn from(doc: &Document) -> Self {
208 DocumentV0_82_0 {
209 main: CardV0_82_0::from(doc.main()),
210 cards: doc.cards().iter().map(CardV0_82_0::from).collect(),
211 }
212 }
213}
214
215impl From<&Card> for CardV0_82_0 {
216 fn from(card: &Card) -> Self {
217 CardV0_82_0 {
218 payload: PayloadV0_82_0::from(card.payload()),
219 body: card.body().to_string(),
220 }
221 }
222}
223
224impl From<&Payload> for PayloadV0_82_0 {
225 fn from(payload: &Payload) -> Self {
226 let nested_comments = payload
230 .flat_nested_comments()
231 .iter()
232 .map(NestedCommentV0_82_0::from)
233 .collect();
234 PayloadV0_82_0 {
235 items: payload
236 .items()
237 .iter()
238 .map(PayloadItemV0_82_0::from)
239 .collect(),
240 nested_comments,
241 }
242 }
243}
244
245impl From<&PayloadItem> for PayloadItemV0_82_0 {
246 fn from(item: &PayloadItem) -> Self {
247 match item {
248 PayloadItem::Quill { reference } => PayloadItemV0_82_0::Quill {
249 value: reference.to_string(),
250 },
251 PayloadItem::Kind { value } => PayloadItemV0_82_0::Kind {
252 value: value.clone(),
253 },
254 PayloadItem::Id { value } => PayloadItemV0_82_0::Id {
255 value: value.clone(),
256 },
257 PayloadItem::Ext { value, .. } => PayloadItemV0_82_0::Ext {
261 value: value.clone(),
262 },
263 PayloadItem::Field {
267 key, value, fill, ..
268 } => PayloadItemV0_82_0::Field {
269 key: key.clone(),
270 value: value.as_json().clone(),
271 fill: *fill,
272 },
273 PayloadItem::Comment { text, inline } => PayloadItemV0_82_0::Comment {
274 text: text.clone(),
275 inline: *inline,
276 },
277 }
278 }
279}
280
281impl From<&NestedComment> for NestedCommentV0_82_0 {
282 fn from(nc: &NestedComment) -> Self {
283 NestedCommentV0_82_0 {
284 container_path: nc
285 .container_path
286 .iter()
287 .map(CommentPathSegmentV0_82_0::from)
288 .collect(),
289 position: nc.position,
290 text: nc.text.clone(),
291 inline: nc.inline,
292 }
293 }
294}
295
296impl From<&CommentPathSegment> for CommentPathSegmentV0_82_0 {
297 fn from(seg: &CommentPathSegment) -> Self {
298 match seg {
299 CommentPathSegment::Key(k) => CommentPathSegmentV0_82_0::Key(k.clone()),
300 CommentPathSegment::Index(i) => CommentPathSegmentV0_82_0::Index(*i),
301 }
302 }
303}
304
305impl TryFrom<StoredDocument> for Document {
306 type Error = StorageError;
307
308 fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
309 match stored {
310 StoredDocument::V0_82_0(payload) => Document::try_from(payload),
311 StoredDocument::V0_81_0(payload) => Document::try_from(DocumentV0_82_0::from(payload)),
312 }
313 }
314}
315
316impl TryFrom<DocumentV0_82_0> for Document {
317 type Error = StorageError;
318
319 fn try_from(payload: DocumentV0_82_0) -> Result<Self, Self::Error> {
320 let main = Card::try_from(payload.main)?;
321 if main.quill().is_none() {
322 return Err(StorageError::Malformed(
323 "main card must carry a $quill entry".into(),
324 ));
325 }
326 let cards = payload
327 .cards
328 .into_iter()
329 .map(Card::try_from)
330 .collect::<Result<Vec<_>, _>>()?;
331 for card in &cards {
332 if card.quill().is_some() {
333 return Err(StorageError::Malformed(
334 "composable cards must not carry a $quill entry".into(),
335 ));
336 }
337 if let Some(kind) = card.kind() {
338 match validate_composable_kind(kind) {
339 Ok(()) => {}
340 Err(super::meta::CardKindError::InvalidName) => {
341 return Err(StorageError::Malformed(format!(
342 "invalid composable card kind {kind:?}: must match \
343 [a-z_][a-z0-9_]*"
344 )));
345 }
346 Err(super::meta::CardKindError::Reserved) => {
347 return Err(StorageError::Malformed(format!(
348 "composable card kind {kind:?} is reserved (root only)"
349 )));
350 }
351 }
352 }
353 }
354 Ok(Document::from_main_and_cards(main, cards, Vec::new()))
355 }
356}
357
358impl TryFrom<CardV0_82_0> for Card {
359 type Error = StorageError;
360
361 fn try_from(card: CardV0_82_0) -> Result<Self, Self::Error> {
362 let payload = Payload::try_from(card.payload)?;
363 validate_dto_payload(&payload)?;
364 Ok(Card::from_parts(payload, card.body))
365 }
366}
367
368impl TryFrom<PayloadV0_82_0> for Payload {
369 type Error = StorageError;
370
371 fn try_from(p: PayloadV0_82_0) -> Result<Self, Self::Error> {
372 let mut items = Vec::with_capacity(p.items.len());
373 for item in p.items {
374 items.push(PayloadItem::try_from(item)?);
375 }
376 let nested = p
377 .nested_comments
378 .into_iter()
379 .map(NestedComment::from)
380 .collect();
381 Ok(Payload::from_items_with_flat_nested(items, nested))
384 }
385}
386
387impl TryFrom<PayloadItemV0_82_0> for PayloadItem {
388 type Error = StorageError;
389
390 fn try_from(item: PayloadItemV0_82_0) -> Result<Self, Self::Error> {
391 Ok(match item {
392 PayloadItemV0_82_0::Quill { value } => {
393 let reference = QuillReference::from_str(&value).map_err(|reason| {
394 StorageError::InvalidQuillReference {
395 value: value.clone(),
396 reason,
397 }
398 })?;
399 PayloadItem::Quill { reference }
400 }
401 PayloadItemV0_82_0::Kind { value } => PayloadItem::Kind { value },
402 PayloadItemV0_82_0::Id { value } => PayloadItem::Id { value },
403 PayloadItemV0_82_0::Ext { value } => PayloadItem::Ext {
404 value,
405 nested_comments: Vec::new(),
406 },
407 PayloadItemV0_82_0::Field { key, value, fill } => PayloadItem::Field {
408 key,
409 value: QuillValue::from_json(value),
410 fill,
411 nested_comments: Vec::new(),
412 },
413 PayloadItemV0_82_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
414 })
415 }
416}
417
418impl From<NestedCommentV0_82_0> for NestedComment {
419 fn from(nc: NestedCommentV0_82_0) -> Self {
420 NestedComment {
421 container_path: nc
422 .container_path
423 .into_iter()
424 .map(CommentPathSegment::from)
425 .collect(),
426 position: nc.position,
427 text: nc.text,
428 inline: nc.inline,
429 }
430 }
431}
432
433impl From<CommentPathSegmentV0_82_0> for CommentPathSegment {
434 fn from(seg: CommentPathSegmentV0_82_0) -> Self {
435 match seg {
436 CommentPathSegmentV0_82_0::Key(k) => CommentPathSegment::Key(k),
437 CommentPathSegmentV0_82_0::Index(i) => CommentPathSegment::Index(i),
438 }
439 }
440}
441
442fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
446 if payload.len() > crate::error::MAX_FIELD_COUNT {
447 return Err(StorageError::Malformed(format!(
448 "card has {} user fields, exceeding the maximum of {}",
449 payload.len(),
450 crate::error::MAX_FIELD_COUNT
451 )));
452 }
453 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
454 for key in payload.keys() {
455 if !seen.insert(key.as_str()) {
456 return Err(StorageError::Malformed(format!(
457 "duplicate user-field key {key:?}"
458 )));
459 }
460 }
461 Ok(())
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
468pub struct DocumentV0_81_0 {
469 pub main: CardV0_81_0,
470 #[serde(default)]
471 pub cards: Vec<CardV0_81_0>,
472}
473
474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476pub struct CardV0_81_0 {
477 pub sentinel: SentinelV0_81_0,
478 #[serde(default)]
479 pub frontmatter: FrontmatterV0_81_0,
480 #[serde(default)]
481 pub body: String,
482}
483
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486#[serde(tag = "kind", rename_all = "lowercase")]
487pub enum SentinelV0_81_0 {
488 Main { quill: String },
489 Card { tag: String },
490}
491
492#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
494pub struct FrontmatterV0_81_0 {
495 #[serde(default)]
496 pub items: Vec<FrontmatterItemV0_81_0>,
497 #[serde(default)]
498 pub nested_comments: Vec<NestedCommentV0_81_0>,
499}
500
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
503#[serde(tag = "kind", rename_all = "lowercase")]
504pub enum FrontmatterItemV0_81_0 {
505 Field {
506 key: String,
507 value: serde_json::Value,
508 #[serde(default)]
509 fill: bool,
510 },
511 Comment {
512 text: String,
513 #[serde(default)]
514 inline: bool,
515 },
516}
517
518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
520pub struct NestedCommentV0_81_0 {
521 pub container_path: Vec<CommentPathSegmentV0_81_0>,
522 pub position: usize,
523 pub text: String,
524 pub inline: bool,
525}
526
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
529pub enum CommentPathSegmentV0_81_0 {
530 Key(String),
531 Index(usize),
532}
533
534impl From<DocumentV0_81_0> for DocumentV0_82_0 {
542 fn from(d: DocumentV0_81_0) -> Self {
543 DocumentV0_82_0 {
544 main: CardV0_82_0::from(d.main),
545 cards: d.cards.into_iter().map(CardV0_82_0::from).collect(),
546 }
547 }
548}
549
550impl From<CardV0_81_0> for CardV0_82_0 {
551 fn from(c: CardV0_81_0) -> Self {
552 let mut items: Vec<PayloadItemV0_82_0> = Vec::new();
553
554 match c.sentinel {
559 SentinelV0_81_0::Main { quill } => {
560 items.push(PayloadItemV0_82_0::Quill { value: quill });
561 items.push(PayloadItemV0_82_0::Kind {
562 value: "main".into(),
563 });
564 }
565 SentinelV0_81_0::Card { tag } => {
566 items.push(PayloadItemV0_82_0::Kind { value: tag });
567 }
568 }
569
570 for item in c.frontmatter.items {
574 items.push(match item {
575 FrontmatterItemV0_81_0::Field { key, value, fill } => {
576 PayloadItemV0_82_0::Field { key, value, fill }
577 }
578 FrontmatterItemV0_81_0::Comment { text, inline } => {
579 PayloadItemV0_82_0::Comment { text, inline }
580 }
581 });
582 }
583
584 let nested_comments = c
585 .frontmatter
586 .nested_comments
587 .into_iter()
588 .map(NestedCommentV0_82_0::from)
589 .collect();
590
591 CardV0_82_0 {
592 payload: PayloadV0_82_0 {
593 items,
594 nested_comments,
595 },
596 body: c.body,
597 }
598 }
599}
600
601impl From<NestedCommentV0_81_0> for NestedCommentV0_82_0 {
602 fn from(nc: NestedCommentV0_81_0) -> Self {
603 NestedCommentV0_82_0 {
604 container_path: nc
605 .container_path
606 .into_iter()
607 .map(CommentPathSegmentV0_82_0::from)
608 .collect(),
609 position: nc.position,
610 text: nc.text,
611 inline: nc.inline,
612 }
613 }
614}
615
616impl From<CommentPathSegmentV0_81_0> for CommentPathSegmentV0_82_0 {
617 fn from(seg: CommentPathSegmentV0_81_0) -> Self {
618 match seg {
619 CommentPathSegmentV0_81_0::Key(k) => CommentPathSegmentV0_82_0::Key(k),
620 CommentPathSegmentV0_81_0::Index(i) => CommentPathSegmentV0_82_0::Index(i),
621 }
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn sample() -> Document {
630 Document::from_markdown(
631 "\
632~~~card-yaml
633$quill: usaf_memo@0.1
634$kind: main
635# a top-level comment
636memo_for:
637 - ORG/SYMBOL # inline comment inside a sequence
638date: 2504-10-05
639subject: !fill Subject of the Memorandum
640~~~
641
642The body of the memorandum.
643
644~~~card-yaml
645$kind: indorsement
646for: ORG/SYMBOL
647from: ORG/SYMBOL
648~~~
649
650This body and the metadata above are an indorsement card.
651",
652 )
653 .unwrap()
654 }
655
656 #[test]
657 fn round_trips_through_serde_json() {
658 let doc = sample();
659 let json = serde_json::to_string(&doc).unwrap();
660 let restored: Document = serde_json::from_str(&json).unwrap();
661 assert_eq!(doc, restored);
662 assert_eq!(doc.to_markdown(), restored.to_markdown());
663 }
664
665 #[test]
666 fn serialization_uses_v0_82_0_schema() {
667 let doc = sample();
668 let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
669 assert_eq!(value["schema"], SCHEMA_V0_82_0);
670 }
671
672 #[test]
673 fn root_kind_is_main_through_round_trip() {
674 let doc = Document::from_markdown(
675 "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
676 )
677 .unwrap();
678 assert_eq!(doc.main().kind(), Some("main"));
679 let restored: Document =
680 serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
681 assert_eq!(doc, restored);
682 assert_eq!(restored.main().kind(), Some("main"));
683 }
684
685 #[test]
686 fn serialization_is_byte_deterministic() {
687 let doc = sample();
691 let first = serde_json::to_string(&doc).unwrap();
692 let second = serde_json::to_string(&doc).unwrap();
693 assert_eq!(first, second, "to_string must be deterministic");
694 let restored: Document = serde_json::from_str(&first).unwrap();
695 let third = serde_json::to_string(&restored).unwrap();
696 assert_eq!(first, third, "byte-equality must survive a round-trip");
697 }
698
699 #[test]
700 fn rejects_unknown_schema_version() {
701 let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
702 assert!(serde_json::from_str::<Document>(json).is_err());
703 }
704
705 #[test]
706 fn peek_schema_version_reads_field_without_full_parse() {
707 let doc = sample();
708 let json = serde_json::to_string(&doc).unwrap();
709 assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_82_0));
710
711 let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
713 assert_eq!(
714 peek_schema_version(future).as_deref(),
715 Some("quillmark/document@0.99.0")
716 );
717 assert_eq!(peek_schema_version("not json"), None);
718 assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
719 }
720
721 #[test]
722 fn comment_on_dollar_line_round_trips() {
723 let src = "\
726~~~card-yaml
727$quill: q@1.0
728$kind: main # required for root
729title: Hi
730~~~
731";
732 let doc = Document::from_markdown(src).unwrap();
733 let json = serde_json::to_string(&doc).unwrap();
734 let restored: Document = serde_json::from_str(&json).unwrap();
735 assert_eq!(doc, restored);
736 assert!(restored
738 .to_markdown()
739 .contains("$kind: main # required for root"));
740 }
741
742 #[test]
743 fn v0_81_0_payload_loads_via_migration() {
744 let json = r#"{
745 "schema": "quillmark/document@0.81.0",
746 "main": {
747 "sentinel": {"kind": "main", "quill": "usaf_memo@0.1"},
748 "frontmatter": {
749 "items": [{"kind": "field", "key": "title", "value": "Hello"}]
750 },
751 "body": "Body."
752 },
753 "cards": []
754 }"#;
755 let doc: Document = serde_json::from_str(json).unwrap();
756 assert_eq!(doc.main().kind(), Some("main"));
757 assert_eq!(doc.quill_reference().to_string(), "usaf_memo@0.1");
758 assert_eq!(
759 doc.main().payload().get("title").unwrap().as_str(),
760 Some("Hello")
761 );
762 }
763
764 #[test]
765 fn v0_81_0_with_composable_card_migrates() {
766 let json = r#"{
767 "schema": "quillmark/document@0.81.0",
768 "main": {
769 "sentinel": {"kind": "main", "quill": "q@1.0"},
770 "frontmatter": {"items": []},
771 "body": ""
772 },
773 "cards": [
774 {
775 "sentinel": {"kind": "card", "tag": "indorsement"},
776 "frontmatter": {"items": [{"kind": "field", "key": "for", "value": "X"}]},
777 "body": "C body"
778 }
779 ]
780 }"#;
781 let doc: Document = serde_json::from_str(json).unwrap();
782 assert_eq!(doc.cards().len(), 1);
783 assert_eq!(doc.cards()[0].kind(), Some("indorsement"));
784 assert_eq!(
785 doc.cards()[0].payload().get("for").unwrap().as_str(),
786 Some("X")
787 );
788 }
789
790 #[test]
791 fn rejects_main_card_without_quill() {
792 let json = r#"{
793 "schema": "quillmark/document@0.82.0",
794 "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
795 "cards": []
796 }"#;
797 let err = serde_json::from_str::<Document>(json).unwrap_err();
798 assert!(err.to_string().contains("$quill"));
799 }
800
801 #[test]
802 fn rejects_composable_card_tagged_main() {
803 let json = r#"{
804 "schema": "quillmark/document@0.82.0",
805 "main": {
806 "payload": {"items": [
807 {"type": "quill", "value": "q@1.0"},
808 {"type": "kind", "value": "main"}
809 ]},
810 "body": ""
811 },
812 "cards": [
813 {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
814 ]
815 }"#;
816 let err = serde_json::from_str::<Document>(json).unwrap_err();
817 assert!(err.to_string().contains("reserved (root only)"));
818 }
819
820 #[test]
821 fn rejects_invalid_quill_reference() {
822 let json = r#"{
823 "schema": "quillmark/document@0.82.0",
824 "main": {
825 "payload": {"items": [
826 {"type": "quill", "value": "not a valid ref!!"},
827 {"type": "kind", "value": "main"}
828 ]},
829 "body": ""
830 },
831 "cards": []
832 }"#;
833 let err = serde_json::from_str::<Document>(json).unwrap_err();
834 assert!(err.to_string().contains("invalid quill reference"));
835 }
836
837}