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 } => {
404 let as_value = serde_json::Value::Object(value);
405 if crate::value::json_depth_exceeds(
406 &as_value,
407 crate::document::limits::MAX_YAML_DEPTH,
408 ) {
409 return Err(StorageError::Malformed(format!(
410 "$ext nests deeper than the maximum of {} levels",
411 crate::document::limits::MAX_YAML_DEPTH
412 )));
413 }
414 let serde_json::Value::Object(value) = as_value else {
415 unreachable!("constructed as Object above")
416 };
417 PayloadItem::Ext {
418 value,
419 nested_comments: Vec::new(),
420 }
421 }
422 PayloadItemV0_82_0::Field { key, value, fill } => {
423 use super::edit::{validate_field, FieldViolation};
424 validate_field(&key, &value).map_err(|v| {
425 StorageError::Malformed(match v {
426 FieldViolation::InvalidName => format!(
427 "invalid field name {key:?}: must match [a-z_][a-z0-9_]*"
428 ),
429 FieldViolation::TooDeep => format!(
430 "field {key:?} nests deeper than the maximum of {} levels",
431 crate::document::limits::MAX_YAML_DEPTH
432 ),
433 })
434 })?;
435 PayloadItem::Field {
436 key,
437 value: QuillValue::from_json(value),
438 fill,
439 nested_comments: Vec::new(),
440 }
441 }
442 PayloadItemV0_82_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
443 })
444 }
445}
446
447impl From<NestedCommentV0_82_0> for NestedComment {
448 fn from(nc: NestedCommentV0_82_0) -> Self {
449 NestedComment {
450 container_path: nc
451 .container_path
452 .into_iter()
453 .map(CommentPathSegment::from)
454 .collect(),
455 position: nc.position,
456 text: nc.text,
457 inline: nc.inline,
458 }
459 }
460}
461
462impl From<CommentPathSegmentV0_82_0> for CommentPathSegment {
463 fn from(seg: CommentPathSegmentV0_82_0) -> Self {
464 match seg {
465 CommentPathSegmentV0_82_0::Key(k) => CommentPathSegment::Key(k),
466 CommentPathSegmentV0_82_0::Index(i) => CommentPathSegment::Index(i),
467 }
468 }
469}
470
471fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
475 if payload.len() > crate::error::MAX_FIELD_COUNT {
476 return Err(StorageError::Malformed(format!(
477 "card has {} user fields, exceeding the maximum of {}",
478 payload.len(),
479 crate::error::MAX_FIELD_COUNT
480 )));
481 }
482 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
483 for key in payload.keys() {
484 if !seen.insert(key.as_str()) {
485 return Err(StorageError::Malformed(format!(
486 "duplicate user-field key {key:?}"
487 )));
488 }
489 }
490 Ok(())
491}
492
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497pub struct DocumentV0_81_0 {
498 pub main: CardV0_81_0,
499 #[serde(default)]
500 pub cards: Vec<CardV0_81_0>,
501}
502
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct CardV0_81_0 {
506 pub sentinel: SentinelV0_81_0,
507 #[serde(default)]
508 pub frontmatter: FrontmatterV0_81_0,
509 #[serde(default)]
510 pub body: String,
511}
512
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515#[serde(tag = "kind", rename_all = "lowercase")]
516pub enum SentinelV0_81_0 {
517 Main { quill: String },
518 Card { tag: String },
519}
520
521#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
523pub struct FrontmatterV0_81_0 {
524 #[serde(default)]
525 pub items: Vec<FrontmatterItemV0_81_0>,
526 #[serde(default)]
527 pub nested_comments: Vec<NestedCommentV0_81_0>,
528}
529
530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(tag = "kind", rename_all = "lowercase")]
533pub enum FrontmatterItemV0_81_0 {
534 Field {
535 key: String,
536 value: serde_json::Value,
537 #[serde(default)]
538 fill: bool,
539 },
540 Comment {
541 text: String,
542 #[serde(default)]
543 inline: bool,
544 },
545}
546
547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
549pub struct NestedCommentV0_81_0 {
550 pub container_path: Vec<CommentPathSegmentV0_81_0>,
551 pub position: usize,
552 pub text: String,
553 pub inline: bool,
554}
555
556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
558pub enum CommentPathSegmentV0_81_0 {
559 Key(String),
560 Index(usize),
561}
562
563impl From<DocumentV0_81_0> for DocumentV0_82_0 {
571 fn from(d: DocumentV0_81_0) -> Self {
572 DocumentV0_82_0 {
573 main: CardV0_82_0::from(d.main),
574 cards: d.cards.into_iter().map(CardV0_82_0::from).collect(),
575 }
576 }
577}
578
579impl From<CardV0_81_0> for CardV0_82_0 {
580 fn from(c: CardV0_81_0) -> Self {
581 let mut items: Vec<PayloadItemV0_82_0> = Vec::new();
582
583 match c.sentinel {
588 SentinelV0_81_0::Main { quill } => {
589 items.push(PayloadItemV0_82_0::Quill { value: quill });
590 items.push(PayloadItemV0_82_0::Kind {
591 value: "main".into(),
592 });
593 }
594 SentinelV0_81_0::Card { tag } => {
595 items.push(PayloadItemV0_82_0::Kind { value: tag });
596 }
597 }
598
599 for item in c.frontmatter.items {
603 items.push(match item {
604 FrontmatterItemV0_81_0::Field { key, value, fill } => {
605 PayloadItemV0_82_0::Field { key, value, fill }
606 }
607 FrontmatterItemV0_81_0::Comment { text, inline } => {
608 PayloadItemV0_82_0::Comment { text, inline }
609 }
610 });
611 }
612
613 let nested_comments = c
614 .frontmatter
615 .nested_comments
616 .into_iter()
617 .map(NestedCommentV0_82_0::from)
618 .collect();
619
620 CardV0_82_0 {
621 payload: PayloadV0_82_0 {
622 items,
623 nested_comments,
624 },
625 body: c.body,
626 }
627 }
628}
629
630impl From<NestedCommentV0_81_0> for NestedCommentV0_82_0 {
631 fn from(nc: NestedCommentV0_81_0) -> Self {
632 NestedCommentV0_82_0 {
633 container_path: nc
634 .container_path
635 .into_iter()
636 .map(CommentPathSegmentV0_82_0::from)
637 .collect(),
638 position: nc.position,
639 text: nc.text,
640 inline: nc.inline,
641 }
642 }
643}
644
645impl From<CommentPathSegmentV0_81_0> for CommentPathSegmentV0_82_0 {
646 fn from(seg: CommentPathSegmentV0_81_0) -> Self {
647 match seg {
648 CommentPathSegmentV0_81_0::Key(k) => CommentPathSegmentV0_82_0::Key(k),
649 CommentPathSegmentV0_81_0::Index(i) => CommentPathSegmentV0_82_0::Index(i),
650 }
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 fn sample() -> Document {
659 Document::from_markdown(
660 "\
661~~~card-yaml
662$quill: usaf_memo@0.1
663$kind: main
664# a top-level comment
665memo_for:
666 - ORG/SYMBOL # inline comment inside a sequence
667date: 2504-10-05
668subject: !fill Subject of the Memorandum
669~~~
670
671The body of the memorandum.
672
673~~~card-yaml
674$kind: indorsement
675for: ORG/SYMBOL
676from: ORG/SYMBOL
677~~~
678
679This body and the metadata above are an indorsement card.
680",
681 )
682 .unwrap()
683 }
684
685 #[test]
686 fn round_trips_through_serde_json() {
687 let doc = sample();
688 let json = serde_json::to_string(&doc).unwrap();
689 let restored: Document = serde_json::from_str(&json).unwrap();
690 assert_eq!(doc, restored);
691 assert_eq!(doc.to_markdown(), restored.to_markdown());
692 }
693
694 #[test]
695 fn serialization_uses_v0_82_0_schema() {
696 let doc = sample();
697 let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
698 assert_eq!(value["schema"], SCHEMA_V0_82_0);
699 }
700
701 #[test]
702 fn root_kind_is_main_through_round_trip() {
703 let doc = Document::from_markdown(
704 "~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
705 )
706 .unwrap();
707 assert_eq!(doc.main().kind(), Some("main"));
708 let restored: Document =
709 serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
710 assert_eq!(doc, restored);
711 assert_eq!(restored.main().kind(), Some("main"));
712 }
713
714 #[test]
715 fn serialization_is_byte_deterministic() {
716 let doc = sample();
720 let first = serde_json::to_string(&doc).unwrap();
721 let second = serde_json::to_string(&doc).unwrap();
722 assert_eq!(first, second, "to_string must be deterministic");
723 let restored: Document = serde_json::from_str(&first).unwrap();
724 let third = serde_json::to_string(&restored).unwrap();
725 assert_eq!(first, third, "byte-equality must survive a round-trip");
726 }
727
728 #[test]
729 fn rejects_unknown_schema_version() {
730 let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
731 assert!(serde_json::from_str::<Document>(json).is_err());
732 }
733
734 #[test]
735 fn peek_schema_version_reads_field_without_full_parse() {
736 let doc = sample();
737 let json = serde_json::to_string(&doc).unwrap();
738 assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_82_0));
739
740 let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
742 assert_eq!(
743 peek_schema_version(future).as_deref(),
744 Some("quillmark/document@0.99.0")
745 );
746 assert_eq!(peek_schema_version("not json"), None);
747 assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
748 }
749
750 #[test]
751 fn comment_on_dollar_line_round_trips() {
752 let src = "\
755~~~card-yaml
756$quill: q@1.0
757$kind: main # required for root
758title: Hi
759~~~
760";
761 let doc = Document::from_markdown(src).unwrap();
762 let json = serde_json::to_string(&doc).unwrap();
763 let restored: Document = serde_json::from_str(&json).unwrap();
764 assert_eq!(doc, restored);
765 assert!(restored
767 .to_markdown()
768 .contains("$kind: main # required for root"));
769 }
770
771 #[test]
772 fn v0_81_0_payload_loads_via_migration() {
773 let json = r#"{
774 "schema": "quillmark/document@0.81.0",
775 "main": {
776 "sentinel": {"kind": "main", "quill": "usaf_memo@0.1"},
777 "frontmatter": {
778 "items": [{"kind": "field", "key": "title", "value": "Hello"}]
779 },
780 "body": "Body."
781 },
782 "cards": []
783 }"#;
784 let doc: Document = serde_json::from_str(json).unwrap();
785 assert_eq!(doc.main().kind(), Some("main"));
786 assert_eq!(doc.quill_reference().to_string(), "usaf_memo@0.1");
787 assert_eq!(
788 doc.main().payload().get("title").unwrap().as_str(),
789 Some("Hello")
790 );
791 }
792
793 #[test]
794 fn v0_81_0_with_composable_card_migrates() {
795 let json = r#"{
796 "schema": "quillmark/document@0.81.0",
797 "main": {
798 "sentinel": {"kind": "main", "quill": "q@1.0"},
799 "frontmatter": {"items": []},
800 "body": ""
801 },
802 "cards": [
803 {
804 "sentinel": {"kind": "card", "tag": "indorsement"},
805 "frontmatter": {"items": [{"kind": "field", "key": "for", "value": "X"}]},
806 "body": "C body"
807 }
808 ]
809 }"#;
810 let doc: Document = serde_json::from_str(json).unwrap();
811 assert_eq!(doc.cards().len(), 1);
812 assert_eq!(doc.cards()[0].kind(), Some("indorsement"));
813 assert_eq!(
814 doc.cards()[0].payload().get("for").unwrap().as_str(),
815 Some("X")
816 );
817 }
818
819 #[test]
820 fn rejects_main_card_without_quill() {
821 let json = r#"{
822 "schema": "quillmark/document@0.82.0",
823 "main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
824 "cards": []
825 }"#;
826 let err = serde_json::from_str::<Document>(json).unwrap_err();
827 assert!(err.to_string().contains("$quill"));
828 }
829
830 #[test]
831 fn rejects_composable_card_tagged_main() {
832 let json = r#"{
833 "schema": "quillmark/document@0.82.0",
834 "main": {
835 "payload": {"items": [
836 {"type": "quill", "value": "q@1.0"},
837 {"type": "kind", "value": "main"}
838 ]},
839 "body": ""
840 },
841 "cards": [
842 {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
843 ]
844 }"#;
845 let err = serde_json::from_str::<Document>(json).unwrap_err();
846 assert!(err.to_string().contains("reserved (root only)"));
847 }
848
849 #[test]
850 fn rejects_invalid_quill_reference() {
851 let json = r#"{
852 "schema": "quillmark/document@0.82.0",
853 "main": {
854 "payload": {"items": [
855 {"type": "quill", "value": "not a valid ref!!"},
856 {"type": "kind", "value": "main"}
857 ]},
858 "body": ""
859 },
860 "cards": []
861 }"#;
862 let err = serde_json::from_str::<Document>(json).unwrap_err();
863 assert!(err.to_string().contains("invalid quill reference"));
864 }
865
866}