1#![allow(non_camel_case_types)]
41
42use std::str::FromStr;
43
44use serde::{Deserialize, Serialize};
45
46use super::frontmatter::{Frontmatter, FrontmatterItem};
47use super::prescan::{CommentPathSegment, NestedComment};
48use super::{Card, Document, Sentinel};
49use crate::value::QuillValue;
50use crate::version::QuillReference;
51
52pub const SCHEMA_V0_81_0: &str = "quillmark/document@0.81.0";
55
56pub fn peek_schema_version(json: &str) -> Option<String> {
65 #[derive(Deserialize)]
66 struct Peek {
67 schema: Option<String>,
68 }
69 serde_json::from_str::<Peek>(json).ok()?.schema
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(tag = "schema")]
79pub enum StoredDocument {
80 #[serde(rename = "quillmark/document@0.81.0")]
82 V0_81_0(DocumentV0_81_0),
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct DocumentV0_81_0 {
88 pub main: CardV0_81_0,
90 #[serde(default)]
92 pub cards: Vec<CardV0_81_0>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct CardV0_81_0 {
98 pub sentinel: SentinelV0_81_0,
100 #[serde(default)]
102 pub frontmatter: FrontmatterV0_81_0,
103 #[serde(default)]
105 pub body: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(tag = "kind", rename_all = "lowercase")]
111pub enum SentinelV0_81_0 {
112 Main {
115 quill: String,
117 },
118 Card {
120 tag: String,
122 },
123}
124
125#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
127pub struct FrontmatterV0_81_0 {
128 #[serde(default)]
130 pub items: Vec<FrontmatterItemV0_81_0>,
131 #[serde(default)]
133 pub nested_comments: Vec<NestedCommentV0_81_0>,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "lowercase")]
139pub enum FrontmatterItemV0_81_0 {
140 Field {
142 key: String,
144 value: serde_json::Value,
146 #[serde(default)]
148 fill: bool,
149 },
150 Comment {
152 text: String,
154 #[serde(default)]
156 inline: bool,
157 },
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct NestedCommentV0_81_0 {
163 pub container_path: Vec<CommentPathSegmentV0_81_0>,
165 pub position: usize,
167 pub text: String,
169 pub inline: bool,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub enum CommentPathSegmentV0_81_0 {
176 Key(String),
178 Index(usize),
180}
181
182#[derive(Debug, Clone, PartialEq)]
184pub enum StorageError {
185 InvalidQuillReference {
187 value: String,
189 reason: String,
191 },
192 MainCardNotMain,
195 ComposableCardIsMain,
197 InvalidCardTag {
199 tag: String,
201 },
202 TooManyFields {
205 count: usize,
207 },
208 ReservedFieldName {
211 key: String,
213 },
214 DuplicateFieldKey {
216 key: String,
218 },
219}
220
221impl std::fmt::Display for StorageError {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 match self {
224 StorageError::InvalidQuillReference { value, reason } => {
225 write!(f, "invalid quill reference {value:?}: {reason}")
226 }
227 StorageError::MainCardNotMain => {
228 write!(f, "main card must carry a QUILL sentinel, not a card tag")
229 }
230 StorageError::ComposableCardIsMain => write!(
231 f,
232 "composable cards must carry a card tag, not a QUILL sentinel"
233 ),
234 StorageError::InvalidCardTag { tag } => {
235 write!(f, "invalid card tag {tag:?}: must match [a-z_][a-z0-9_]*")
236 }
237 StorageError::TooManyFields { count } => write!(
238 f,
239 "card has {count} frontmatter fields, exceeding the maximum of {}",
240 crate::error::MAX_FIELD_COUNT
241 ),
242 StorageError::ReservedFieldName { key } => {
243 write!(f, "reserved name {key:?} cannot be used as a field name")
244 }
245 StorageError::DuplicateFieldKey { key } => {
246 write!(f, "duplicate frontmatter field key {key:?}")
247 }
248 }
249 }
250}
251
252fn validate_dto_frontmatter(fm: &Frontmatter) -> Result<(), StorageError> {
256 if fm.len() > crate::error::MAX_FIELD_COUNT {
257 return Err(StorageError::TooManyFields { count: fm.len() });
258 }
259 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
260 for key in fm.keys() {
261 if super::edit::is_reserved_name(key) {
262 return Err(StorageError::ReservedFieldName { key: key.clone() });
263 }
264 if !seen.insert(key.as_str()) {
265 return Err(StorageError::DuplicateFieldKey { key: key.clone() });
266 }
267 }
268 Ok(())
269}
270
271impl std::error::Error for StorageError {}
272
273impl From<Document> for StoredDocument {
276 fn from(doc: Document) -> Self {
277 StoredDocument::V0_81_0(DocumentV0_81_0::from(&doc))
278 }
279}
280
281impl From<&Document> for DocumentV0_81_0 {
282 fn from(doc: &Document) -> Self {
283 DocumentV0_81_0 {
284 main: CardV0_81_0::from(doc.main()),
285 cards: doc.cards().iter().map(CardV0_81_0::from).collect(),
286 }
287 }
288}
289
290impl From<&Card> for CardV0_81_0 {
291 fn from(card: &Card) -> Self {
292 CardV0_81_0 {
293 sentinel: SentinelV0_81_0::from(card.sentinel()),
294 frontmatter: FrontmatterV0_81_0::from(card.frontmatter()),
295 body: card.body().to_string(),
296 }
297 }
298}
299
300impl From<&Sentinel> for SentinelV0_81_0 {
301 fn from(sentinel: &Sentinel) -> Self {
302 match sentinel {
303 Sentinel::Main(reference) => SentinelV0_81_0::Main {
304 quill: reference.to_string(),
305 },
306 Sentinel::Card(tag) => SentinelV0_81_0::Card { tag: tag.clone() },
307 }
308 }
309}
310
311impl From<&Frontmatter> for FrontmatterV0_81_0 {
312 fn from(fm: &Frontmatter) -> Self {
313 FrontmatterV0_81_0 {
314 items: fm
315 .items()
316 .iter()
317 .map(FrontmatterItemV0_81_0::from)
318 .collect(),
319 nested_comments: fm
320 .nested_comments()
321 .iter()
322 .map(NestedCommentV0_81_0::from)
323 .collect(),
324 }
325 }
326}
327
328impl From<&FrontmatterItem> for FrontmatterItemV0_81_0 {
329 fn from(item: &FrontmatterItem) -> Self {
330 match item {
331 FrontmatterItem::Field { key, value, fill } => FrontmatterItemV0_81_0::Field {
332 key: key.clone(),
333 value: value.as_json().clone(),
334 fill: *fill,
335 },
336 FrontmatterItem::Comment { text, inline } => FrontmatterItemV0_81_0::Comment {
337 text: text.clone(),
338 inline: *inline,
339 },
340 }
341 }
342}
343
344impl From<&NestedComment> for NestedCommentV0_81_0 {
345 fn from(nc: &NestedComment) -> Self {
346 NestedCommentV0_81_0 {
347 container_path: nc
348 .container_path
349 .iter()
350 .map(CommentPathSegmentV0_81_0::from)
351 .collect(),
352 position: nc.position,
353 text: nc.text.clone(),
354 inline: nc.inline,
355 }
356 }
357}
358
359impl From<&CommentPathSegment> for CommentPathSegmentV0_81_0 {
360 fn from(seg: &CommentPathSegment) -> Self {
361 match seg {
362 CommentPathSegment::Key(k) => CommentPathSegmentV0_81_0::Key(k.clone()),
363 CommentPathSegment::Index(i) => CommentPathSegmentV0_81_0::Index(*i),
364 }
365 }
366}
367
368impl TryFrom<StoredDocument> for Document {
371 type Error = StorageError;
372
373 fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
374 match stored {
375 StoredDocument::V0_81_0(payload) => Document::try_from(payload),
376 }
377 }
378}
379
380impl TryFrom<DocumentV0_81_0> for Document {
381 type Error = StorageError;
382
383 fn try_from(payload: DocumentV0_81_0) -> Result<Self, Self::Error> {
384 let main = Card::try_from(payload.main)?;
385 if !main.sentinel().is_main() {
386 return Err(StorageError::MainCardNotMain);
387 }
388 let cards = payload
389 .cards
390 .into_iter()
391 .map(Card::try_from)
392 .collect::<Result<Vec<_>, _>>()?;
393 if cards.iter().any(|c| c.sentinel().is_main()) {
394 return Err(StorageError::ComposableCardIsMain);
395 }
396 Ok(Document::from_main_and_cards(main, cards))
397 }
398}
399
400impl TryFrom<CardV0_81_0> for Card {
401 type Error = StorageError;
402
403 fn try_from(card: CardV0_81_0) -> Result<Self, Self::Error> {
404 let sentinel = Sentinel::try_from(card.sentinel)?;
405 let frontmatter = Frontmatter::from(card.frontmatter);
406 validate_dto_frontmatter(&frontmatter)?;
407 Ok(Card::new_with_sentinel(sentinel, frontmatter, card.body))
408 }
409}
410
411impl TryFrom<SentinelV0_81_0> for Sentinel {
412 type Error = StorageError;
413
414 fn try_from(sentinel: SentinelV0_81_0) -> Result<Self, Self::Error> {
415 match sentinel {
416 SentinelV0_81_0::Main { quill } => {
417 let reference = QuillReference::from_str(&quill).map_err(|reason| {
418 StorageError::InvalidQuillReference {
419 value: quill.clone(),
420 reason,
421 }
422 })?;
423 Ok(Sentinel::Main(reference))
424 }
425 SentinelV0_81_0::Card { tag } => {
426 if !super::sentinel::is_valid_tag_name(&tag) {
427 return Err(StorageError::InvalidCardTag { tag });
428 }
429 Ok(Sentinel::Card(tag))
430 }
431 }
432 }
433}
434
435impl From<FrontmatterV0_81_0> for Frontmatter {
436 fn from(fm: FrontmatterV0_81_0) -> Self {
437 let items = fm.items.into_iter().map(FrontmatterItem::from).collect();
438 let nested = fm
439 .nested_comments
440 .into_iter()
441 .map(NestedComment::from)
442 .collect();
443 Frontmatter::from_items_with_nested(items, nested)
444 }
445}
446
447impl From<FrontmatterItemV0_81_0> for FrontmatterItem {
448 fn from(item: FrontmatterItemV0_81_0) -> Self {
449 match item {
450 FrontmatterItemV0_81_0::Field { key, value, fill } => FrontmatterItem::Field {
451 key,
452 value: QuillValue::from_json(value),
453 fill,
454 },
455 FrontmatterItemV0_81_0::Comment { text, inline } => {
456 FrontmatterItem::Comment { text, inline }
457 }
458 }
459 }
460}
461
462impl From<NestedCommentV0_81_0> for NestedComment {
463 fn from(nc: NestedCommentV0_81_0) -> Self {
464 NestedComment {
465 container_path: nc
466 .container_path
467 .into_iter()
468 .map(CommentPathSegment::from)
469 .collect(),
470 position: nc.position,
471 text: nc.text,
472 inline: nc.inline,
473 }
474 }
475}
476
477impl From<CommentPathSegmentV0_81_0> for CommentPathSegment {
478 fn from(seg: CommentPathSegmentV0_81_0) -> Self {
479 match seg {
480 CommentPathSegmentV0_81_0::Key(k) => CommentPathSegment::Key(k),
481 CommentPathSegmentV0_81_0::Index(i) => CommentPathSegment::Index(i),
482 }
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 fn sample() -> Document {
491 Document::from_markdown(
492 "\
493---
494QUILL: usaf_memo@0.1
495# a top-level comment
496memo_for:
497 - ORG/SYMBOL # inline comment inside a sequence
498date: 2504-10-05
499subject: !fill Subject of the Memorandum
500---
501
502The body of the memorandum.
503
504```card indorsement
505for: ORG/SYMBOL
506from: ORG/SYMBOL
507```
508
509This body and the metadata above are an indorsement card.
510",
511 )
512 .unwrap()
513 }
514
515 #[test]
516 fn round_trips_through_serde_json() {
517 let doc = sample();
518 let json = serde_json::to_string(&doc).unwrap();
519 let restored: Document = serde_json::from_str(&json).unwrap();
520 assert_eq!(doc, restored);
521 assert_eq!(doc.to_markdown(), restored.to_markdown());
522 }
523
524 #[test]
525 fn serialization_is_byte_deterministic() {
526 let doc = sample();
533 let first = serde_json::to_string(&doc).unwrap();
534 let second = serde_json::to_string(&doc).unwrap();
535 assert_eq!(
536 first, second,
537 "to_string must be deterministic (byte-equal on repeated calls)"
538 );
539 let restored: Document = serde_json::from_str(&first).unwrap();
540 let third = serde_json::to_string(&restored).unwrap();
541 assert_eq!(
542 first, third,
543 "byte-equality must survive a round-trip through fromJson/toJson"
544 );
545
546 let from_markdown = sample();
549 let from_dto: Document =
550 serde_json::from_str(&serde_json::to_string(&from_markdown).unwrap()).unwrap();
551 assert_eq!(from_markdown, from_dto);
552 assert_eq!(
553 serde_json::to_string(&from_markdown).unwrap(),
554 serde_json::to_string(&from_dto).unwrap(),
555 "value-equal documents must serialize to byte-equal strings regardless of construction path"
556 );
557 }
558
559 #[test]
560 fn serialized_form_carries_schema_version() {
561 let doc = sample();
562 let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
563 assert_eq!(value["schema"], SCHEMA_V0_81_0);
564 }
565
566 #[test]
567 fn rejects_unknown_schema_version() {
568 let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
569 assert!(serde_json::from_str::<Document>(json).is_err());
570 }
571
572 #[test]
573 fn peek_schema_version_reads_field_without_full_parse() {
574 let doc = sample();
575 let json = serde_json::to_string(&doc).unwrap();
576 assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_81_0));
577
578 let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
581 assert_eq!(
582 peek_schema_version(future).as_deref(),
583 Some("quillmark/document@0.99.0")
584 );
585
586 assert_eq!(peek_schema_version("not json"), None);
588 assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
589 assert_eq!(peek_schema_version(r#"{"schema":42}"#), None);
590 assert_eq!(peek_schema_version("[1,2,3]"), None);
591 }
592
593 #[test]
594 fn rejects_invalid_quill_reference() {
595 let stored = StoredDocument::V0_81_0(DocumentV0_81_0 {
596 main: CardV0_81_0 {
597 sentinel: SentinelV0_81_0::Main {
598 quill: "not a valid ref!!".to_string(),
599 },
600 frontmatter: FrontmatterV0_81_0::default(),
601 body: String::new(),
602 },
603 cards: Vec::new(),
604 });
605 let err = Document::try_from(stored).unwrap_err();
606 assert!(matches!(err, StorageError::InvalidQuillReference { .. }));
607 }
608
609 #[test]
610 fn rejects_main_card_with_card_sentinel() {
611 let json = r#"{"schema":"quillmark/document@0.81.0",
615 "main":{"sentinel":{"kind":"card","tag":"x"},"frontmatter":{},"body":""}}"#;
616 let err = serde_json::from_str::<Document>(json).unwrap_err();
617 assert!(err.to_string().contains("main card must carry a QUILL sentinel"));
618 }
619
620 #[test]
621 fn rejects_composable_card_with_main_sentinel() {
622 let stored = StoredDocument::V0_81_0(DocumentV0_81_0 {
623 main: CardV0_81_0 {
624 sentinel: SentinelV0_81_0::Main {
625 quill: "usaf_memo@0.1".to_string(),
626 },
627 frontmatter: FrontmatterV0_81_0::default(),
628 body: String::new(),
629 },
630 cards: vec![CardV0_81_0 {
631 sentinel: SentinelV0_81_0::Main {
632 quill: "usaf_memo@0.1".to_string(),
633 },
634 frontmatter: FrontmatterV0_81_0::default(),
635 body: String::new(),
636 }],
637 });
638 let err = Document::try_from(stored).unwrap_err();
639 assert_eq!(err, StorageError::ComposableCardIsMain);
640 }
641
642 fn doc_with_main_frontmatter(fm: FrontmatterV0_81_0) -> StoredDocument {
644 StoredDocument::V0_81_0(DocumentV0_81_0 {
645 main: CardV0_81_0 {
646 sentinel: SentinelV0_81_0::Main {
647 quill: "usaf_memo@0.1".to_string(),
648 },
649 frontmatter: fm,
650 body: String::new(),
651 },
652 cards: Vec::new(),
653 })
654 }
655
656 fn dto_field(key: &str) -> FrontmatterItemV0_81_0 {
657 FrontmatterItemV0_81_0::Field {
658 key: key.to_string(),
659 value: serde_json::json!("x"),
660 fill: false,
661 }
662 }
663
664 #[test]
665 fn rejects_invalid_card_tag() {
666 let stored = StoredDocument::V0_81_0(DocumentV0_81_0 {
667 main: CardV0_81_0 {
668 sentinel: SentinelV0_81_0::Main {
669 quill: "usaf_memo@0.1".to_string(),
670 },
671 frontmatter: FrontmatterV0_81_0::default(),
672 body: String::new(),
673 },
674 cards: vec![CardV0_81_0 {
675 sentinel: SentinelV0_81_0::Card {
676 tag: "Bad Tag".to_string(),
677 },
678 frontmatter: FrontmatterV0_81_0::default(),
679 body: String::new(),
680 }],
681 });
682 let err = Document::try_from(stored).unwrap_err();
683 assert!(matches!(err, StorageError::InvalidCardTag { .. }));
684 }
685
686 #[test]
687 fn rejects_reserved_field_name() {
688 let fm = FrontmatterV0_81_0 {
689 items: vec![dto_field("BODY")],
690 nested_comments: Vec::new(),
691 };
692 let err = Document::try_from(doc_with_main_frontmatter(fm)).unwrap_err();
693 assert!(matches!(err, StorageError::ReservedFieldName { .. }));
694 }
695
696 #[test]
697 fn rejects_duplicate_field_key() {
698 let fm = FrontmatterV0_81_0 {
699 items: vec![dto_field("a"), dto_field("a")],
700 nested_comments: Vec::new(),
701 };
702 let err = Document::try_from(doc_with_main_frontmatter(fm)).unwrap_err();
703 assert!(matches!(err, StorageError::DuplicateFieldKey { .. }));
704 }
705
706 #[test]
707 fn rejects_too_many_fields() {
708 let items = (0..=crate::error::MAX_FIELD_COUNT)
709 .map(|i| dto_field(&format!("f{i}")))
710 .collect();
711 let fm = FrontmatterV0_81_0 {
712 items,
713 nested_comments: Vec::new(),
714 };
715 let err = Document::try_from(doc_with_main_frontmatter(fm)).unwrap_err();
716 assert!(matches!(err, StorageError::TooManyFields { .. }));
717 }
718
719 #[test]
720 fn accepts_non_identifier_field_keys() {
721 let fm = FrontmatterV0_81_0 {
724 items: vec![dto_field("memo-for")],
725 nested_comments: Vec::new(),
726 };
727 assert!(Document::try_from(doc_with_main_frontmatter(fm)).is_ok());
728 }
729
730 #[test]
731 fn explicit_dto_conversion_round_trips() {
732 let doc = sample();
733 let dto = DocumentV0_81_0::from(&doc);
734 let restored = Document::try_from(dto).unwrap();
735 assert_eq!(doc, restored);
736 }
737}