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