#![allow(non_camel_case_types)]
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use quillmark_content::Content;
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_93_0: &str = "quillmark/document@0.93.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.93.0")]
V0_93_0(DocumentV0_93_0),
#[serde(rename = "quillmark/document@0.92.0")]
V0_92_0(DocumentV0_92_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_93_0 {
pub main: CardV0_93_0,
#[serde(default)]
pub cards: Vec<CardV0_93_0>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CardV0_93_0 {
pub payload: PayloadV0_93_0,
pub body: CanonicalContent,
}
pub type PayloadV0_93_0 = PayloadV0_92_0;
#[derive(Debug, Clone, PartialEq)]
pub struct CanonicalContent(pub Content);
impl Serialize for CanonicalContent {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
quillmark_content::serial::to_canonical_value(&self.0).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for CanonicalContent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let rt = quillmark_content::serial::from_canonical_value(&value)
.map_err(serde::de::Error::custom)?;
Ok(CanonicalContent(rt))
}
}
#[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_93_0(DocumentV0_93_0::from(&doc))
}
}
impl From<&Document> for DocumentV0_93_0 {
fn from(doc: &Document) -> Self {
DocumentV0_93_0 {
main: CardV0_93_0::from(doc.main()),
cards: doc.cards().iter().map(CardV0_93_0::from).collect(),
}
}
}
impl From<&Card> for CardV0_93_0 {
fn from(card: &Card) -> Self {
CardV0_93_0 {
payload: PayloadV0_92_0::from(card.payload()),
body: CanonicalContent(card.body().clone()),
}
}
}
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_93_0(payload) => Document::try_from(payload),
StoredDocument::V0_92_0(payload) => {
Document::try_from(DocumentV0_93_0::try_from(payload)?)
}
}
}
}
impl TryFrom<DocumentV0_93_0> for Document {
type Error = StorageError;
fn try_from(payload: DocumentV0_93_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))
}
}
impl TryFrom<CardV0_93_0> for Card {
type Error = StorageError;
fn try_from(card: CardV0_93_0) -> Result<Self, Self::Error> {
let payload = Payload::try_from(card.payload)?;
validate_dto_payload(&payload)?;
Ok(Card::from_parts(payload, card.body.0))
}
}
impl TryFrom<DocumentV0_92_0> for DocumentV0_93_0 {
type Error = StorageError;
fn try_from(d: DocumentV0_92_0) -> Result<Self, Self::Error> {
Ok(DocumentV0_93_0 {
main: CardV0_93_0::try_from(d.main)?,
cards: d
.cards
.into_iter()
.map(CardV0_93_0::try_from)
.collect::<Result<_, _>>()?,
})
}
}
impl TryFrom<CardV0_92_0> for CardV0_93_0 {
type Error = StorageError;
fn try_from(card: CardV0_92_0) -> Result<Self, Self::Error> {
let body = super::import_body(&card.body)
.map_err(|e| StorageError::Malformed(format!("card body: {e}")))?;
Ok(CardV0_93_0 {
payload: card.payload,
body: CanonicalContent(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> {
crate::value::depth_check_meta_map(value, |max| {
StorageError::Malformed(format!("{key} nests deeper than the maximum of {} levels", max))
})
}
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),
}
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Document {
Document::parse(
"\
~~~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()
.document
}
#[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 content_field_survives_storage_round_trip_losslessly() {
use quillmark_content::model::{Mark, MarkKind};
let mut doc = sample();
let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
content.marks.push(Mark {
start: 0,
end: 10,
kind: MarkKind::Underline,
});
content.normalize();
let json = quillmark_content::serial::to_canonical_value(&content);
let schema = crate::quill::FieldSchema::new(
"intro".to_string(),
crate::quill::FieldType::RichText { inline: false },
None,
);
doc.main_mut()
.commit_field("intro", crate::QuillValue::from_json(json), &schema)
.unwrap();
let stored = serde_json::to_string(&doc).unwrap();
let restored: Document = serde_json::from_str(&stored).unwrap();
assert_eq!(doc, restored, "content field must survive storage round-trip");
let read = restored.main().field_richtext("intro").unwrap().unwrap();
assert!(
read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)),
"underline (content-only) must survive the DTO carrier"
);
}
#[test]
fn nested_fill_survives_storage_round_trip() {
let doc = Document::parse(
"~~~card-yaml\n$quill: q@0.1\n$kind: main\naddr:\n street: !must_fill\n city: Anytown\n~~~\n",
)
.unwrap()
.document;
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 root_kind_is_main_through_round_trip() {
let doc = Document::parse(
"~~~card-yaml\n$quill: usaf_memo@0.1\n$kind: main\ntitle: \"Hi\"\n~~~\n",
)
.unwrap()
.document;
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 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_93_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::parse(src).unwrap().document;
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 retired_legacy_schema_tags_are_rejected() {
for tag in ["quillmark/document@0.81.0", "quillmark/document@0.82.0"] {
let json = format!(
r#"{{"schema":"{tag}","main":{{"payload":{{"items":[]}},"body":""}},"cards":[]}}"#
);
assert!(
serde_json::from_str::<Document>(&json).is_err(),
"expected {tag} to be rejected as an unknown schema"
);
}
}
#[test]
fn rejects_main_card_without_quill() {
let json = r#"{
"schema": "quillmark/document@0.92.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.92.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.92.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 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);
}
fn locate_body_subtree(envelope: &str) -> &str {
const KEY: &str = "\"body\":";
let start = envelope.find(KEY).expect("body key present") + KEY.len();
let bytes = envelope.as_bytes();
assert_eq!(
bytes[start], b'{',
"body must embed as a nested object, not an escaped string"
);
let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
for (i, &b) in bytes[start..].iter().enumerate() {
if in_str {
match (escaped, b) {
(true, _) => escaped = false,
(false, b'\\') => escaped = true,
(false, b'"') => in_str = false,
_ => {}
}
continue;
}
match b {
b'"' => in_str = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return &envelope[start..start + i + 1];
}
}
_ => {}
}
}
panic!("unbalanced body object");
}
#[test]
fn body_subtree_is_byte_identical_to_canonical_json() {
let doc = Document::parse(
"~~~card-yaml\n$quill: q@0.1\n$kind: main\ntitle: Hi\n~~~\n\n\
A paragraph with **bold**, _emph_, and a [link](https://example.com).\n\n\
Second paragraph continues the content.\n",
)
.unwrap()
.document;
let rt = doc.main().body().clone();
assert!(
!rt.marks.is_empty(),
"test needs a non-trivial content (marks present)"
);
let expected = rt.to_canonical_json();
let envelope = serde_json::to_string(&doc).unwrap();
let body = locate_body_subtree(&envelope);
assert_eq!(
body, expected,
"the envelope body subtree must equal to_canonical_json byte-for-byte"
);
assert!(body.starts_with("{\"islands\":"));
}
#[test]
fn v0_93_0_round_trips_as_fixed_point() {
let doc = sample();
let first = serde_json::to_string(&doc).unwrap();
let restored: Document = serde_json::from_str(&first).unwrap();
assert_eq!(doc, restored);
let second = serde_json::to_string(&restored).unwrap();
assert_eq!(
first, second,
"V0_93_0 serialize→deserialize is a byte-fixed point"
);
assert_eq!(peek_schema_version(&first).as_deref(), Some(SCHEMA_V0_93_0));
}
#[test]
fn legacy_table_body_migrates_deterministically_with_islands() {
let blob = r#"{
"schema": "quillmark/document@0.92.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@0.1"},
{"type": "kind", "value": "main"}
]},
"body": "| A | B |\n| - | - |\n| 1 | 2 |\n"
},
"cards": []
}"#;
let doc: Document = serde_json::from_str(blob).unwrap();
let body = doc.main().body();
assert_eq!(body.islands.len(), 1, "table imports as one island");
assert_eq!(body.islands[0].id, "isl-0", "sequential island id");
assert_eq!(body.islands[0].island_type, "table");
let key = body.to_canonical_json();
assert_eq!(
key,
"{\"islands\":[{\"id\":\"isl-0\",\"loss\":\"lossless\",\"props\":{\
\"aligns\":[\"none\",\"none\"],\
\"header\":[{\"marks\":[],\"text\":\"A\"},{\"marks\":[],\"text\":\"B\"}],\
\"rows\":[[{\"marks\":[],\"text\":\"1\"},{\"marks\":[],\"text\":\"2\"}]]},\
\"type\":\"table\"}],\
\"lines\":[{\"containers\":[],\"kind\":\"island\"}],\
\"marks\":[],\"text\":\"\u{FFFC}\"}",
"regenerated @0.93.0 golden: cells are structured text+marks"
);
let again: Document = serde_json::from_str(blob).unwrap();
assert_eq!(
serde_json::to_string(&doc).unwrap(),
serde_json::to_string(&again).unwrap(),
"same legacy input → same migrated bytes"
);
let reser = serde_json::to_string(&doc).unwrap();
assert_eq!(peek_schema_version(&reser).as_deref(), Some(SCHEMA_V0_93_0));
}
#[test]
fn over_nested_legacy_body_is_malformed() {
let deep = ">".repeat(crate::error::MAX_NESTING_DEPTH + 5);
let card = CardV0_92_0 {
payload: PayloadV0_92_0::default(),
body: format!("{deep} too deep"),
};
let err = CardV0_93_0::try_from(card).unwrap_err();
assert!(matches!(err, StorageError::Malformed(_)), "got: {err:?}");
assert!(err.to_string().contains("card body"));
}
#[test]
fn deserialize_rejects_invalid_content_body() {
let blob = r#"{
"schema": "quillmark/document@0.93.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@0.1"},
{"type": "kind", "value": "main"}
]},
"body": {"text": "a\nb", "lines": [{"kind": "para", "containers": []}], "marks": [], "islands": []}
},
"cards": []
}"#;
assert!(serde_json::from_str::<Document>(blob).is_err());
}
}