#![allow(non_camel_case_types)]
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::meta::validate_composable_kind;
use super::payload::{MetaKey, Payload, PayloadItem};
use super::prescan::{CommentPathSegment, NestedComment};
use super::{Card, Document};
use crate::value::QuillValue;
use crate::version::QuillReference;
pub const SCHEMA_V0_92_0: &str = "quillmark/document@0.92.0";
pub fn peek_schema_version(json: &str) -> Option<String> {
#[derive(Deserialize)]
struct Peek {
schema: Option<String>,
}
serde_json::from_str::<Peek>(json).ok()?.schema
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "schema")]
pub enum StoredDocument {
#[serde(rename = "quillmark/document@0.92.0")]
V0_92_0(DocumentV0_92_0),
#[serde(rename = "quillmark/document@0.82.0")]
V0_82_0(DocumentV0_82_0),
#[serde(rename = "quillmark/document@0.81.0")]
V0_81_0(DocumentV0_81_0),
}
#[derive(Debug, Clone, PartialEq)]
pub enum StorageError {
InvalidQuillReference {
value: String,
reason: String,
},
Malformed(String),
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StorageError::InvalidQuillReference { value, reason } => {
write!(f, "invalid quill reference {value:?}: {reason}")
}
StorageError::Malformed(msg) => f.write_str(msg),
}
}
}
impl std::error::Error for StorageError {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocumentV0_82_0 {
pub main: CardV0_82_0,
#[serde(default)]
pub cards: Vec<CardV0_82_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CardV0_82_0 {
pub payload: PayloadV0_82_0,
#[serde(default)]
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PayloadV0_82_0 {
#[serde(default)]
pub items: Vec<PayloadItemV0_82_0>,
#[serde(default)]
pub nested_comments: Vec<NestedCommentV0_82_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PayloadItemV0_82_0 {
Quill { value: String },
Kind { value: String },
Id { value: String },
Ext {
value: serde_json::Map<String, serde_json::Value>,
},
Field {
key: String,
value: serde_json::Value,
#[serde(default)]
fill: bool,
},
Comment {
text: String,
#[serde(default)]
inline: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NestedCommentV0_82_0 {
pub container_path: Vec<CommentPathSegmentV0_82_0>,
pub position: usize,
pub text: String,
pub inline: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CommentPathSegmentV0_82_0 {
Key(String),
Index(usize),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocumentV0_92_0 {
pub main: CardV0_92_0,
#[serde(default)]
pub cards: Vec<CardV0_92_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CardV0_92_0 {
pub payload: PayloadV0_92_0,
#[serde(default)]
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PayloadV0_92_0 {
#[serde(default)]
pub items: Vec<PayloadItemV0_92_0>,
#[serde(default)]
pub nested_comments: Vec<NestedCommentV0_92_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PayloadItemV0_92_0 {
Quill { value: String },
Kind { value: String },
Id { value: String },
Ext {
value: serde_json::Map<String, serde_json::Value>,
},
Seed {
value: serde_json::Map<String, serde_json::Value>,
},
Field {
key: String,
value: serde_json::Value,
#[serde(default)]
fill: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
nested_fills: Vec<Vec<CommentPathSegmentV0_92_0>>,
},
Comment {
text: String,
#[serde(default)]
inline: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NestedCommentV0_92_0 {
pub container_path: Vec<CommentPathSegmentV0_92_0>,
pub position: usize,
pub text: String,
pub inline: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CommentPathSegmentV0_92_0 {
Key(String),
Index(usize),
}
impl From<Document> for StoredDocument {
fn from(doc: Document) -> Self {
StoredDocument::V0_92_0(DocumentV0_92_0::from(&doc))
}
}
impl From<&Document> for DocumentV0_92_0 {
fn from(doc: &Document) -> Self {
DocumentV0_92_0 {
main: CardV0_92_0::from(doc.main()),
cards: doc.cards().iter().map(CardV0_92_0::from).collect(),
}
}
}
impl From<&Card> for CardV0_92_0 {
fn from(card: &Card) -> Self {
CardV0_92_0 {
payload: PayloadV0_92_0::from(card.payload()),
body: card.body().to_string(),
}
}
}
impl From<&Payload> for PayloadV0_92_0 {
fn from(payload: &Payload) -> Self {
let nested_comments = payload
.flat_nested_comments()
.iter()
.map(NestedCommentV0_92_0::from)
.collect();
PayloadV0_92_0 {
items: payload
.items()
.iter()
.map(PayloadItemV0_92_0::from)
.collect(),
nested_comments,
}
}
}
impl From<&PayloadItem> for PayloadItemV0_92_0 {
fn from(item: &PayloadItem) -> Self {
match item {
PayloadItem::Quill { reference } => PayloadItemV0_92_0::Quill {
value: reference.to_string(),
},
PayloadItem::Kind { value } => PayloadItemV0_92_0::Kind {
value: value.clone(),
},
PayloadItem::Id { value } => PayloadItemV0_92_0::Id {
value: value.clone(),
},
PayloadItem::Meta {
key: MetaKey::Ext,
value,
..
} => PayloadItemV0_92_0::Ext {
value: value.clone(),
},
PayloadItem::Meta {
key: MetaKey::Seed,
value,
..
} => PayloadItemV0_92_0::Seed {
value: value.clone(),
},
PayloadItem::Field {
key, value, fill, ..
} => PayloadItemV0_92_0::Field {
key: key.clone(),
value: value.as_json().clone(),
fill: *fill,
nested_fills: value
.nonroot_fill_paths()
.map(|p| p.iter().map(CommentPathSegmentV0_92_0::from).collect())
.collect(),
},
PayloadItem::Comment { text, inline } => PayloadItemV0_92_0::Comment {
text: text.clone(),
inline: *inline,
},
}
}
}
impl From<&NestedComment> for NestedCommentV0_92_0 {
fn from(nc: &NestedComment) -> Self {
NestedCommentV0_92_0 {
container_path: nc
.container_path
.iter()
.map(CommentPathSegmentV0_92_0::from)
.collect(),
position: nc.position,
text: nc.text.clone(),
inline: nc.inline,
}
}
}
impl From<&CommentPathSegment> for CommentPathSegmentV0_92_0 {
fn from(seg: &CommentPathSegment) -> Self {
match seg {
CommentPathSegment::Key(k) => CommentPathSegmentV0_92_0::Key(k.clone()),
CommentPathSegment::Index(i) => CommentPathSegmentV0_92_0::Index(*i),
}
}
}
impl TryFrom<StoredDocument> for Document {
type Error = StorageError;
fn try_from(stored: StoredDocument) -> Result<Self, Self::Error> {
match stored {
StoredDocument::V0_92_0(payload) => Document::try_from(payload),
StoredDocument::V0_82_0(payload) => Document::try_from(DocumentV0_92_0::from(payload)),
StoredDocument::V0_81_0(payload) => {
Document::try_from(DocumentV0_92_0::from(DocumentV0_82_0::from(payload)))
}
}
}
}
impl TryFrom<DocumentV0_92_0> for Document {
type Error = StorageError;
fn try_from(payload: DocumentV0_92_0) -> Result<Self, Self::Error> {
let main = Card::try_from(payload.main)?;
if main.quill().is_none() {
return Err(StorageError::Malformed(
"main card must carry a $quill entry".into(),
));
}
let cards = payload
.cards
.into_iter()
.map(Card::try_from)
.collect::<Result<Vec<_>, _>>()?;
for card in &cards {
if card.quill().is_some() {
return Err(StorageError::Malformed(
"composable cards must not carry a $quill entry".into(),
));
}
if card.seed().is_some() {
return Err(StorageError::Malformed(
"composable cards must not carry a $seed entry".into(),
));
}
if let Some(kind) = card.kind() {
match validate_composable_kind(kind) {
Ok(()) => {}
Err(super::meta::CardKindError::InvalidName) => {
return Err(StorageError::Malformed(format!(
"invalid composable card kind {kind:?}: must match \
[a-z_][a-z0-9_]*"
)));
}
Err(super::meta::CardKindError::Reserved) => {
return Err(StorageError::Malformed(format!(
"composable card kind {kind:?} is reserved (root only)"
)));
}
}
}
}
Ok(Document::from_main_and_cards(main, cards, Vec::new()))
}
}
impl TryFrom<CardV0_92_0> for Card {
type Error = StorageError;
fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
let payload = Payload::try_from(card.payload)?;
validate_dto_payload(&payload)?;
Ok(Card::from_parts(payload, card.body))
}
}
impl TryFrom<PayloadV0_92_0> for Payload {
type Error = StorageError;
fn try_from(p: PayloadV0_92_0) -> Result<Self, Self::Error> {
let mut items = Vec::with_capacity(p.items.len());
for item in p.items {
items.push(PayloadItem::try_from(item)?);
}
let nested = p
.nested_comments
.into_iter()
.map(NestedComment::from)
.collect();
Ok(Payload::from_items_with_flat_nested(items, nested))
}
}
impl TryFrom<PayloadItemV0_92_0> for PayloadItem {
type Error = StorageError;
fn try_from(item: PayloadItemV0_92_0) -> Result<Self, Self::Error> {
Ok(match item {
PayloadItemV0_92_0::Quill { value } => {
let reference = QuillReference::from_str(&value).map_err(|reason| {
StorageError::InvalidQuillReference {
value: value.clone(),
reason,
}
})?;
PayloadItem::Quill { reference }
}
PayloadItemV0_92_0::Kind { value } => PayloadItem::Kind { value },
PayloadItemV0_92_0::Id { value } => PayloadItem::Id { value },
PayloadItemV0_92_0::Ext { value } => PayloadItem::Meta {
key: MetaKey::Ext,
value: depth_check_meta_map(value, "$ext")?,
nested_comments: Vec::new(),
},
PayloadItemV0_92_0::Seed { value } => PayloadItem::Meta {
key: MetaKey::Seed,
value: depth_check_meta_map(value, "$seed")?,
nested_comments: Vec::new(),
},
PayloadItemV0_92_0::Field {
key,
value,
fill,
nested_fills,
} => {
use super::edit::{validate_field, FieldViolation};
validate_field(&key, &value).map_err(|v| {
StorageError::Malformed(match v {
FieldViolation::InvalidName => {
format!("invalid field name {key:?}: must match [A-Za-z_][A-Za-z0-9_]*")
}
FieldViolation::TooDeep => format!(
"field {key:?} nests deeper than the maximum of {} levels",
crate::document::limits::MAX_YAML_DEPTH
),
})
})?;
let mut qv = QuillValue::from_json(value);
for path in nested_fills {
let segs: Vec<CommentPathSegment> =
path.into_iter().map(CommentPathSegment::from).collect();
qv.set_fill_at(&segs);
}
PayloadItem::Field {
key,
value: qv,
fill,
nested_comments: Vec::new(),
}
}
PayloadItemV0_92_0::Comment { text, inline } => PayloadItem::Comment { text, inline },
})
}
}
fn depth_check_meta_map(
value: serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, StorageError> {
let as_value = serde_json::Value::Object(value);
if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH) {
return Err(StorageError::Malformed(format!(
"{key} nests deeper than the maximum of {} levels",
crate::document::limits::MAX_YAML_DEPTH
)));
}
let serde_json::Value::Object(value) = as_value else {
unreachable!("constructed as Object above")
};
Ok(value)
}
impl From<NestedCommentV0_92_0> for NestedComment {
fn from(nc: NestedCommentV0_92_0) -> Self {
NestedComment {
container_path: nc
.container_path
.into_iter()
.map(CommentPathSegment::from)
.collect(),
position: nc.position,
text: nc.text,
inline: nc.inline,
}
}
}
impl From<CommentPathSegmentV0_92_0> for CommentPathSegment {
fn from(seg: CommentPathSegmentV0_92_0) -> Self {
match seg {
CommentPathSegmentV0_92_0::Key(k) => CommentPathSegment::Key(k),
CommentPathSegmentV0_92_0::Index(i) => CommentPathSegment::Index(i),
}
}
}
impl From<DocumentV0_82_0> for DocumentV0_92_0 {
fn from(d: DocumentV0_82_0) -> Self {
DocumentV0_92_0 {
main: CardV0_92_0::from(d.main),
cards: d.cards.into_iter().map(CardV0_92_0::from).collect(),
}
}
}
impl From<CardV0_82_0> for CardV0_92_0 {
fn from(c: CardV0_82_0) -> Self {
CardV0_92_0 {
payload: PayloadV0_92_0::from(c.payload),
body: c.body,
}
}
}
impl From<PayloadV0_82_0> for PayloadV0_92_0 {
fn from(p: PayloadV0_82_0) -> Self {
PayloadV0_92_0 {
items: p.items.into_iter().map(PayloadItemV0_92_0::from).collect(),
nested_comments: p
.nested_comments
.into_iter()
.map(NestedCommentV0_92_0::from)
.collect(),
}
}
}
impl From<PayloadItemV0_82_0> for PayloadItemV0_92_0 {
fn from(item: PayloadItemV0_82_0) -> Self {
match item {
PayloadItemV0_82_0::Quill { value } => PayloadItemV0_92_0::Quill { value },
PayloadItemV0_82_0::Kind { value } => PayloadItemV0_92_0::Kind { value },
PayloadItemV0_82_0::Id { value } => PayloadItemV0_92_0::Id { value },
PayloadItemV0_82_0::Ext { value } => PayloadItemV0_92_0::Ext { value },
PayloadItemV0_82_0::Field { key, value, fill } => PayloadItemV0_92_0::Field {
key,
value,
fill,
nested_fills: Vec::new(),
},
PayloadItemV0_82_0::Comment { text, inline } => {
PayloadItemV0_92_0::Comment { text, inline }
}
}
}
}
impl From<NestedCommentV0_82_0> for NestedCommentV0_92_0 {
fn from(nc: NestedCommentV0_82_0) -> Self {
NestedCommentV0_92_0 {
container_path: nc
.container_path
.into_iter()
.map(CommentPathSegmentV0_92_0::from)
.collect(),
position: nc.position,
text: nc.text,
inline: nc.inline,
}
}
}
impl From<CommentPathSegmentV0_82_0> for CommentPathSegmentV0_92_0 {
fn from(seg: CommentPathSegmentV0_82_0) -> Self {
match seg {
CommentPathSegmentV0_82_0::Key(k) => CommentPathSegmentV0_92_0::Key(k),
CommentPathSegmentV0_82_0::Index(i) => CommentPathSegmentV0_92_0::Index(i),
}
}
}
fn validate_dto_payload(payload: &Payload) -> Result<(), StorageError> {
if payload.len() > crate::error::MAX_FIELD_COUNT {
return Err(StorageError::Malformed(format!(
"card has {} user fields, exceeding the maximum of {}",
payload.len(),
crate::error::MAX_FIELD_COUNT
)));
}
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for key in payload.keys() {
if !seen.insert(key.as_str()) {
return Err(StorageError::Malformed(format!(
"duplicate user-field key {key:?}"
)));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocumentV0_81_0 {
pub main: CardV0_81_0,
#[serde(default)]
pub cards: Vec<CardV0_81_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CardV0_81_0 {
pub sentinel: SentinelV0_81_0,
#[serde(default)]
pub frontmatter: FrontmatterV0_81_0,
#[serde(default)]
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SentinelV0_81_0 {
Main { quill: String },
Card { tag: String },
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct FrontmatterV0_81_0 {
#[serde(default)]
pub items: Vec<FrontmatterItemV0_81_0>,
#[serde(default)]
pub nested_comments: Vec<NestedCommentV0_81_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum FrontmatterItemV0_81_0 {
Field {
key: String,
value: serde_json::Value,
#[serde(default)]
fill: bool,
},
Comment {
text: String,
#[serde(default)]
inline: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NestedCommentV0_81_0 {
pub container_path: Vec<CommentPathSegmentV0_81_0>,
pub position: usize,
pub text: String,
pub inline: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CommentPathSegmentV0_81_0 {
Key(String),
Index(usize),
}
impl From<DocumentV0_81_0> for DocumentV0_82_0 {
fn from(d: DocumentV0_81_0) -> Self {
DocumentV0_82_0 {
main: CardV0_82_0::from(d.main),
cards: d.cards.into_iter().map(CardV0_82_0::from).collect(),
}
}
}
impl From<CardV0_81_0> for CardV0_82_0 {
fn from(c: CardV0_81_0) -> Self {
let mut items: Vec<PayloadItemV0_82_0> = Vec::new();
match c.sentinel {
SentinelV0_81_0::Main { quill } => {
items.push(PayloadItemV0_82_0::Quill { value: quill });
items.push(PayloadItemV0_82_0::Kind {
value: "main".into(),
});
}
SentinelV0_81_0::Card { tag } => {
items.push(PayloadItemV0_82_0::Kind { value: tag });
}
}
for item in c.frontmatter.items {
items.push(match item {
FrontmatterItemV0_81_0::Field { key, value, fill } => {
PayloadItemV0_82_0::Field { key, value, fill }
}
FrontmatterItemV0_81_0::Comment { text, inline } => {
PayloadItemV0_82_0::Comment { text, inline }
}
});
}
let nested_comments = c
.frontmatter
.nested_comments
.into_iter()
.map(NestedCommentV0_82_0::from)
.collect();
CardV0_82_0 {
payload: PayloadV0_82_0 {
items,
nested_comments,
},
body: c.body,
}
}
}
impl From<NestedCommentV0_81_0> for NestedCommentV0_82_0 {
fn from(nc: NestedCommentV0_81_0) -> Self {
NestedCommentV0_82_0 {
container_path: nc
.container_path
.into_iter()
.map(CommentPathSegmentV0_82_0::from)
.collect(),
position: nc.position,
text: nc.text,
inline: nc.inline,
}
}
}
impl From<CommentPathSegmentV0_81_0> for CommentPathSegmentV0_82_0 {
fn from(seg: CommentPathSegmentV0_81_0) -> Self {
match seg {
CommentPathSegmentV0_81_0::Key(k) => CommentPathSegmentV0_82_0::Key(k),
CommentPathSegmentV0_81_0::Index(i) => CommentPathSegmentV0_82_0::Index(i),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Document {
Document::from_markdown(
"\
~~~card-yaml
$quill: usaf_memo@0.1
$kind: main
# a top-level comment
memo_for:
- ORG/SYMBOL # inline comment inside a sequence
date: 2504-10-05
subject: !must_fill Subject of the Memorandum
~~~
The body of the memorandum.
~~~card-yaml
$kind: indorsement
for: ORG/SYMBOL
from: ORG/SYMBOL
~~~
This body and the metadata above are an indorsement card.
",
)
.unwrap()
}
#[test]
fn round_trips_through_serde_json() {
let doc = sample();
let json = serde_json::to_string(&doc).unwrap();
let restored: Document = serde_json::from_str(&json).unwrap();
assert_eq!(doc, restored);
assert_eq!(doc.to_markdown(), restored.to_markdown());
}
#[test]
fn serialization_uses_current_schema() {
let doc = sample();
let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
assert_eq!(value["schema"], SCHEMA_V0_92_0);
}
#[test]
fn nested_fill_survives_storage_round_trip() {
let doc = Document::from_markdown(
"~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n street: !must_fill\n city: Anytown\n~~~\n",
)
.unwrap();
let json = serde_json::to_string(&doc).unwrap();
let restored: Document = serde_json::from_str(&json).unwrap();
assert_eq!(doc, restored, "nested fill must survive storage round-trip");
assert!(
restored.to_markdown().contains("street: !must_fill"),
"Got:\n{}",
restored.to_markdown()
);
}
#[test]
fn v0_82_0_payload_migrates_forward() {
let json = r#"{
"schema": "quillmark/document@0.82.0",
"main": {
"payload": {
"items": [
{"type": "quill", "value": "usaf_memo@0.1"},
{"type": "kind", "value": "main"},
{"type": "field", "key": "title", "value": "Hello", "fill": false}
]
},
"body": "Body."
},
"cards": []
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
assert_eq!(doc.main().kind(), Some("main"));
assert_eq!(
doc.main().payload().get("title").unwrap().as_str(),
Some("Hello")
);
}
#[test]
fn root_kind_is_main_through_round_trip() {
let doc = Document::from_markdown(
"~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
)
.unwrap();
assert_eq!(doc.main().kind(), Some("main"));
let restored: Document =
serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
assert_eq!(doc, restored);
assert_eq!(restored.main().kind(), Some("main"));
}
#[test]
fn serialization_is_byte_deterministic() {
let doc = sample();
let first = serde_json::to_string(&doc).unwrap();
let second = serde_json::to_string(&doc).unwrap();
assert_eq!(first, second, "to_string must be deterministic");
let restored: Document = serde_json::from_str(&first).unwrap();
let third = serde_json::to_string(&restored).unwrap();
assert_eq!(first, third, "byte-equality must survive a round-trip");
}
#[test]
fn rejects_unknown_schema_version() {
let json = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
assert!(serde_json::from_str::<Document>(json).is_err());
}
#[test]
fn peek_schema_version_reads_field_without_full_parse() {
let doc = sample();
let json = serde_json::to_string(&doc).unwrap();
assert_eq!(peek_schema_version(&json).as_deref(), Some(SCHEMA_V0_92_0));
let future = r#"{"schema":"quillmark/document@0.99.0","main":{}}"#;
assert_eq!(
peek_schema_version(future).as_deref(),
Some("quillmark/document@0.99.0")
);
assert_eq!(peek_schema_version("not json"), None);
assert_eq!(peek_schema_version(r#"{"foo":"bar"}"#), None);
}
#[test]
fn comment_on_dollar_line_round_trips() {
let src = "\
~~~card-yaml
$quill: q@1.0
$kind: main # required for root
title: Hi
~~~
";
let doc = Document::from_markdown(src).unwrap();
let json = serde_json::to_string(&doc).unwrap();
let restored: Document = serde_json::from_str(&json).unwrap();
assert_eq!(doc, restored);
assert!(restored
.to_markdown()
.contains("$kind: main # required for root"));
}
#[test]
fn v0_81_0_payload_loads_via_migration() {
let json = r#"{
"schema": "quillmark/document@0.81.0",
"main": {
"sentinel": {"kind": "main", "quill": "usaf_memo@0.1"},
"frontmatter": {
"items": [{"kind": "field", "key": "title", "value": "Hello"}]
},
"body": "Body."
},
"cards": []
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
assert_eq!(doc.main().kind(), Some("main"));
assert_eq!(doc.quill_reference().to_string(), "usaf_memo@0.1");
assert_eq!(
doc.main().payload().get("title").unwrap().as_str(),
Some("Hello")
);
}
#[test]
fn v0_81_0_with_composable_card_migrates() {
let json = r#"{
"schema": "quillmark/document@0.81.0",
"main": {
"sentinel": {"kind": "main", "quill": "q@1.0"},
"frontmatter": {"items": []},
"body": ""
},
"cards": [
{
"sentinel": {"kind": "card", "tag": "indorsement"},
"frontmatter": {"items": [{"kind": "field", "key": "for", "value": "X"}]},
"body": "C body"
}
]
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
assert_eq!(doc.cards().len(), 1);
assert_eq!(doc.cards()[0].kind(), Some("indorsement"));
assert_eq!(
doc.cards()[0].payload().get("for").unwrap().as_str(),
Some("X")
);
}
#[test]
fn rejects_main_card_without_quill() {
let json = r#"{
"schema": "quillmark/document@0.82.0",
"main": {"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""},
"cards": []
}"#;
let err = serde_json::from_str::<Document>(json).unwrap_err();
assert!(err.to_string().contains("$quill"));
}
#[test]
fn rejects_composable_card_tagged_main() {
let json = r#"{
"schema": "quillmark/document@0.82.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@1.0"},
{"type": "kind", "value": "main"}
]},
"body": ""
},
"cards": [
{"payload": {"items": [{"type": "kind", "value": "main"}]}, "body": ""}
]
}"#;
let err = serde_json::from_str::<Document>(json).unwrap_err();
assert!(err.to_string().contains("reserved (root only)"));
}
#[test]
fn rejects_invalid_quill_reference() {
let json = r#"{
"schema": "quillmark/document@0.82.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "not a valid ref!!"},
{"type": "kind", "value": "main"}
]},
"body": ""
},
"cards": []
}"#;
let err = serde_json::from_str::<Document>(json).unwrap_err();
assert!(err.to_string().contains("invalid quill reference"));
}
#[test]
fn v0_82_0_payload_loads_via_migration() {
let json = r#"{
"schema": "quillmark/document@0.82.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "usaf_memo@0.1"},
{"type": "kind", "value": "main"},
{"type": "field", "key": "title", "value": "Hello"}
]},
"body": "Body."
},
"cards": []
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
assert_eq!(doc.main().kind(), Some("main"));
assert_eq!(
doc.main().payload().get("title").unwrap().as_str(),
Some("Hello")
);
let reser = serde_json::to_string(&doc).unwrap();
assert_eq!(peek_schema_version(&reser).as_deref(), Some(SCHEMA_V0_92_0));
}
#[test]
fn v0_82_0_blob_with_seed_item_is_rejected() {
let json = r#"{
"schema": "quillmark/document@0.82.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@1.0"},
{"type": "kind", "value": "main"},
{"type": "seed", "value": {"indorsement": {"from": "X"}}}
]},
"body": ""
},
"cards": []
}"#;
assert!(serde_json::from_str::<Document>(json).is_err());
}
#[test]
fn rejects_composable_card_with_seed() {
let json = r#"{
"schema": "quillmark/document@0.92.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@1.0"},
{"type": "kind", "value": "main"}
]},
"body": ""
},
"cards": [
{"payload": {"items": [
{"type": "kind", "value": "indorsement"},
{"type": "seed", "value": {"note": {"from": "X"}}}
]}, "body": ""}
]
}"#;
let err = serde_json::from_str::<Document>(json).unwrap_err();
assert!(err
.to_string()
.contains("composable cards must not carry a $seed entry"));
}
#[test]
fn v0_92_0_seed_item_round_trips() {
let json = r#"{
"schema": "quillmark/document@0.92.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@1.0"},
{"type": "kind", "value": "main"},
{"type": "seed", "value": {"indorsement": {"from": "49 FW/CC"}}}
]},
"body": ""
},
"cards": []
}"#;
let doc: Document = serde_json::from_str(json).unwrap();
let overlay = doc
.main()
.seed()
.and_then(|m| m.get("indorsement"))
.and_then(crate::SeedOverlay::from_json)
.expect("overlay present");
assert_eq!(
overlay.fields.get("from").and_then(|v| v.as_str()),
Some("49 FW/CC")
);
let reser: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
assert_eq!(doc, reser);
}
}