Skip to main content

quillmark_content/
serial.rs

1//! Canonical JSON serialization — the freeze.
2//!
3//! Byte-deterministic within this schema: equal [`Content`] values (by
4//! `PartialEq` after [`Content::normalize`]) serialize to byte-equal JSON,
5//! insensitive to the order marks/islands were discovered in. Three order
6//! sources are closed here and in `normalize`: mark order (canonical sort),
7//! island order (slot position), and object-key order inside island `props` /
8//! unknown-mark `attrs` (recursively sorted). `deserialize ∘ serialize` is a
9//! fixed point on canonical bytes.
10//!
11//! The seam encoding (Option A) and the storage encoding are the *same*
12//! canonical form — one serializer, not two to keep aligned.
13
14use crate::model::{
15    sort_keys_owned, sorted_value, Container, Invariant, Island, Line, LineKind, Loss, Mark,
16    MarkKind, Content,
17};
18use serde_json::{Map, Value};
19
20/// Why canonical-JSON parsing failed. Structural only — a well-formed producer
21/// (this crate's serializer, the seam, storage) never trips these.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ParseError {
24    /// Top-level JSON was not an object, or a required key was missing/mistyped.
25    Shape(&'static str),
26    /// The JSON itself did not parse.
27    Json(String),
28    /// The value parsed but violates a content invariant.
29    Invalid(crate::model::Invariant),
30}
31
32impl std::fmt::Display for ParseError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            ParseError::Shape(s) => write!(f, "content json shape: {s}"),
36            ParseError::Json(s) => write!(f, "content json parse: {s}"),
37            ParseError::Invalid(inv) => write!(f, "content invariant: {inv:?}"),
38        }
39    }
40}
41impl std::error::Error for ParseError {}
42
43impl Content {
44    /// Serialize to canonical JSON bytes. Normalizes a copy first, so the output
45    /// is canonical regardless of the caller's mark/island order. Every object
46    /// key is sorted recursively so the bytes do **not** depend on
47    /// `serde_json`'s `preserve_order` feature being enabled in the consumer's
48    /// crate graph — the canonical form is feature-independent.
49    pub fn to_canonical_json(&self) -> String {
50        to_canonical_value(self).to_string()
51    }
52
53    /// Parse canonical JSON, normalize (idempotent), and validate. Returns
54    /// [`ParseError::Invalid`] for a content that violates its invariants, so
55    /// storage cannot silently round-trip a malformed value.
56    /// `from_canonical_json(to_canonical_json(x))` round-trips to a canonical
57    /// value and re-serializes to identical bytes.
58    pub fn from_canonical_json(s: &str) -> Result<Content, ParseError> {
59        let v: Value = serde_json::from_str(s).map_err(|e| ParseError::Json(e.to_string()))?;
60        from_canonical_value(&v)
61    }
62
63    fn to_value(&self) -> Value {
64        let mut root = Map::new();
65        root.insert("text".into(), Value::String(self.text.clone()));
66        root.insert(
67            "lines".into(),
68            Value::Array(self.lines.iter().map(line_to_value).collect()),
69        );
70        root.insert(
71            "marks".into(),
72            Value::Array(self.marks.iter().map(mark_to_value).collect()),
73        );
74        root.insert(
75            "islands".into(),
76            Value::Array(self.islands.iter().map(island_to_value).collect()),
77        );
78        Value::Object(root)
79    }
80
81    fn from_value(v: &Value) -> Result<Content, ParseError> {
82        let obj = v.as_object().ok_or(ParseError::Shape("root not object"))?;
83        let text = obj
84            .get("text")
85            .and_then(Value::as_str)
86            .ok_or(ParseError::Shape("text"))?
87            .to_string();
88        let lines = arr(obj, "lines")?
89            .iter()
90            .map(line_from_value)
91            .collect::<Result<_, _>>()?;
92        let marks = arr(obj, "marks")?
93            .iter()
94            .map(mark_from_value)
95            .collect::<Result<_, _>>()?;
96        let islands = arr(obj, "islands")?
97            .iter()
98            .map(island_from_value)
99            .collect::<Result<_, _>>()?;
100        Ok(Content {
101            text,
102            lines,
103            marks,
104            islands,
105        })
106    }
107}
108
109/// The canonical content form as a structural [`Value`] — the recursively
110/// key-sorted tree [`Content::to_canonical_json`] renders to bytes. A storage
111/// layer embeds this as a nested object (never an escaped string): serializing
112/// the returned value with `serde_json` is byte-identical to that JSON
113/// (`to_canonical_value(rt).to_string() == rt.to_canonical_json()`), independent
114/// of the consumer's `preserve_order` feature. Normalizes a copy first, so the
115/// value is canonical whatever the caller's mark/island order.
116pub fn to_canonical_value(rt: &Content) -> Value {
117    let mut rt = rt.clone();
118    rt.normalize();
119    sort_keys_owned(rt.to_value())
120}
121
122/// Parse the canonical content form from a structural [`Value`], normalize
123/// (idempotent), and validate — the [`Value`]-input counterpart to
124/// [`Content::from_canonical_json`]. Returns [`ParseError::Invalid`] for a
125/// content that violates its invariants, so a storage layer parsing the embedded
126/// object rejects a malformed value at load rather than round-tripping it.
127pub fn from_canonical_value(v: &Value) -> Result<Content, ParseError> {
128    let mut rt = Content::from_value(v)?;
129    rt.normalize();
130    rt.validate().map_err(ParseError::Invalid)?;
131    Ok(rt)
132}
133
134fn arr<'a>(obj: &'a Map<String, Value>, key: &'static str) -> Result<&'a Vec<Value>, ParseError> {
135    obj.get(key)
136        .and_then(Value::as_array)
137        .ok_or(ParseError::Shape(key))
138}
139
140// ---- Line ----
141
142/// Encode a [`LineKind`] into its canonical `kind` fields (`"para"`,
143/// `{"kind":"heading","level":n}`, …). Public so the mark/line **op** wire
144/// ([`crate::ops`]) reuses the exact discriminant a `ContentLine` carries,
145/// rather than forking the encoding.
146pub fn line_kind_to_value(kind: &LineKind) -> Value {
147    let mut m = Map::new();
148    match kind {
149        LineKind::Para => {
150            m.insert("kind".into(), "para".into());
151        }
152        LineKind::Heading { level } => {
153            m.insert("kind".into(), "heading".into());
154            m.insert("level".into(), Value::from(*level));
155        }
156        LineKind::Code { lang } => {
157            m.insert("kind".into(), "code".into());
158            if let Some(l) = lang {
159                m.insert("lang".into(), Value::String(l.clone()));
160            }
161        }
162        LineKind::Island => {
163            m.insert("kind".into(), "island".into());
164        }
165        LineKind::Rule => {
166            m.insert("kind".into(), "rule".into());
167        }
168    }
169    Value::Object(m)
170}
171
172/// Decode a [`LineKind`] from an object carrying the canonical `kind` fields.
173/// The inverse of [`line_kind_to_value`]; the shared line-kind reader for
174/// [`line_from_value`] and the line-op wire.
175pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
176    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
177    match o.get("kind").and_then(Value::as_str) {
178        Some("para") => Ok(LineKind::Para),
179        Some("heading") => {
180            let level = o
181                .get("level")
182                .and_then(Value::as_u64)
183                .ok_or(ParseError::Shape("heading level"))?;
184            if !(1..=6).contains(&level) {
185                return Err(ParseError::Shape("heading level"));
186            }
187            Ok(LineKind::Heading { level: level as u8 })
188        }
189        Some("code") => Ok(LineKind::Code {
190            lang: o.get("lang").and_then(Value::as_str).map(str::to_string),
191        }),
192        Some("island") => Ok(LineKind::Island),
193        Some("rule") => Ok(LineKind::Rule),
194        _ => Err(ParseError::Shape("line kind")),
195    }
196}
197
198fn line_to_value(line: &Line) -> Value {
199    let Value::Object(mut m) = line_kind_to_value(&line.kind) else {
200        unreachable!("line_kind_to_value always returns an object")
201    };
202    m.insert(
203        "containers".into(),
204        Value::Array(line.containers.iter().map(container_to_value).collect()),
205    );
206    // Omitted when false (the common case) — deterministic since presence is a
207    // pure function of the value.
208    if line.continues {
209        m.insert("continues".into(), Value::Bool(true));
210    }
211    Value::Object(m)
212}
213
214fn line_from_value(v: &Value) -> Result<Line, ParseError> {
215    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
216    let kind = line_kind_from_value(v)?;
217    let containers = o
218        .get("containers")
219        .and_then(Value::as_array)
220        .ok_or(ParseError::Shape("containers"))?
221        .iter()
222        .map(container_from_value)
223        .collect::<Result<_, _>>()?;
224    let continues = o.get("continues").and_then(Value::as_bool).unwrap_or(false);
225    Ok(Line {
226        kind,
227        containers,
228        continues,
229    })
230}
231
232/// Encode a [`Container`] into its canonical wire object. Public so the line-op
233/// wire ([`crate::ops`]) reuses the same container shape a `ContentLine`
234/// carries.
235pub fn container_to_value(c: &Container) -> Value {
236    let mut m = Map::new();
237    match c {
238        Container::ListItem {
239            ordered,
240            start,
241            ordinal,
242        } => {
243            m.insert("container".into(), "list_item".into());
244            m.insert("ordered".into(), Value::Bool(*ordered));
245            m.insert("start".into(), Value::from(*start));
246            m.insert("ordinal".into(), Value::from(*ordinal));
247        }
248        Container::Quote => {
249            m.insert("container".into(), "quote".into());
250        }
251    }
252    Value::Object(m)
253}
254
255/// Decode a [`Container`] from its canonical wire object. The inverse of
256/// [`container_to_value`].
257pub fn container_from_value(v: &Value) -> Result<Container, ParseError> {
258    let o = v.as_object().ok_or(ParseError::Shape("container"))?;
259    match o.get("container").and_then(Value::as_str) {
260        Some("list_item") => Ok(Container::ListItem {
261            ordered: o.get("ordered").and_then(Value::as_bool).unwrap_or(false),
262            start: o.get("start").and_then(Value::as_u64).unwrap_or(1),
263            ordinal: o.get("ordinal").and_then(Value::as_u64).unwrap_or(0),
264        }),
265        Some("quote") => Ok(Container::Quote),
266        _ => Err(ParseError::Shape("container kind")),
267    }
268}
269
270// ---- Mark ----
271
272/// Encode a [`Mark`] (`{start, end, type, …}`) into its canonical wire object.
273/// Public so the mark-op wire ([`crate::ops`]) reuses the exact `type`
274/// discriminant a `ContentMark` carries.
275pub fn mark_to_value(mark: &Mark) -> Value {
276    let mut m = Map::new();
277    m.insert("start".into(), Value::from(mark.start));
278    m.insert("end".into(), Value::from(mark.end));
279    match &mark.kind {
280        MarkKind::Strong => {
281            m.insert("type".into(), "strong".into());
282        }
283        MarkKind::Emph => {
284            m.insert("type".into(), "emph".into());
285        }
286        MarkKind::Underline => {
287            m.insert("type".into(), "underline".into());
288        }
289        MarkKind::Strike => {
290            m.insert("type".into(), "strike".into());
291        }
292        MarkKind::Code => {
293            m.insert("type".into(), "code".into());
294        }
295        MarkKind::Link { url } => {
296            m.insert("type".into(), "link".into());
297            m.insert("url".into(), Value::String(url.clone()));
298        }
299        MarkKind::Anchor { id } => {
300            m.insert("type".into(), "anchor".into());
301            m.insert("id".into(), Value::String(id.clone()));
302        }
303        MarkKind::Unknown { tag, attrs } => {
304            m.insert("type".into(), Value::String(tag.clone()));
305            m.insert("attrs".into(), sorted_value(attrs));
306        }
307    }
308    Value::Object(m)
309}
310
311/// Decode a [`Mark`] from its canonical wire object. The inverse of
312/// [`mark_to_value`]; the shared mark reader for the content decoder and the
313/// mark-op wire.
314pub fn mark_from_value(v: &Value) -> Result<Mark, ParseError> {
315    let o = v.as_object().ok_or(ParseError::Shape("mark"))?;
316    let start = o
317        .get("start")
318        .and_then(Value::as_u64)
319        .ok_or(ParseError::Shape("mark start"))? as usize;
320    let end = o
321        .get("end")
322        .and_then(Value::as_u64)
323        .ok_or(ParseError::Shape("mark end"))? as usize;
324    let ty = o
325        .get("type")
326        .and_then(Value::as_str)
327        .ok_or(ParseError::Shape("mark type"))?;
328    let kind = match ty {
329        "strong" => MarkKind::Strong,
330        "emph" => MarkKind::Emph,
331        "underline" => MarkKind::Underline,
332        "strike" => MarkKind::Strike,
333        "code" => MarkKind::Code,
334        "link" => MarkKind::Link {
335            url: o
336                .get("url")
337                .and_then(Value::as_str)
338                .unwrap_or_default()
339                .to_string(),
340        },
341        "anchor" => MarkKind::Anchor {
342            id: o
343                .get("id")
344                .and_then(Value::as_str)
345                .unwrap_or_default()
346                .to_string(),
347        },
348        // Open set: any other type name is an unknown mark, round-tripped opaque
349        // with whatever `attrs` it carried.
350        other => MarkKind::Unknown {
351            tag: other.to_string(),
352            attrs: o.get("attrs").cloned().unwrap_or(Value::Null),
353        },
354    };
355    Ok(Mark { start, end, kind })
356}
357
358// ---- Table cell {text, marks} ----
359//
360// A pipe-table cell is inline-only: its own plain `text` plus `marks` whose
361// ranges are USV offsets into that text (0..cell_len). The marks ride the SAME
362// wire shape prose marks use (`mark_to_value`/`mark_from_value`), so nothing
363// forks the encoding. Import builds cells, export/emit render them, and
364// `Content::normalize`/`validate` canonicalize/check the marks — all through
365// these helpers.
366
367/// Parse a table-cell object `{text, marks}` leniently: its plain text plus the
368/// marks over it. A malformed mark is skipped rather than failing — cells are
369/// flat inline, so this never recurses. Public so the typst emitter renders a
370/// cell through the same parse the codecs use.
371pub fn parse_cell(v: &Value) -> (String, Vec<Mark>) {
372    let text = v
373        .get("text")
374        .and_then(Value::as_str)
375        .unwrap_or_default()
376        .to_string();
377    let marks = v
378        .get("marks")
379        .and_then(Value::as_array)
380        .map(|arr| arr.iter().filter_map(|m| mark_from_value(m).ok()).collect())
381        .unwrap_or_default();
382    (text, marks)
383}
384
385/// Build a table-cell object `{text, marks}` — the inverse of [`parse_cell`],
386/// reusing [`mark_to_value`]. Key order is fixed by the recursive
387/// [`sorted_value`] pass in [`Content::normalize`], not here.
388pub(crate) fn cell_to_value(text: &str, marks: &[Mark]) -> Value {
389    let mut m = Map::new();
390    m.insert("text".into(), Value::String(text.to_string()));
391    m.insert(
392        "marks".into(),
393        Value::Array(marks.iter().map(mark_to_value).collect()),
394    );
395    Value::Object(m)
396}
397
398/// Every cell's `(text, marks)` in a table island's props — header then each
399/// body row, in order. For [`Content::validate`]'s cell-mark invariant checks.
400pub(crate) fn table_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
401    let mut out = Vec::new();
402    if let Some(h) = props.get("header").and_then(Value::as_array) {
403        out.extend(h.iter().map(parse_cell));
404    }
405    if let Some(rows) = props.get("rows").and_then(Value::as_array) {
406        for row in rows {
407            if let Some(r) = row.as_array() {
408                out.extend(r.iter().map(parse_cell));
409            }
410        }
411    }
412    out
413}
414
415/// Islands whose props carry inline `{text, marks}` cells — the set that
416/// participates in mark normalization and cell-mark validation. Adding a
417/// mark-carrying island type is a single edit here;
418/// [`normalize_island_cell_marks`] and [`island_cell_marks`] both route through
419/// it, so neither `normalize` nor `validate` can silently skip a new type
420/// (which would void the canonical-bytes guarantee for its cells).
421fn island_is_mark_carrying(island_type: &str) -> bool {
422    matches!(island_type, "table")
423}
424
425/// Repair a mark-carrying island's structure in place (a no-op for a
426/// non-mark-carrying type) — the normalize-side island-type dispatch, kept
427/// beside the table codec rather than as a bare `"table"` match in `model`.
428pub(crate) fn normalize_island_structure(island: &mut Island) {
429    if island_is_mark_carrying(&island.island_type) {
430        normalize_table_props(&mut island.props);
431    }
432}
433
434/// A mark-carrying island's `(text, marks)` cells for validation (empty for a
435/// non-mark-carrying type) — the validate-side twin of
436/// [`normalize_island_structure`].
437pub(crate) fn island_cell_marks(island: &Island) -> Vec<(String, Vec<Mark>)> {
438    if island_is_mark_carrying(&island.island_type) {
439        table_cells(&island.props)
440    } else {
441        Vec::new()
442    }
443}
444
445/// A mark-carrying island's shape violation, if any (`None` for a well-formed
446/// or non-mark-carrying island) — the validate-side twin of the shape repair in
447/// [`normalize_island_structure`]. `normalize` guarantees this returns `None`.
448pub(crate) fn island_shape_error(island: &Island) -> Option<Invariant> {
449    if island_is_mark_carrying(&island.island_type) {
450        table_shape_error(&island.props)
451    } else {
452        None
453    }
454}
455
456/// Repair a table island's props in place to the canonical shape:
457///
458/// - **One column count.** `cols` is the widest of the header, any body row, and
459///   `aligns`; the header, each row, and `aligns` are padded up to it (padding
460///   only grows — no cell is ever truncated). Materializing the count into the
461///   header means the markdown projection (header-derived) and the Typst
462///   projection (widest-row) agree on one number.
463/// - **Single-line cells.** Any `\n`/`\r` in a cell's text becomes a space (the
464///   same rule import applies to soft/hard breaks). A 1:1 replacement keeps char
465///   offsets stable, so the cell's marks stay in range.
466/// - **Canonical cell marks.** Each cell's marks are re-normalized (sort,
467///   same-kind union, drop zero-width) so equal cells serialize to equal bytes.
468fn normalize_table_props(props: &mut Value) {
469    let cols = table_cols(props);
470    let Some(obj) = props.as_object_mut() else {
471        return;
472    };
473    let header = obj.entry("header").or_insert_with(|| Value::Array(vec![]));
474    // A non-array header (a bare string, say) carries no cells; rewrite it to an
475    // empty array so it canonicalizes to a zero-column, content-free table
476    // rather than retaining opaque garbage that `validate` would then reject.
477    if !header.is_array() {
478        *header = Value::Array(vec![]);
479    }
480    pad_row(header, cols);
481    if let Some(h) = header.as_array_mut() {
482        h.iter_mut().for_each(canon_cell);
483    }
484    let aligns = obj.entry("aligns").or_insert_with(|| Value::Array(vec![]));
485    if let Some(a) = aligns.as_array_mut() {
486        while a.len() < cols {
487            a.push(Value::String("none".into()));
488        }
489    }
490    if let Some(rows) = obj.get_mut("rows").and_then(Value::as_array_mut) {
491        for row in rows.iter_mut() {
492            pad_row(row, cols);
493            if let Some(r) = row.as_array_mut() {
494                r.iter_mut().for_each(canon_cell);
495            }
496        }
497    }
498}
499
500/// A table's canonical column count: the widest of its header, any body row, and
501/// its `aligns` array. Padding (never truncation) brings every part up to it.
502fn table_cols(props: &Value) -> usize {
503    let arr_len = |k: &str| props.get(k).and_then(Value::as_array).map(|a| a.len());
504    let header = arr_len("header").unwrap_or(0);
505    let aligns = arr_len("aligns").unwrap_or(0);
506    let widest_row = props
507        .get("rows")
508        .and_then(Value::as_array)
509        .map(|rows| {
510            rows.iter()
511                .map(|r| r.as_array().map(|a| a.len()).unwrap_or(0))
512                .max()
513                .unwrap_or(0)
514        })
515        .unwrap_or(0);
516    header.max(aligns).max(widest_row)
517}
518
519/// Pad a cell array (header or body row) up to `cols` with empty cells. Never
520/// shrinks — `cols` is the widest, so a shorter array only grows.
521fn pad_row(v: &mut Value, cols: usize) {
522    if let Some(arr) = v.as_array_mut() {
523        while arr.len() < cols {
524            arr.push(cell_to_value("", &[]));
525        }
526    }
527}
528
529/// De-newline a cell's text (each `\n`/`\r` → a space, 1:1 so mark offsets hold)
530/// and re-normalize its marks. Reached per-cell from [`normalize_table_props`].
531fn canon_cell(cell: &mut Value) {
532    let (text, marks) = parse_cell(cell);
533    let text = if text.contains(['\n', '\r']) {
534        text.replace(['\n', '\r'], " ")
535    } else {
536        text
537    };
538    *cell = cell_to_value(&text, &crate::model::normalize_marks(marks));
539}
540
541/// A table island's shape violation, if any — the widths the header, `aligns`,
542/// and each body row must share (the header width), plus the `\n`-free-cell rule.
543/// The validate-side twin of [`normalize_table_props`].
544fn table_shape_error(props: &Value) -> Option<Invariant> {
545    // A present-but-non-array header can't carry column cells — `normalize`
546    // rewrites it to an empty array, so an un-normalized one is a hand-built
547    // degenerate island. (An absent header is a zero-column table, which is
548    // well-formed: `empty_table_is_valid`.)
549    if props.get("header").is_some_and(|h| !h.is_array()) {
550        return Some(Invariant::TableHeaderNotArray);
551    }
552    let cols = props
553        .get("header")
554        .and_then(Value::as_array)
555        .map(|a| a.len())
556        .unwrap_or(0);
557    let aligns = props
558        .get("aligns")
559        .and_then(Value::as_array)
560        .map(|a| a.len())
561        .unwrap_or(0);
562    if aligns != cols {
563        return Some(Invariant::TableAlignsMismatch { aligns, cols });
564    }
565    if let Some(rows) = props.get("rows").and_then(Value::as_array) {
566        for (i, row) in rows.iter().enumerate() {
567            let width = row.as_array().map(|a| a.len()).unwrap_or(0);
568            if width != cols {
569                return Some(Invariant::TableRaggedRow {
570                    row: i,
571                    width,
572                    cols,
573                });
574            }
575        }
576    }
577    for (i, (text, _)) in table_cells(props).iter().enumerate() {
578        if text.contains('\n') || text.contains('\r') {
579            return Some(Invariant::TableCellNewline { cell: i });
580        }
581    }
582    None
583}
584
585// ---- Island ----
586
587fn island_to_value(island: &Island) -> Value {
588    let mut m = Map::new();
589    m.insert("id".into(), Value::String(island.id.clone()));
590    m.insert("type".into(), Value::String(island.island_type.clone()));
591    m.insert("props".into(), sorted_value(&island.props));
592    m.insert("loss".into(), loss_to_str(island.loss).into());
593    Value::Object(m)
594}
595
596fn island_from_value(v: &Value) -> Result<Island, ParseError> {
597    let o = v.as_object().ok_or(ParseError::Shape("island"))?;
598    Ok(Island {
599        id: o
600            .get("id")
601            .and_then(Value::as_str)
602            .ok_or(ParseError::Shape("island id"))?
603            .to_string(),
604        island_type: o
605            .get("type")
606            .and_then(Value::as_str)
607            .ok_or(ParseError::Shape("island type"))?
608            .to_string(),
609        props: o.get("props").cloned().unwrap_or(Value::Null),
610        loss: loss_from_str(o.get("loss").and_then(Value::as_str).unwrap_or("lossless")),
611    })
612}
613
614fn loss_to_str(loss: Loss) -> &'static str {
615    match loss {
616        Loss::Lossless => "lossless",
617        Loss::Degraded => "degraded",
618        Loss::Unrepresentable => "unrepresentable",
619    }
620}
621
622fn loss_from_str(s: &str) -> Loss {
623    match s {
624        "lossless" => Loss::Lossless,
625        "degraded" => Loss::Degraded,
626        // Unknown/future loss class defaults to the *safe* end: never claim a
627        // value the reader can't interpret "carries faithfully".
628        _ => Loss::Unrepresentable,
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::model::{Line, LineKind};
636
637    fn sample() -> Content {
638        Content {
639            text: "hello world".into(),
640            lines: vec![Line {
641                kind: LineKind::Para,
642                containers: vec![],
643                continues: false,
644            }],
645            marks: vec![
646                Mark {
647                    start: 6,
648                    end: 11,
649                    kind: MarkKind::Strong,
650                },
651                Mark {
652                    start: 0,
653                    end: 5,
654                    kind: MarkKind::Emph,
655                },
656            ],
657            islands: vec![],
658        }
659    }
660
661    #[test]
662    fn island_props_key_order_does_not_leak() {
663        let mut one = Content::empty();
664        one.text = "\u{FFFC}".into();
665        one.lines = vec![Line {
666            kind: LineKind::Island,
667            containers: vec![],
668            continues: false,
669        }];
670        one.islands = vec![Island {
671            id: "i1".into(),
672            island_type: "table".into(),
673            props: serde_json::json!({"b": 1, "a": 2}),
674            loss: Loss::Lossless,
675        }];
676        let mut two = one.clone();
677        two.islands[0].props = serde_json::json!({"a": 2, "b": 1}); // keys reversed
678        assert_eq!(one.to_canonical_json(), two.to_canonical_json());
679    }
680
681    #[test]
682    fn golden_bytes_are_feature_independent() {
683        // Pins the exact canonical form. Every object key is sorted, so the
684        // bytes do not depend on serde_json's preserve_order feature. If this
685        // string changes, the freeze changed — bump the schema version.
686        let rt = sample();
687        assert_eq!(
688            rt.to_canonical_json(),
689            r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":5,"start":0,"type":"emph"},{"end":11,"start":6,"type":"strong"}],"text":"hello world"}"#
690        );
691    }
692
693    #[test]
694    fn from_canonical_json_rejects_invalid() {
695        // lines.len() != segment count — must not silently round-trip.
696        let bad =
697            r#"{"text":"a\nb","lines":[{"kind":"para","containers":[]}],"marks":[],"islands":[]}"#;
698        assert!(matches!(
699            Content::from_canonical_json(bad),
700            Err(ParseError::Invalid(_))
701        ));
702    }
703
704    #[test]
705    fn reserved_unknown_tag_rejected() {
706        // An Unknown mark may not reuse a built-in type name (would parse back
707        // as the built-in, dropping attrs — non-injective).
708        let mut rt = Content::empty();
709        rt.text = "abcd".into();
710        rt.marks = vec![Mark {
711            start: 0,
712            end: 4,
713            kind: MarkKind::Unknown {
714                tag: "strong".into(),
715                attrs: serde_json::json!({}),
716            },
717        }];
718        assert!(matches!(
719            rt.validate(),
720            Err(crate::model::Invariant::ReservedUnknownTag(_))
721        ));
722    }
723
724    #[test]
725    fn unknown_loss_class_defaults_unrepresentable() {
726        let json = r#"{"text":"","lines":[{"kind":"island","containers":[]}],"marks":[],"islands":[{"id":"i1","type":"widget","props":{},"loss":"future_class"}]}"#;
727        let rt = Content::from_canonical_json(json).unwrap();
728        assert_eq!(rt.islands[0].loss, Loss::Unrepresentable);
729    }
730
731    #[test]
732    fn unknown_mark_round_trips_opaque() {
733        let mut rt = Content::empty();
734        rt.text = "abcd".into();
735        rt.marks = vec![Mark {
736            start: 0,
737            end: 4,
738            kind: MarkKind::Unknown {
739                tag: "highlight".into(),
740                attrs: serde_json::json!({"color": "yellow"}),
741            },
742        }];
743        let json = rt.to_canonical_json();
744        let back = Content::from_canonical_json(&json).unwrap();
745        assert_eq!(back.marks[0].kind, rt.marks[0].kind);
746    }
747}