Skip to main content

monospace_description/
lib.rs

1//! The diagram description format: JSON deserialized into a `monospace_diagram::Diagram` or
2//! serialized back from one.
3//!
4//! Two calls do the whole of it — [`parse`] takes the text and hands back the diagram, and
5//! [`serialize`] takes the diagram and hands back the formatted JSON text — and every type behind
6//! those calls is private to this crate. The public surface is four things: [`parse`],
7//! [`ParseError`], [`serialize`] and [`SerializeError`]. Nothing here reaches a file, a terminal or
8//! a screen: a caller that wants to read from or write to disk reads or writes the disk itself and
9//! hands the text over. **Nothing here names a window either**, because which part of a diagram to
10//! draw is the caller's question and the window is the caller's.
11//!
12//! **This crate is a layer above the diagram, and it knows nothing of the editing layer.** Both
13//! hang off `monospace-diagram` and neither depends on the other; what would join them is a
14//! convenience that opens a file into a session, and that belongs to whoever needs it rather than
15//! to either layer. See [`docs/diagram-model.md`](../../../docs/diagram-model.md).
16//!
17//! # The envelope
18//!
19//! One file is one JSON object of two fields, and both are required:
20//!
21//! ```json
22//! {
23//!   "next_id": 3,
24//!   "shapes": []
25//! }
26//! ```
27//!
28//! - **`next_id`** is the ordinal the next shape added takes, which is where numbering **resumes**
29//!   rather than a count of what was read: `1`, `7` and `9` under `next_id: 10` hands back `10`. It
30//!   is a **nonzero** ordinal, because zero is not an identity; `"next_id": 0` is refused here
31//!   rather than seeded with something no shape can carry.
32//! - **`shapes`** is the figures in drawing order, empty or not, the last front-most and deciding a
33//!   shared cell first. Each entry names a `kind` and carries the `id` its shape is held under.
34//!
35//! **There is no `canvas`.** A description that still carries one is read, and the canvas ignored:
36//! there is no writer, so nothing outside this repository has ever produced a file, and a stricter
37//! error would be a change to wording that fifteen tests assert verbatim for no gain. **The window a
38//! picture is drawn into is the caller's**, named on the command line or on the line that opens a
39//! render marker — see [`docs/diagram-model.md`](../../../docs/diagram-model.md), which holds that
40//! the diagram measures nothing and sizes nothing.
41//!
42//! # What is not here
43//!
44//! **No file.** Neither [`parse`] nor [`serialize`] takes a path, so nothing in this crate reaches
45//! a filesystem — which is also what keeps it portable by naming rather than by anything it
46//! avoids.
47
48use std::num::NonZeroU16;
49
50use monospace_core::{Direction, Glyph, Orientation as CoreOrientation, SymbolStyleName, Text};
51use monospace_diagram::{
52    Anchor, Delta, Diagram, Endpoint as DiagramEndpoint, EndpointAt, Position, Reference,
53    Shape as DiagramShape, ShapeId,
54};
55use serde::{Deserialize, Deserializer, Serialize, Serializer};
56
57/// A description that could not be read.
58///
59/// **The message is the one `serde_json` produced, unchanged.** A newtype rather than a
60/// re-wrap: the value of this type is that `serde` does not appear in this crate's public API, not
61/// that it says anything the underlying error did not. A caller prints it and the words are the
62/// ones the format has always reported — a missing field named, a `kind` this build does not know
63/// named beside the two it does, an identity written as free text and quoted back — which is what
64/// the contract tests here and in `monospace-cli` assert on.
65///
66/// What would earn it more is a writer: a file whose `kind` this build does not know is a
67/// different kind of problem from one that is spelled wrong, and only then is there something to add
68/// that `serde` cannot say.
69#[derive(Debug)]
70pub struct ParseError(serde_json::Error);
71
72impl std::fmt::Display for ParseError {
73    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        self.0.fmt(formatter)
75    }
76}
77
78impl std::error::Error for ParseError {
79    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80        Some(&self.0)
81    }
82}
83
84/// Reads a description and hands back the diagram it holds, and nothing else.
85///
86/// The whole of this crate's public surface for reading, in one call: there is no value to build,
87/// hold or convert afterwards, so no method can be called in the wrong order and none leaves a
88/// diagram half-converted. **The window is not here** — which part of a diagram to draw is the
89/// caller's question, and a caller that wants one names it on the command line or on the line that
90/// opens a render marker.
91///
92/// # Errors
93///
94/// [`ParseError`] when the text is not a description this crate reads — a field left out, a `kind`
95/// it does not name, an identity written as free text, a `next_id` of zero. The message names the
96/// field it was refused on and is the one `serde_json` produced.
97///
98/// # Examples
99///
100/// ```
101/// # use monospace_description::parse;
102/// let diagram = parse(
103///     r#"{
104///         "next_id": 2,
105///         "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
106///                       "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
107///     }"#,
108/// )
109/// .expect("a well-formed description");
110///
111/// assert!(diagram
112///     .get(monospace_diagram::ShapeId::new(std::num::NonZeroU16::new(1).expect("one is nonzero")))
113///     .is_some());
114/// ```
115pub fn parse(json: &str) -> Result<Diagram, ParseError> {
116    let description: Description = serde_json::from_str(json).map_err(ParseError)?;
117    Ok(description.into_diagram())
118}
119
120/// A description that could not be serialized.
121///
122/// **The message is the one `serde_json` produced, unchanged.** A newtype rather than a
123/// re-wrap: the value of this type is that `serde` does not appear in this crate's public API, not
124/// that it says anything the underlying error did not.
125#[derive(Debug)]
126pub struct SerializeError(serde_json::Error);
127
128impl std::fmt::Display for SerializeError {
129    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        self.0.fmt(formatter)
131    }
132}
133
134impl std::error::Error for SerializeError {
135    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
136        Some(&self.0)
137    }
138}
139
140/// Serializes `diagram` into formatted JSON matching the description format read by [`parse`].
141///
142/// The output is 2-space pretty-printed JSON ending in a newline, with `next_id` and the `shapes`
143/// array in drawing order from back to front.
144///
145/// # Errors
146///
147/// [`SerializeError`] when the description could not be converted to JSON.
148///
149/// # Examples
150///
151/// ```
152/// # use monospace_description::{parse, serialize};
153/// let diagram = parse(
154///     r#"{
155///         "next_id": 2,
156///         "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
157///                       "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
158///     }"#,
159/// )
160/// .expect("a well-formed description");
161///
162/// let json = serialize(&diagram).expect("serializes to JSON");
163/// assert!(json.contains("\"next_id\": 2"));
164/// ```
165pub fn serialize(diagram: &Diagram) -> Result<String, SerializeError> {
166    let description = Description::from_diagram(diagram);
167    let mut json = serde_json::to_string_pretty(&description).map_err(SerializeError)?;
168    json.push('\n');
169    Ok(json)
170}
171
172/// A position, mirroring `monospace_core::Pos` for deserialization and serialization.
173#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
174struct Pos {
175    x: i16,
176    y: i16,
177}
178
179impl From<Pos> for monospace_core::Pos {
180    fn from(pos: Pos) -> Self {
181        monospace_core::Pos { x: pos.x, y: pos.y }
182    }
183}
184
185impl From<monospace_core::Pos> for Pos {
186    fn from(pos: monospace_core::Pos) -> Self {
187        Self { x: pos.x, y: pos.y }
188    }
189}
190
191/// A size, mirroring `monospace_core::Size` for deserialization and serialization.
192#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
193struct Size {
194    width: u16,
195    height: u16,
196}
197
198impl From<Size> for monospace_core::Size {
199    fn from(size: Size) -> Self {
200        monospace_core::Size {
201            width: size.width,
202            height: size.height,
203        }
204    }
205}
206
207impl From<monospace_core::Size> for Size {
208    fn from(size: monospace_core::Size) -> Self {
209        Self {
210            width: size.width,
211            height: size.height,
212        }
213    }
214}
215
216/// Whether a line runs along a row or a column, mirroring `monospace_core::Orientation`.
217#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
218#[serde(rename_all = "lowercase")]
219enum Orientation {
220    Horizontal,
221    Vertical,
222}
223
224impl From<Orientation> for CoreOrientation {
225    fn from(orientation: Orientation) -> Self {
226        match orientation {
227            Orientation::Horizontal => CoreOrientation::Horizontal,
228            Orientation::Vertical => CoreOrientation::Vertical,
229        }
230    }
231}
232
233impl From<CoreOrientation> for Orientation {
234    fn from(orientation: CoreOrientation) -> Self {
235        match orientation {
236            CoreOrientation::Horizontal => Self::Horizontal,
237            CoreOrientation::Vertical => Self::Vertical,
238        }
239    }
240}
241
242/// The direction a connector endpoint leaves in, mirroring `monospace_core::Direction`.
243#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
244#[serde(rename_all = "lowercase")]
245enum Leaving {
246    Up,
247    Right,
248    Down,
249    Left,
250}
251
252impl From<Leaving> for Direction {
253    fn from(leaving: Leaving) -> Self {
254        match leaving {
255            Leaving::Up => Direction::Up,
256            Leaving::Right => Direction::Right,
257            Leaving::Down => Direction::Down,
258            Leaving::Left => Direction::Left,
259        }
260    }
261}
262
263impl From<Direction> for Leaving {
264    fn from(direction: Direction) -> Self {
265        match direction {
266            Direction::Up => Self::Up,
267            Direction::Right => Self::Right,
268            Direction::Down => Self::Down,
269            Direction::Left => Self::Left,
270        }
271    }
272}
273
274/// Serializes a required glyph field as its string representation.
275fn serialize_glyph<S>(glyph: &Glyph, serializer: S) -> Result<S::Ok, S::Error>
276where
277    S: Serializer,
278{
279    serializer.serialize_str(glyph.as_str())
280}
281
282/// Serializes an optional glyph field (`fill`): `Some` writes the string, `None` is skipped.
283#[allow(clippy::ref_option)]
284fn serialize_optional_glyph<S>(glyph: &Option<Glyph>, serializer: S) -> Result<S::Ok, S::Error>
285where
286    S: Serializer,
287{
288    match glyph {
289        Some(glyph) => serializer.serialize_str(glyph.as_str()),
290        None => serializer.serialize_none(),
291    }
292}
293
294/// Serializes an optional text field (`text`): lines are joined with newlines.
295#[allow(clippy::ref_option)]
296fn serialize_optional_text<S>(text: &Option<Text>, serializer: S) -> Result<S::Ok, S::Error>
297where
298    S: Serializer,
299{
300    match text {
301        Some(text) => {
302            let content = text
303                .lines()
304                .iter()
305                .map(|line| line.iter().map(Glyph::as_str).collect::<String>())
306                .collect::<Vec<_>>()
307                .join("\n");
308            serializer.serialize_str(&content)
309        }
310        None => serializer.serialize_none(),
311    }
312}
313
314#[allow(clippy::trivially_copy_pass_by_ref)]
315fn is_zero(n: &i16) -> bool {
316    *n == 0
317}
318
319fn shape_id_ordinal(id: ShapeId) -> NonZeroU16 {
320    id.to_string()
321        .parse::<NonZeroU16>()
322        .expect("ShapeId displays its NonZeroU16")
323}
324
325/// Deserializes a required glyph field: the text must be exactly one grapheme cluster with no
326/// control character, or this reports the same data-error kind `serde_json` reports for anything
327/// else wrong with the file.
328fn deserialize_glyph<'de, D>(deserializer: D) -> Result<Glyph, D::Error>
329where
330    D: Deserializer<'de>,
331{
332    let text = String::deserialize(deserializer)?;
333    Glyph::new(&text).ok_or_else(|| {
334        serde::de::Error::custom(format!("{text:?} is not exactly one grapheme cluster"))
335    })
336}
337
338/// Deserializes an optional glyph field (`fill`): absent stays `None`; present, it is checked the
339/// same way [`deserialize_glyph`] checks a required one.
340fn deserialize_optional_glyph<'de, D>(deserializer: D) -> Result<Option<Glyph>, D::Error>
341where
342    D: Deserializer<'de>,
343{
344    #[derive(Deserialize)]
345    struct Wrapper(#[serde(deserialize_with = "deserialize_glyph")] Glyph);
346
347    Ok(Option::<Wrapper>::deserialize(deserializer)?.map(|wrapper| wrapper.0))
348}
349
350/// Deserializes a text field: one string, split on `'\n'`, where any other control character is
351/// refused **by the field's name** the way [`deserialize_glyph`] refuses a bad `fill`.
352fn deserialize_text<'de, D>(deserializer: D) -> Result<Text, D::Error>
353where
354    D: Deserializer<'de>,
355{
356    let content = String::deserialize(deserializer)?;
357    Text::new(&content).ok_or_else(|| {
358        serde::de::Error::custom(format!(
359            "`text` is lines of glyphs: {content:?} holds a control character that is not a \
360             newline"
361        ))
362    })
363}
364
365/// Deserializes an optional text field (`text`): absent stays `None`; present, it is checked the
366/// same way [`deserialize_text`] checks a required one.
367fn deserialize_optional_text<'de, D>(deserializer: D) -> Result<Option<Text>, D::Error>
368where
369    D: Deserializer<'de>,
370{
371    #[derive(Deserialize)]
372    struct Wrapper(#[serde(deserialize_with = "deserialize_text")] Text);
373
374    Ok(Option::<Wrapper>::deserialize(deserializer)?.map(|wrapper| wrapper.0))
375}
376
377/// The ordinal the three identity fields share, and the reason each names itself on a refusal.
378///
379/// An identity is a number greater than zero — `1`, not `"#1"` — so a file spelling it any other
380/// way is a **data error**, and the message says which field was wrong rather than leaving a reader
381/// to work back from a position: `serde` reports `invalid type: string "#1", expected a nonzero
382/// u16`, which names neither `id` nor `shape` nor `next_id`, and the three are the whole of what a
383/// caller has to go on.
384///
385/// The name is the field's, written beside the field rather than read from serde's error, because
386/// serde cannot know it: the same [`NonZeroU16`] is deserialized from three different keys. The
387/// underlying message is carried through whole, so what the file actually said survives next to the
388/// field it was said in.
389fn ordinal<'de, D>(field: &'static str, deserializer: D) -> Result<NonZeroU16, D::Error>
390where
391    D: Deserializer<'de>,
392{
393    NonZeroU16::deserialize(deserializer)
394        .map_err(|error| serde::de::Error::custom(format!("`{field}` is an ordinal: {error}")))
395}
396
397/// The `id` of an entry in `shapes`. A thin wrapper over [`ordinal`] so that the name in the message
398/// is the field's own, beside the field it belongs to.
399fn id<'de, D>(deserializer: D) -> Result<NonZeroU16, D::Error>
400where
401    D: Deserializer<'de>,
402{
403    ordinal("id", deserializer)
404}
405
406/// The `next_id` a diagram's numbering resumes from.
407fn next_id<'de, D>(deserializer: D) -> Result<NonZeroU16, D::Error>
408where
409    D: Deserializer<'de>,
410{
411    ordinal("next_id", deserializer)
412}
413
414/// The `shape` a reference names.
415fn shape<'de, D>(deserializer: D) -> Result<NonZeroU16, D::Error>
416where
417    D: Deserializer<'de>,
418{
419    ordinal("shape", deserializer)
420}
421
422/// What an endpoint's `terminal` is on the wire, tagged by `kind`: one chosen glyph, one symbol in a
423/// named style, or one arm. An unrecognized `kind` is reported by name, and the message names the
424/// three accepted.
425///
426/// Internally tagged rather than externally, because `deserialize_glyph` needs a _named_ field to
427/// sit on and the external form has none — `Glyph(Glyph)` does not compile, since `Glyph` derives
428/// no `Deserialize`. A field an `arm` does not know is ignored, as everywhere else in this format.
429///
430/// **The `symbol` variant carries a style name and not a symbol.** Which symbol it writes follows
431/// from the endpoint's `leaving`, decided by the core so no file can spell a head that disagrees
432/// with the direction it leaves in. The name is not checked here: the catalog the caller renders
433/// with answers it, and a name it does not hold draws a space.
434#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
435#[serde(tag = "kind", rename_all = "lowercase")]
436enum Terminal {
437    Glyph {
438        #[serde(
439            serialize_with = "serialize_glyph",
440            deserialize_with = "deserialize_glyph"
441        )]
442        glyph: Glyph,
443    },
444    Symbol {
445        style: String,
446    },
447    Arm,
448}
449
450impl From<Terminal> for monospace_core::Terminal {
451    fn from(terminal: Terminal) -> Self {
452        match terminal {
453            Terminal::Glyph { glyph } => monospace_core::Terminal::Glyph { glyph },
454            Terminal::Symbol { style } => monospace_core::Terminal::Symbol {
455                style: SymbolStyleName::from(style.as_str()),
456            },
457            Terminal::Arm => monospace_core::Terminal::Arm,
458        }
459    }
460}
461
462impl From<monospace_core::Terminal> for Terminal {
463    fn from(terminal: monospace_core::Terminal) -> Self {
464        match terminal {
465            monospace_core::Terminal::Glyph { glyph } => Self::Glyph { glyph },
466            monospace_core::Terminal::Symbol { style } => Self::Symbol { style: style.0 },
467            monospace_core::Terminal::Arm => Self::Arm,
468        }
469    }
470}
471
472/// Which of a figure's four sides an endpoint hangs from, mirroring `monospace_diagram::Anchor`.
473///
474/// The same four and no fifth: there is no corner and no center, and the file cannot name one.
475#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
476#[serde(rename_all = "lowercase")]
477enum AnchorDescription {
478    Top,
479    Right,
480    Bottom,
481    Left,
482}
483
484impl From<AnchorDescription> for Anchor {
485    fn from(anchor: AnchorDescription) -> Self {
486        match anchor {
487            AnchorDescription::Top => Anchor::Top,
488            AnchorDescription::Right => Anchor::Right,
489            AnchorDescription::Bottom => Anchor::Bottom,
490            AnchorDescription::Left => Anchor::Left,
491        }
492    }
493}
494
495impl From<Anchor> for AnchorDescription {
496    fn from(anchor: Anchor) -> Self {
497        match anchor {
498            Anchor::Top => Self::Top,
499            Anchor::Right => Self::Right,
500            Anchor::Bottom => Self::Bottom,
501            Anchor::Left => Self::Left,
502        }
503    }
504}
505
506/// How far from that side an endpoint stands, mirroring `monospace_diagram::Delta`.
507///
508/// `Default` is what makes the field optional on the wire: `#[serde(default)]` on the field below
509/// builds a zero delta when the key is absent, so a reference that means "on the side itself" spells
510/// three fields rather than five. The two amounts are signed and are in the **screen** axes whatever
511/// side the anchor names — `dx` is cells right, `dy` cells down — and neither is checked against the
512/// side it is measured from.
513#[derive(Deserialize, Serialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
514struct OffsetDescription {
515    dx: i16,
516    dy: i16,
517}
518
519impl From<OffsetDescription> for Delta {
520    fn from(offset: OffsetDescription) -> Self {
521        Delta {
522            dx: offset.dx,
523            dy: offset.dy,
524        }
525    }
526}
527
528impl From<Delta> for OffsetDescription {
529    fn from(offset: Delta) -> Self {
530        Self {
531            dx: offset.dx,
532            dy: offset.dy,
533        }
534    }
535}
536
537/// What an endpoint's `at` is on the wire, tagged by `kind`: a point, or a reference to a side of
538/// another figure.
539///
540/// **The tag is the rule, not a sentence.** A file cannot say both, cannot say neither, and cannot
541/// say one by mistake — all three are compile-time facts about this enum rather than conventions a
542/// reader has to remember. The three spellings were measured against this crate's own `serde`
543/// rather than argued: an untagged union reads every existing description and
544/// answers anything wrong with `data did not match any variant of untagged enum At`, and a sibling
545/// `reference` beside an optional `at` refuses nothing at all.
546///
547/// A reference to a shape the diagram does not hold, or to an anchor that kind does not answer, is
548/// **not** an error here or anywhere downstream: the figure holding it is simply not drawn and the
549/// run succeeds. **The silence is the answer, on purpose** — a reference naming a shape that is not
550/// there yet is an ordinary state of a diagram being built rather than an invalid one, so the only
551/// thing this layer owes anybody is to refuse to draw something it cannot place. What it pays for
552/// that is that a diagram which drew nothing and a diagram whose every reference is broken look
553/// identical, and there is nothing to ask about the difference.
554///
555/// `Point` carries its `x` and `y` coordinates inline so the enum serializes and deserializes
556/// with the internal `kind` tag identically.
557///
558/// The `Reference` variant's two gap fields are **two spellings of one gap**, and which one a file
559/// reaches for is the caller's: `offset` is the screen axes whatever side the anchor names, and
560/// `out` is the side's own words, so `"anchor": "bottom", "out": 1` is one cell below the border
561/// without the file knowing that "below" is `dy: 1`. Both are optional and absent is zero, so a
562/// reference on the side itself is three fields; `out` is added to `offset` rather than replacing it,
563/// and a file may write either or both. `Reference` preserves both `offset` and `out` separately,
564/// resolving their sum at draw time and keeping `out` on serialization.
565#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
566#[serde(tag = "kind", rename_all = "lowercase")]
567enum At {
568    Point {
569        x: i16,
570        y: i16,
571    },
572    Reference {
573        #[serde(deserialize_with = "shape")]
574        shape: NonZeroU16,
575        anchor: AnchorDescription,
576        #[serde(default)]
577        offset: OffsetDescription,
578        #[serde(default, skip_serializing_if = "is_zero")]
579        out: i16,
580    },
581}
582
583impl From<Position> for At {
584    fn from(position: Position) -> Self {
585        match position {
586            Position::Absolute(pos) => Self::Point { x: pos.x, y: pos.y },
587            Position::Reference(reference) => Self::Reference {
588                shape: shape_id_ordinal(reference.id),
589                anchor: reference.anchor.into(),
590                offset: reference.offset.into(),
591                out: reference.out,
592            },
593        }
594    }
595}
596
597/// One endpoint of a connector: where it stands, the direction it leaves in, and its terminal.
598///
599/// `at` is an [`At`], so the diagram's `Position` is spelled by name in the conversion below: the
600/// `into()` on a [`Pos`] resolves `description::Pos → monospace_core::Pos` and lands on
601/// `Position::Absolute`, while a reference is built field by field. A reference may only be held by
602/// a **connector's endpoint** — a `box` and a `line` keep their bare `Pos` — and the types say so
603/// rather than the prose. The restriction is what keeps a chain of references one link long: a
604/// reference names a box or a line, both positioned absolutely, or a connector, which answers no
605/// side at all, so nothing recurses and no cycle can be built.
606/// An endpoint positioning at a `point` requires `leaving`. An endpoint positioning at a `reference`
607/// derives its leaving direction from its anchor and must not specify `leaving`.
608#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
609struct Endpoint {
610    at: At,
611    #[serde(skip_serializing_if = "Option::is_none")]
612    leaving: Option<Leaving>,
613    terminal: Terminal,
614}
615
616impl<'de> Deserialize<'de> for Endpoint {
617    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
618    where
619        D: Deserializer<'de>,
620    {
621        #[derive(Deserialize)]
622        struct RawEndpoint {
623            at: At,
624            #[serde(default)]
625            leaving: Option<Leaving>,
626            terminal: Terminal,
627        }
628
629        let raw = RawEndpoint::deserialize(deserializer)?;
630        match (&raw.at, raw.leaving) {
631            (At::Point { .. }, None) => Err(serde::de::Error::missing_field("leaving")),
632            (At::Reference { .. }, Some(_)) => Err(serde::de::Error::custom(
633                "a reference endpoint derives its leaving direction from its anchor and must not specify `leaving`",
634            )),
635            _ => Ok(Self {
636                at: raw.at,
637                leaving: raw.leaving,
638                terminal: raw.terminal,
639            }),
640        }
641    }
642}
643
644impl From<DiagramEndpoint> for Endpoint {
645    fn from(endpoint: DiagramEndpoint) -> Self {
646        match endpoint.at {
647            EndpointAt::Absolute { at, leaving } => Self {
648                at: Position::Absolute(at).into(),
649                leaving: Some(leaving.into()),
650                terminal: endpoint.terminal.into(),
651            },
652            EndpointAt::Reference(reference) => Self {
653                at: Position::Reference(reference).into(),
654                leaving: None,
655                terminal: endpoint.terminal.into(),
656            },
657        }
658    }
659}
660
661impl From<Endpoint> for DiagramEndpoint {
662    fn from(endpoint: Endpoint) -> Self {
663        match endpoint.at {
664            At::Point { x, y } => DiagramEndpoint::absolute(
665                monospace_core::Pos { x, y },
666                endpoint
667                    .leaving
668                    .expect("a deserialized point endpoint always has a leaving direction")
669                    .into(),
670                endpoint.terminal.into(),
671            ),
672            At::Reference {
673                shape,
674                anchor,
675                offset,
676                out,
677            } => DiagramEndpoint::reference(
678                Reference::new(ShapeId::new(shape), anchor.into(), offset.into(), out),
679                endpoint.terminal.into(),
680            ),
681        }
682    }
683}
684
685/// One entry in a `Description`'s `shapes` array, tagged by `kind`. An unrecognized `kind` is
686/// reported by name, since this enum is internally tagged.
687///
688/// Every variant carries an `id`: the identity its shape is held under, and the one a `reference`
689/// names. It is required, so a file leaving it out is refused **by name**, exactly as `canvas`,
690/// `shapes`, `leaving`, `terminal` and `at`'s `kind` already are. It sits **first** in every
691/// variant because a variant's fields are read in declaration order, and `id` immediately after
692/// `kind` on the wire is where all the descriptions in this repository already put it.
693///
694/// **The identity is an ordinal rather than any text.** One number greater than zero, deserialized
695/// by [`id`] so that `"#1"` is refused by name rather than read as a string nobody can resolve,
696/// and the same spelling the `next_id` beside it has always used.
697///
698/// The identity is **not** checked for uniqueness, and two entries may carry one: both are read,
699/// the first is what every change and every reference finds, and the second is reachable by no
700/// identity until the first is removed. That cost is accepted, and it is why the model's "unique
701/// within that diagram" is amended in the `docs` commit rather than enforced here.
702#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
703#[serde(tag = "kind", rename_all = "lowercase")]
704enum ShapeDescription {
705    Box {
706        #[serde(deserialize_with = "id")]
707        id: NonZeroU16,
708        at: Pos,
709        size: Size,
710        stroke: String,
711        #[serde(
712            default,
713            skip_serializing_if = "Option::is_none",
714            serialize_with = "serialize_optional_glyph",
715            deserialize_with = "deserialize_optional_glyph"
716        )]
717        fill: Option<Glyph>,
718        #[serde(
719            default,
720            skip_serializing_if = "Option::is_none",
721            serialize_with = "serialize_optional_text",
722            deserialize_with = "deserialize_optional_text"
723        )]
724        text: Option<Text>,
725    },
726    Line {
727        #[serde(deserialize_with = "id")]
728        id: NonZeroU16,
729        at: Pos,
730        len: u16,
731        orientation: Orientation,
732        stroke: String,
733    },
734    Connector {
735        #[serde(deserialize_with = "id")]
736        id: NonZeroU16,
737        from: Endpoint,
738        to: Endpoint,
739        stroke: String,
740    },
741}
742
743impl ShapeDescription {
744    fn from_shape(id: ShapeId, shape: &DiagramShape) -> Self {
745        let ordinal = shape_id_ordinal(id);
746        match shape {
747            DiagramShape::Box {
748                at,
749                size,
750                stroke,
751                fill,
752                text,
753            } => Self::Box {
754                id: ordinal,
755                at: (*at).into(),
756                size: (*size).into(),
757                stroke: stroke.0.clone(),
758                fill: fill.clone(),
759                text: text.clone(),
760            },
761            DiagramShape::Line {
762                at,
763                len,
764                orientation,
765                stroke,
766            } => Self::Line {
767                id: ordinal,
768                at: (*at).into(),
769                len: *len,
770                orientation: (*orientation).into(),
771                stroke: stroke.0.clone(),
772            },
773            DiagramShape::Connector { from, to, stroke } => Self::Connector {
774                id: ordinal,
775                from: from.clone().into(),
776                to: to.clone().into(),
777                stroke: stroke.0.clone(),
778            },
779        }
780    }
781
782    /// The identity this entry's shape is held under, read before the shape is converted.
783    ///
784    /// One method rather than three because the field is on every variant and the conversion below
785    /// drops it: the figure carries no identity, and it is the diagram that holds the one the file
786    /// wrote.
787    fn id(&self) -> ShapeId {
788        match self {
789            ShapeDescription::Box { id, .. }
790            | ShapeDescription::Line { id, .. }
791            | ShapeDescription::Connector { id, .. } => ShapeId::new(*id),
792        }
793    }
794}
795
796impl From<ShapeDescription> for DiagramShape {
797    /// Converts this description into the matching `monospace_diagram` shape, dropping no
798    /// parameter. The `id` goes with the diagram rather than with the figure: nothing about a
799    /// returned shape says where it sits, which is the arrangement here and stays it.
800    fn from(description: ShapeDescription) -> Self {
801        match description {
802            ShapeDescription::Box {
803                at,
804                size,
805                stroke,
806                fill,
807                text,
808                ..
809            } => DiagramShape::Box {
810                at: at.into(),
811                size: size.into(),
812                stroke: stroke.as_str().into(),
813                fill,
814                text,
815            },
816            ShapeDescription::Line {
817                at,
818                len,
819                orientation,
820                stroke,
821                ..
822            } => DiagramShape::Line {
823                at: at.into(),
824                len,
825                orientation: orientation.into(),
826                stroke: stroke.as_str().into(),
827            },
828            ShapeDescription::Connector {
829                from, to, stroke, ..
830            } => DiagramShape::Connector {
831                from: from.into(),
832                to: to.into(),
833                stroke: stroke.as_str().into(),
834            },
835        }
836    }
837}
838
839/// The whole of one description file: the ordinal the next shape takes, and an ordered list of
840/// shapes.
841///
842/// Private because nothing outside this crate needs it: [`parse`] reads one and hands back what it
843/// holds, and a caller that could hold a `Description` would be able to look at a format this
844/// crate does not promise to keep.
845#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
846struct Description {
847    /// The ordinal the next `add` takes: a number rather than a container holding one, because it
848    /// is one number. Required, so a file leaving it out is refused by name, the way `shapes`
849    /// already is.
850    ///
851    /// **It is trusted, not checked, beyond being nonzero.** A stale value — an entry removed, a
852    /// `2` deleted — hands back an identity already in use. That is D6's accepted cost (see
853    /// `specs/197`), and the repair it named was one line in `monospace-diagram`: `Diagram::add`
854    /// issues the first ordinal at or after this one that names no figure, which is what
855    /// `specs/212-hold-the-diagram-as-a-persistent-value.md` decides. **So the numbering a file
856    /// resumes at is a lower bound rather than a promise**, and a figure the diagram issued can come
857    /// out under an ordinal later than the one the reader would have counted to. What **is** checked
858    /// is the zero: zero is not an identity, so `"next_id": 0` is a data error naming the field
859    /// rather than a counter seeded with something the diagram could not carry.
860    #[serde(deserialize_with = "next_id")]
861    next_id: NonZeroU16,
862    /// The figures, in the order they are drawn. Required, and the order is load-bearing: the array
863    /// order is the drawing order, so the last entry is front-most and decides a shared cell first.
864    shapes: Vec<ShapeDescription>,
865}
866
867impl Description {
868    /// Builds a description representation from `diagram`.
869    fn from_diagram(diagram: &Diagram) -> Self {
870        Self {
871            next_id: diagram.next_id(),
872            shapes: diagram
873                .shapes()
874                .map(|(id, shape)| ShapeDescription::from_shape(id, shape))
875                .collect(),
876        }
877    }
878
879    /// Builds a diagram from `shapes`, in order, under the identities the file wrote and with its
880    /// numbering resuming where it says.
881    ///
882    /// **The order is still the order.** The array order remains the drawing order, so every
883    /// picture in the repository comes out byte for byte what it did — that is the claim the whole
884    /// mechanical change rests on, and it was measured rather than argued.
885    ///
886    /// A shape a connector names before the entry carrying it is written still resolves, because
887    /// resolution happens at draw time and the whole diagram exists by then. That edge case needs
888    /// no code here; it is what a whole diagram exists by then means.
889    fn into_diagram(self) -> Diagram {
890        let mut diagram = Diagram::numbered_from(self.next_id);
891        for shape in self.shapes {
892            diagram = diagram.add_under(shape.id(), shape.into());
893        }
894        diagram
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use monospace_core::{Buffer, Direction, GlyphCatalog, Pos, Size, Window};
901    use monospace_diagram::{Shape as DiagramShape, ShapeId};
902
903    use super::{Description, Diagram, ParseError, parse};
904
905    /// The error a description that must not read hands back.
906    ///
907    /// **A helper rather than `expect_err`**, which would need `Debug` on the pair `parse` returns:
908    /// `Diagram` derives none, and deriving it here would mean the diagram crate grew a `Debug`
909    /// every consumer inherits for the sake of one test module. `Result::err` does not.
910    fn refusal(json: &str) -> ParseError {
911        parse(json)
912            .err()
913            .expect("a description this format refuses must fail")
914    }
915
916    /// Rule 1 — `parse` reads a description and hands back the diagram it holds, and nothing else.
917    ///
918    /// **The test is the type.** A function that returns a `Diagram` cannot be handed a window, so
919    /// the claim is made where it can fail: in a caller that binds one value and draws it into a
920    /// window of its own choosing. A test asserting `parse(..).is_ok()` would pass against a
921    /// signature returning a pair, and would prove nothing the compiler does not already.
922    #[test]
923    fn parse_hands_back_the_diagram_and_nothing_else() {
924        let diagram: Diagram = parse(
925            r#"{
926            "next_id": 2,
927            "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
928                          "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
929        }"#,
930        )
931        .expect("a well-formed description");
932
933        // And the window is the caller's: the same diagram drawn into two different ones.
934        let drawn = |origin: Pos, size: Size| {
935            let mut buffer = Buffer::new(Window { origin, size });
936            diagram.draw(&mut buffer);
937            monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window())
938        };
939        let small = drawn(
940            Pos { x: 0, y: 0 },
941            Size {
942                width: 4,
943                height: 3,
944            },
945        );
946        let large = drawn(
947            Pos { x: 0, y: 0 },
948            Size {
949                width: 8,
950                height: 3,
951            },
952        );
953
954        assert_eq!(small, "┌──┐\n│  │\n└──┘\n");
955        assert_ne!(
956            small, large,
957            "the window is the caller's, not the description's"
958        );
959    }
960
961    /// Rule 9 — a box's `text` is one string, `\n` splits it into lines, and any other control
962    /// character is refused naming the field it was written in.
963    #[test]
964    fn a_description_refuses_a_control_character_in_text() {
965        let json = r#"{
966            "next_id": 2,
967            "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
968                          "size": { "width": 4, "height": 3 }, "stroke": "light",
969                          "text": "a\tb" } ]
970        }"#;
971        let error = refusal(json).to_string();
972
973        assert!(
974            error.contains("`text`"),
975            "the refusal names the field the text was written in: {error}"
976        );
977
978        let json = json.replace("a\\tb", "one\\ntwo");
979        parse(&json).expect("`\\n` is the one control character a text allows");
980    }
981
982    /// Rule 9 — a box's `text` is read and drawn centred in the box's interior: an 8x5 box puts
983    /// `hi` at `(3, 2)`, the position the diagram's own box test measures.
984    #[test]
985    fn a_description_reads_a_box_with_text() {
986        let diagram = parse(
987            r#"{
988            "next_id": 2,
989            "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
990                          "size": { "width": 8, "height": 5 }, "stroke": "light",
991                          "text": "hi" } ]
992        }"#,
993        )
994        .expect("a box with a text is a well-formed description");
995
996        let mut buffer = Buffer::new(Window {
997            origin: Pos { x: 0, y: 0 },
998            size: Size {
999                width: 8,
1000                height: 5,
1001            },
1002        });
1003        diagram.draw(&mut buffer);
1004
1005        assert_eq!(
1006            monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window()),
1007            "┌──────┐\n│      │\n│  hi  │\n│      │\n└──────┘\n"
1008        );
1009    }
1010
1011    /// Rule 2 — a description carrying a `canvas` is read, and the canvas ignored.
1012    ///
1013    /// **The file says `13x9` and the picture is drawn at `6x4`.** That is the whole of the claim,
1014    /// and it is the block a reader should be least comfortable with: a file that says it is one
1015    /// size and is drawn at another is a wrong answer given without an error. It is accepted because
1016    /// there is no writer, so nothing outside this repository has ever produced a file, and every
1017    /// marker is rewritten in the same increment.
1018    #[test]
1019    fn a_description_carrying_a_canvas_is_read_and_the_canvas_ignored() {
1020        let json = r#"{
1021            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 13, "height": 9 } },
1022            "next_id": 2,
1023            "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1024                          "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
1025        }"#;
1026
1027        let diagram: Diagram = parse(json).expect("a description carrying a canvas still reads");
1028        let origin = Pos { x: 0, y: 0 };
1029        let size = Size {
1030            width: 6,
1031            height: 4,
1032        };
1033        let mut buffer = Buffer::new(Window { origin, size });
1034        diagram.draw(&mut buffer);
1035
1036        assert_eq!(
1037            monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window()),
1038            "┌──┐  \n│  │  \n└──┘  \n      \n",
1039            "the `13x9` in the file has no say in it: the picture is the window the caller chose"
1040        );
1041    }
1042
1043    /// The rendering of a parsed description, drawn into `size` at `origin`.
1044    ///
1045    /// **The window is a parameter rather than something the description carries**, which is the
1046    /// whole of what this crate's public surface is now: a caller that wants a picture names the
1047    /// window, and the description it drew from has nothing to say about it.
1048    ///
1049    /// **The core's own Light table, and no glyph set beside it.** Every shape here is drawn in
1050    /// `light`, which is what Light covers, and the two tests that need a picture are about what a
1051    /// reference resolves to rather than about how a stroke draws — so a second catalog would add a
1052    /// dependency to answer nothing they ask. Measured rather than argued: the same three shapes
1053    /// drawn in `double` come out of this catalog as an empty buffer.
1054    fn render_once(description: Description, origin: Pos, size: Size) -> String {
1055        let diagram = description.into_diagram();
1056
1057        let mut buffer = Buffer::new(Window { origin, size });
1058        diagram.draw(&mut buffer);
1059        monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window())
1060    }
1061
1062    /// An unrecognized `kind` fails to deserialize and names the unrecognized value.
1063    #[test]
1064    fn an_unrecognized_kind_fails_to_deserialize_and_names_it() {
1065        let json = r#"{
1066            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
1067            "next_id": 1,
1068            "shapes": [ { "kind": "triangle" } ]
1069        }"#;
1070
1071        let error = refusal(json);
1072
1073        assert!(error.to_string().contains("triangle"), "{error}");
1074    }
1075
1076    /// A `fill` of more than one grapheme cluster fails to deserialize.
1077    #[test]
1078    fn a_multi_grapheme_fill_fails_to_deserialize() {
1079        let json = r#"{
1080            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
1081            "next_id": 2,
1082            "shapes": [
1083                { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 },
1084                  "stroke": "light", "fill": "ab" }
1085            ]
1086        }"#;
1087
1088        assert!(parse(json).is_err());
1089    }
1090
1091    /// A `terminal`'s glyph of more than one grapheme cluster fails to deserialize. The grapheme
1092    /// check sits on the named field inside the tagged object, which is the only reason the wire
1093    /// form is internally tagged.
1094    #[test]
1095    fn a_multi_grapheme_terminal_glyph_fails_to_deserialize() {
1096        let json = r#"{
1097            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 8, "height": 1 } },
1098            "next_id": 2,
1099            "shapes": [
1100                { "kind": "connector", "id": 1,
1101                  "from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right",
1102                            "terminal": { "kind": "glyph", "glyph": "ab" } },
1103                  "to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
1104                          "terminal": { "kind": "glyph", "glyph": ">" } },
1105                  "stroke": "light" }
1106            ]
1107        }"#;
1108
1109        assert!(parse(json).is_err());
1110    }
1111
1112    /// One connector, with `TERMINAL` standing where the `from` endpoint's terminal goes. The three
1113    /// refusals below differ only in what is written there, which is what makes them the same test
1114    /// three times over.
1115    const ARROW: &str = r#""from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right", "terminal": TERMINAL },
1116                  "to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
1117                          "terminal": { "kind": "glyph", "glyph": ">" } }"#;
1118
1119    /// The same connector with the field left out altogether.
1120    const ARROW_WITHOUT_A_TERMINAL: &str = r#""from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right" },
1121                  "to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
1122                          "terminal": { "kind": "glyph", "glyph": ">" } }"#;
1123
1124    fn description_of(connector: &str) -> String {
1125        format!(
1126            r#"{{
1127            "canvas": {{ "origin": {{ "x": 0, "y": 0 }}, "size": {{ "width": 8, "height": 1 }} }},
1128            "next_id": 2,
1129            "shapes": [
1130                {{ "kind": "connector", "id": 1,
1131                  {connector},
1132                  "stroke": "light" }}
1133            ]
1134        }}"#
1135        )
1136    }
1137
1138    /// The message the binary reports for an endpoint whose terminal is `terminal`.
1139    ///
1140    /// `serde_json` appends the position the problem was found at, so what is compared is the
1141    /// message this crate produced and not the whole of what `serde_json` renders.
1142    fn error_for(terminal: &str) -> String {
1143        let json = description_of(&ARROW.replace("TERMINAL", terminal));
1144        refusal(&json).to_string()
1145    }
1146
1147    /// A `kind` the model has not named is refused by name, and the message names the three that
1148    /// are accepted — so the value is never read as one of them.
1149    #[test]
1150    fn an_unrecognized_terminal_kind_names_it_and_the_three_that_are_accepted() {
1151        let error = error_for(r#"{ "kind": "dot" }"#);
1152
1153        assert!(
1154            error.starts_with("unknown variant `dot`, expected one of `glyph`, `symbol`, `arm`"),
1155            "{error}"
1156        );
1157    }
1158
1159    /// A symbol terminal names a style, and a file that leaves the field out is refused **by
1160    /// name** rather than read as an arm or as a default style. The fallback #245 proposed was for
1161    /// an unknown name; a missing one is not a name at all.
1162    #[test]
1163    fn a_symbol_terminal_without_a_style_is_refused_by_name() {
1164        let error = error_for(r#"{ "kind": "symbol" }"#);
1165
1166        assert!(error.starts_with("missing field `style`"), "{error}");
1167    }
1168
1169    /// D6's other half: a style name the catalog does not hold is accepted by `parse` and draws a
1170    /// space, because the name is resolved at render rather than where the file is read. The
1171    /// catalog here holds `solid`, so the space is the misspelled name's and not a missing table's.
1172    #[test]
1173    fn an_unknown_style_name_draws_a_space() {
1174        let json = description_of(
1175            r#""from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right",
1176                         "terminal": { "kind": "symbol", "style": "nope" } },
1177               "to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
1178                       "terminal": { "kind": "symbol", "style": "solid" } }"#,
1179        );
1180        let diagram = parse(&json).expect("an unknown style is read, not refused");
1181        let mut buffer = Buffer::new(Window {
1182            origin: Pos { x: 0, y: 0 },
1183            size: Size {
1184                width: 7,
1185                height: 1,
1186            },
1187        });
1188        diagram.draw(&mut buffer);
1189        let catalog = GlyphCatalog::union([GlyphCatalog::light(), GlyphCatalog::solid()]);
1190
1191        assert_eq!(
1192            monospace_core::render(&buffer, &catalog, buffer.window()),
1193            " ─────▶\n",
1194            "the unknown name is a space and the held one draws its glyph"
1195        );
1196    }
1197
1198    /// A `glyph` that is not exactly one grapheme cluster is rejected with the message the old
1199    /// `head` produced, byte for byte.
1200    #[test]
1201    fn a_multi_grapheme_terminal_glyph_is_rejected_with_the_message_the_old_head_produced() {
1202        let error = error_for(r#"{ "kind": "glyph", "glyph": "ab" }"#);
1203
1204        assert!(
1205            error.starts_with("\"ab\" is not exactly one grapheme cluster"),
1206            "{error}"
1207        );
1208    }
1209
1210    /// The field is required for both values, so a file that omits it is refused rather than read
1211    /// as a connector with no terminal at either end. A terminal's presence never decides what a
1212    /// description means.
1213    #[test]
1214    fn an_omitted_terminal_field_is_refused_by_name() {
1215        let json = description_of(ARROW_WITHOUT_A_TERMINAL);
1216
1217        let error = refusal(&json).to_string();
1218
1219        assert!(error.starts_with("missing field `terminal`"), "{error}");
1220    }
1221
1222    /// The externally tagged spelling was measured too: every value of `terminal` is an object
1223    /// tagged by `kind`, so a bare string is refused on purpose rather than read as an arm.
1224    #[test]
1225    fn an_externally_tagged_terminal_is_refused() {
1226        let error = error_for(r#""arm""#);
1227
1228        assert!(
1229            error.starts_with(
1230                r#"invalid type: string "arm", expected internally tagged enum Terminal"#
1231            ),
1232            "{error}"
1233        );
1234    }
1235
1236    /// The same connector as `ARROW`, with `AT` standing where the `from` endpoint's `at` goes, so
1237    /// the three cases below differ only in what an `at` says and the file around it is fixed.
1238    const ARROW_WITH_AN_AT: &str = r#""from": { "at": AT, "leaving": "right",
1239        "terminal": { "kind": "arm" } },
1240      "to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
1241              "terminal": { "kind": "glyph", "glyph": ">" } }"#;
1242
1243    /// A connector whose `from` endpoint stands at `AT` without a `leaving` direction, used for
1244    /// reference endpoints which derive leaving from their anchor.
1245    const ARROW_WITH_A_REFERENCE: &str = r#""from": { "at": AT,
1246        "terminal": { "kind": "arm" } },
1247      "to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
1248              "terminal": { "kind": "glyph", "glyph": ">" } }"#;
1249
1250    /// The message the binary reports for an endpoint whose `at` is `at`, read the way a reader
1251    /// reads it: the message, not the `serde_json` position that follows it.
1252    fn error_for_an_at(at: &str) -> String {
1253        let json = description_of(&ARROW_WITH_AN_AT.replace("AT", at));
1254        refusal(&json).to_string()
1255    }
1256
1257    /// A point written without a tag is **refused**, not read as one of the two.
1258    ///
1259    /// The position that follows the message is `serde_json`'s and moves with the bytes, which is
1260    /// why what is compared is the message. Before the tag landed this file rendered and exited
1261    /// successfully — the silence the tag is there to end, measured both ways.
1262    #[test]
1263    fn an_untagged_at_is_refused_by_name() {
1264        let error = error_for_an_at(r#"{ "x": 0, "y": 0 }"#);
1265
1266        assert!(error.starts_with("missing field `kind`"), "{error}");
1267    }
1268
1269    /// A `kind` the union has no arm for is refused by name, **and the message names the two it
1270    /// does have** — so the value is never read as one of them.
1271    ///
1272    /// The same rule this format's other `kind` fields have always obeyed, and the message is the
1273    /// whole of what choosing the tag bought over an untagged union, which would have answered the
1274    /// same file with `data did not match any variant of untagged enum At`.
1275    #[test]
1276    fn an_unknown_at_kind_names_it_and_the_two_that_are_accepted() {
1277        let error = error_for_an_at(r#"{ "kind": "arrows", "x": 0, "y": 0 }"#);
1278
1279        assert!(
1280            error.starts_with("unknown variant `arrows`, expected `point` or `reference`"),
1281            "{error}"
1282        );
1283    }
1284
1285    /// A `point` written beside a stray `shape` is **accepted**, and the `shape` is dropped in
1286    /// silence.
1287    ///
1288    /// This is the format's existing behavior rather than anything this slice adds, and it is the
1289    /// cost of an internal tag rather than a defect: the tag is the rule, and a field beside it is
1290    /// an unknown field exactly as a description carrying `mode` was before the format dropped it.
1291    /// Pinned because the alternative — refusing a file for a harmless extra key — is the change a
1292    /// later slice would make silently, and this is where the present answer is written down.
1293    #[test]
1294    fn a_stray_field_beside_an_at_is_dropped_in_silence() {
1295        let json = description_of(
1296            &ARROW_WITH_AN_AT.replace("AT", r#"{ "kind": "point", "x": 1, "y": 1, "shape": 5 }"#),
1297        );
1298
1299        assert!(
1300            parse(&json).is_ok(),
1301            "a stray field beside a tagged `at` must not refuse the file"
1302        );
1303    }
1304
1305    /// A reference carrying **no** `offset` at all reads, and is a reference standing on the side
1306    /// itself — which is the case the field's being optional exists for.
1307    ///
1308    /// Without `#[serde(default)]` this would be a missing-field error on `offset`, and every
1309    /// reference meaning "on the border" would have to spell two zeros to say nothing — the reason
1310    /// the field is optional and absent zero.
1311    #[test]
1312    fn a_reference_with_no_offset_is_read_as_a_reference_on_the_side_itself() {
1313        let json = description_of(&ARROW_WITH_A_REFERENCE.replace(
1314            "AT",
1315            r#"{ "kind": "reference", "shape": 1, "anchor": "right" }"#,
1316        ));
1317
1318        assert!(
1319            parse(&json).is_ok(),
1320            "a reference without an `offset` must read"
1321        );
1322    }
1323
1324    /// An unknown key **inside** a reference is dropped in silence — which is why a file could not
1325    /// spell `out` before the field existed, and why `"out": 1` read by a build without it draws the
1326    /// point on the border and says nothing at all.
1327    ///
1328    /// One level in from `a_stray_field_beside_an_at_is_dropped_in_silence`: that one puts a field
1329    /// beside the tag and this one beside a reference's own fields, and both are the same cost of an
1330    /// internal tag. **`along` is the key this is really about** — the second spelling of the amount
1331    /// that runs along the side, declined because the offset already says it — so a file naming it
1332    /// reads, and reads as a reference that says nothing about it.
1333    #[test]
1334    fn an_unknown_key_inside_a_reference_is_dropped_in_silence() {
1335        let json = description_of(&ARROW_WITH_A_REFERENCE.replace(
1336            "AT",
1337            r#"{ "kind": "reference", "shape": 1, "anchor": "right", "along": 2 }"#,
1338        ));
1339
1340        assert!(
1341            parse(&json).is_ok(),
1342            "an unknown key beside a reference's own fields must not refuse the file"
1343        );
1344    }
1345
1346    /// A reference follows the identity, **both directions**.
1347    ///
1348    /// Two descriptions over the same window, the same two boxes and the same connector, differing
1349    /// only in the order their entries are listed in. The connector naming the **box** draws the
1350    /// same buffer whichever order the entries are in; the connector naming the **place** draws two
1351    /// different pictures, which is what those files mean today.
1352    ///
1353    /// Both directions, because a reader that made the identity win in one and not in the other
1354    /// would pass a single test — and the second half is the one that is easy to leave out, since
1355    /// the first half is what the slice is for.
1356    ///
1357    /// **The identities are ordinals rather than the free text this test used to give them**,
1358    /// which is the whole cost the format change names: the two halves are still about the same
1359    /// thing — an identity is not a place — and they say it with `1` and `2`.
1360    #[test]
1361    fn a_reference_follows_the_identity_and_not_the_place_where_the_entry_is_written() {
1362        // Two boxes at **fixed** positions, far enough apart that the route to either is a
1363        // different drawing: one at `{0, 3}` and one at `{11, 3}`, and a connector falling from
1364        // `{7, 0}` onto the **top** of whichever box it is told to name. The two boxes do not
1365        // touch, which is what makes the two routes distinguishable at all — measured, because a
1366        // pair that shares cells composes to the same picture whichever way round it is built.
1367        //
1368        // `left` and `right` are the ordinals given to the box at `{0, 3}` and the one at `{11, 3}`
1369        // respectively, and `right_listed_first` says which of them the `shapes` array lists
1370        // first. Nothing else changes between the four descriptions below, so the route can only
1371        // move if the reader is following the place rather than the identity.
1372        let description_with =
1373            |left: u16, right: u16, right_listed_first: bool, connector_names| {
1374                let left_box = format!(
1375                    r#"{{ "kind": "box", "id": {left}, "at": {{ "x": 0, "y": 3 }},
1376                  "size": {{ "width": 4, "height": 3 }}, "stroke": "light" }}"#
1377                );
1378                let right_box = format!(
1379                    r#"{{ "kind": "box", "id": {right}, "at": {{ "x": 11, "y": 3 }},
1380                  "size": {{ "width": 4, "height": 3 }}, "stroke": "light" }}"#
1381                );
1382                let boxes = if right_listed_first {
1383                    format!("{right_box},\n                {left_box}")
1384                } else {
1385                    format!("{left_box},\n                {right_box}")
1386                };
1387                format!(
1388                    r#"{{
1389            "canvas": {{ "origin": {{ "x": 0, "y": 0 }}, "size": {{ "width": 16, "height": 6 }} }},
1390            "next_id": 3,
1391            "shapes": [
1392                {boxes},
1393                {{ "kind": "connector", "id": 3,
1394                  "from": {{ "at": {{ "kind": "point", "x": 7, "y": 0 }}, "leaving": "down",
1395                             "terminal": {{ "kind": "glyph", "glyph": "▼" }} }},
1396                  "to": {{ "at": {{ "kind": "reference", "shape": {connector_names},
1397                                      "anchor": "top" }},
1398                          "terminal": {{ "kind": "arm" }} }},
1399                  "stroke": "light" }}
1400            ]
1401        }}"#
1402                )
1403            };
1404        // `serde_json` and not [`parse`]: `render_once` takes the `Description` because the window
1405        // is a parameter now, and `parse` hands back the diagram and nothing else.
1406        let read = |json: String| serde_json::from_str::<Description>(&json).expect("well-formed");
1407        let window = (
1408            Pos { x: 0, y: 0 },
1409            Size {
1410                width: 16,
1411                height: 6,
1412            },
1413        );
1414
1415        // Naming the **box**: the box at `{11, 3}` is `2` in both files and is listed first in one
1416        // and second in the other, so both draw the same picture.
1417        let right_listed_first =
1418            render_once(read(description_with(1, 2, true, 2)), window.0, window.1);
1419        let right_listed_second =
1420            render_once(read(description_with(1, 2, false, 2)), window.0, window.1);
1421        assert_eq!(
1422            right_listed_first, right_listed_second,
1423            "a reference naming a shape must not move when the entries are listed in another order"
1424        );
1425
1426        // Naming the **place**: `2` is the identity the second entry carries in each file, and the
1427        // two files put a different box there, so the connector lands on a different box in each.
1428        // That is what those files mean today, and the half that says the identity won in one
1429        // direction and not in the other — a reader that made it win in both would have failed the
1430        // first half.
1431        let place_right_first =
1432            render_once(read(description_with(2, 1, true, 2)), window.0, window.1);
1433        let place_right_second =
1434            render_once(read(description_with(1, 2, false, 2)), window.0, window.1);
1435        assert_ne!(
1436            place_right_first, place_right_second,
1437            "a reference naming a place must still follow the place it is written at"
1438        );
1439    }
1440
1441    /// A missing identity is refused **by name**, exactly as `canvas`, `shapes`, `leaving`,
1442    /// `terminal` and `at`'s `kind` already are.
1443    ///
1444    /// Asserted on the message and not on the line and column that follow it, which are serde's
1445    /// and move with the bytes. The `display_with_line_column` half of the claim is the rest of the
1446    /// format's existing behavior and is already pinned by
1447    /// `an_omitted_terminal_field_is_refused_by_name`; this pins the two new names beside it.
1448    #[test]
1449    fn a_missing_identity_is_refused_by_name() {
1450        let without_next_id = r#"{
1451            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
1452            "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1453                          "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
1454        }"#;
1455        let without_an_id = r#"{
1456            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
1457            "next_id": 2,
1458            "shapes": [ { "kind": "box", "at": { "x": 0, "y": 0 },
1459                          "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
1460        }"#;
1461
1462        for (json, named) in [(without_next_id, "next_id"), (without_an_id, "id")] {
1463            let error = refusal(json);
1464            assert!(
1465                error
1466                    .to_string()
1467                    .contains(&format!("missing field `{named}`")),
1468                "expected `missing field `{named}``, got: {error}"
1469            );
1470        }
1471    }
1472
1473    /// Free text is **refused**, and the message names the field it was refused on.
1474    ///
1475    /// **Both fields, and both spellings**, because the three identity fields share one
1476    /// deserializer and the only thing that differs between them is the name it puts in the
1477    /// message. `serde`'s own wording — `invalid type: string "#1", expected a nonzero u16` — names
1478    /// neither, so a reader handed it has nothing to work back from; and a wrapper that named
1479    /// `id` for all three would send a reader after the wrong field on the other two.
1480    ///
1481    /// **The spelling is quoted and not summarized**, so a file that wrote `"arrow"` says so rather
1482    /// than being told it wrote something else. The `next_id` half is
1483    /// `a_next_id_of_zero_is_refused_and_names_the_field_it_was_refused_on` beside this one.
1484    #[test]
1485    fn an_identity_written_as_free_text_is_refused_and_names_the_field() {
1486        let a_string_id = r#"{
1487            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
1488            "next_id": 2,
1489            "shapes": [ { "kind": "box", "id": "arrow", "at": { "x": 0, "y": 0 },
1490                          "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
1491        }"#;
1492        let a_string_shape = &description_of(&ARROW_WITH_A_REFERENCE.replace(
1493            "AT",
1494            r##"{ "kind": "reference", "shape": "#1", "anchor": "right" }"##,
1495        ));
1496
1497        for (json, named, said) in [
1498            (a_string_id, "id", "\"arrow\""),
1499            (a_string_shape, "shape", "\"#1\""),
1500        ] {
1501            let error = refusal(json).to_string();
1502            assert!(
1503                error.starts_with(&format!("`{named}` is an ordinal: ")),
1504                "expected `{named}` to be named first, got: {error}"
1505            );
1506            assert!(
1507                error.contains(said),
1508                "expected the message to quote what the file said, `{said}`, got: {error}"
1509            );
1510        }
1511    }
1512
1513    /// A `next_id` of zero is refused and named, because zero is not an identity.
1514    ///
1515    /// **`0` is the one value the whole format cannot express**, so a counter that took it would be
1516    /// a counter seeded with nothing the diagram could carry — and the old reader held it and issued
1517    /// `"#0"` from it. The refusal names `next_id` for the same reason the other two name theirs.
1518    #[test]
1519    fn a_next_id_of_zero_is_refused_and_names_the_field_it_was_refused_on() {
1520        let json = r#"{
1521            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
1522            "next_id": 0,
1523            "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1524                          "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
1525        }"#;
1526
1527        let error = refusal(json).to_string();
1528
1529        assert!(
1530            error.starts_with("`next_id` is an ordinal: "),
1531            "expected `next_id` to be named first, got: {error}"
1532        );
1533        assert!(
1534            error.contains('0'),
1535            "expected the message to quote the zero it was handed, got: {error}"
1536        );
1537    }
1538
1539    /// An ordinal is read everywhere the format names a shape, and the file still draws.
1540    ///
1541    /// **The positive case beside the two refusals**, because a reader that refused everything
1542    /// would pass both of those. The same three fields are here written as `1` and `2`, and the
1543    /// connector's reference resolves through the ordinal the box carries: **a connector answers no
1544    /// anchor**, so this draws the connector rather than the window an unresolvable reference leaves,
1545    /// which is what makes the refusal above and this the same field read two ways.
1546    #[test]
1547    fn an_ordinal_is_read_where_the_format_names_a_shape() {
1548        let json = r#"{
1549            "canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 8, "height": 3 } },
1550            "next_id": 3,
1551            "shapes": [
1552                { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1553                  "size": { "width": 4, "height": 3 }, "stroke": "light" },
1554                { "kind": "connector", "id": 2,
1555                  "from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
1556                            "terminal": { "kind": "glyph", "glyph": ">" } },
1557                  "to": { "at": { "kind": "point", "x": 7, "y": 1 }, "leaving": "left",
1558                          "terminal": { "kind": "arm" } },
1559                  "stroke": "light" }
1560            ]
1561        }"#;
1562
1563        let picture = render_once(
1564            serde_json::from_str::<Description>(json).expect("an ordinal is an ordinal"),
1565            Pos { x: 0, y: 0 },
1566            Size {
1567                width: 8,
1568                height: 3,
1569            },
1570        );
1571
1572        assert!(
1573            picture.contains('>'),
1574            "the connector must draw, so the reference resolved through the ordinal: {picture:?}"
1575        );
1576    }
1577
1578    #[test]
1579    fn converting_a_diagram_to_description_and_back_preserves_the_diagram() {
1580        let json = include_str!("../../monospace/assets/demo.json");
1581        let original = parse(json).expect("demo parses");
1582        let description = Description::from_diagram(&original);
1583        let restored = description.into_diagram();
1584        let second_description = Description::from_diagram(&restored);
1585        assert_eq!(second_description, Description::from_diagram(&original));
1586    }
1587
1588    #[test]
1589    fn serialize_produces_two_space_indented_json_ending_in_newline() {
1590        let json = include_str!("../../monospace/assets/demo.json");
1591        let diagram = parse(json).expect("demo parses");
1592        let serialized = super::serialize(&diagram).expect("serializes");
1593
1594        assert!(
1595            serialized.ends_with('\n'),
1596            "serialized JSON has no trailing newline"
1597        );
1598        assert!(serialized.contains("  \"next_id\": 28,\n  \"shapes\": ["));
1599
1600        let read_again = parse(&serialized).expect("reads serialized JSON back");
1601        let description_after = Description::from_diagram(&read_again);
1602        assert_eq!(description_after, Description::from_diagram(&diagram));
1603    }
1604
1605    /// A reference with a non-zero `out` preserves both `offset` and `out` across serialization,
1606    /// and a reference with an `out` of zero omits the `out` field from the JSON output.
1607    #[test]
1608    fn serializing_a_reference_preserves_its_out_field_and_omits_it_when_zero() {
1609        let json = r#"{
1610  "canvas": {
1611    "origin": { "x": 0, "y": 0 },
1612    "size": { "width": 10, "height": 5 }
1613  },
1614  "next_id": 4,
1615  "shapes": [
1616    {
1617      "kind": "box",
1618      "id": 1,
1619      "at": { "x": 0, "y": 0 },
1620      "size": { "width": 4, "height": 3 },
1621      "stroke": "light"
1622    },
1623    {
1624      "kind": "connector",
1625      "id": 2,
1626      "from": {
1627        "at": {
1628          "kind": "reference",
1629          "shape": 1,
1630          "anchor": "right",
1631          "offset": { "dx": 2, "dy": -1 },
1632          "out": 1
1633        },
1634        "terminal": { "kind": "arm" }
1635      },
1636      "to": {
1637        "at": { "kind": "point", "x": 8, "y": 1 },
1638        "leaving": "left",
1639        "terminal": { "kind": "arm" }
1640      },
1641      "stroke": "light"
1642    },
1643    {
1644      "kind": "connector",
1645      "id": 3,
1646      "from": {
1647        "at": {
1648          "kind": "reference",
1649          "shape": 1,
1650          "anchor": "bottom",
1651          "offset": { "dx": 1, "dy": 0 }
1652        },
1653        "terminal": { "kind": "arm" }
1654      },
1655      "to": {
1656        "at": { "kind": "point", "x": 2, "y": 4 },
1657        "leaving": "up",
1658        "terminal": { "kind": "arm" }
1659      },
1660      "stroke": "light"
1661    }
1662  ]
1663}"#;
1664
1665        let diagram = parse(json).expect("valid description parses");
1666        let serialized = super::serialize(&diagram).expect("serializes cleanly");
1667
1668        assert!(
1669            serialized.contains("\"out\": 1"),
1670            "serialized output must preserve non-zero out: {serialized}"
1671        );
1672        assert!(
1673            serialized.contains("\"dx\": 2") && serialized.contains("\"dy\": -1"),
1674            "offset must remain separate without folding out into it: {serialized}"
1675        );
1676
1677        let round_tripped = parse(&serialized).expect("serialized JSON parses back");
1678        assert_eq!(
1679            Description::from_diagram(&round_tripped),
1680            Description::from_diagram(&diagram),
1681            "round trip preserves the description structure"
1682        );
1683
1684        let desc = serde_json::from_str::<serde_json::Value>(&serialized)
1685            .expect("serialized JSON is valid JSON");
1686        let shapes = desc["shapes"].as_array().expect("shapes is array");
1687        let conn2_from = &shapes[1]["from"]["at"];
1688        assert_eq!(conn2_from["out"], 1);
1689        assert_eq!(conn2_from["offset"]["dx"], 2);
1690        assert_eq!(conn2_from["offset"]["dy"], -1);
1691
1692        let conn3_from = &shapes[2]["from"]["at"];
1693        assert!(
1694            conn3_from.get("out").is_none(),
1695            "an out of zero is omitted from the serialized reference JSON"
1696        );
1697        assert!(
1698            shapes[1]["from"].get("leaving").is_none(),
1699            "reference endpoint must omit leaving when serialized"
1700        );
1701        assert_eq!(shapes[1]["to"]["leaving"], "left");
1702    }
1703
1704    /// A reference endpoint derives its leaving direction from its anchor and must not specify
1705    /// `leaving`.
1706    #[test]
1707    fn a_reference_endpoint_without_leaving_derives_its_leaving_direction_from_the_anchor() {
1708        let json = r#"{
1709            "next_id": 3,
1710            "shapes": [
1711                { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1712                  "size": { "width": 4, "height": 3 }, "stroke": "light" },
1713                { "kind": "connector", "id": 2,
1714                  "from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
1715                            "terminal": { "kind": "arm" } },
1716                  "to": { "at": { "kind": "point", "x": 8, "y": 1 }, "leaving": "left",
1717                          "terminal": { "kind": "arm" } },
1718                  "stroke": "light" }
1719            ]
1720        }"#;
1721
1722        let diagram = parse(json).expect("valid reference endpoint without leaving parses");
1723        let connector = diagram.get(ShapeId::new(std::num::NonZeroU16::new(2).unwrap()));
1724        let Some(DiagramShape::Connector { from, .. }) = connector else {
1725            panic!("expected connector");
1726        };
1727        assert_eq!(from.leaving(), Direction::Right);
1728    }
1729
1730    /// A point endpoint requires a leaving direction and is refused by name if `leaving` is missing.
1731    #[test]
1732    fn a_point_endpoint_without_leaving_is_refused_by_name() {
1733        let json = r#"{
1734            "next_id": 2,
1735            "shapes": [
1736                { "kind": "connector", "id": 1,
1737                  "from": { "at": { "kind": "point", "x": 1, "y": 2 },
1738                            "terminal": { "kind": "arm" } },
1739                  "to": { "at": { "kind": "point", "x": 8, "y": 2 }, "leaving": "left",
1740                          "terminal": { "kind": "arm" } },
1741                  "stroke": "light" }
1742            ]
1743        }"#;
1744
1745        let error = refusal(json).to_string();
1746        assert!(
1747            error.starts_with("missing field `leaving`"),
1748            "expected missing field `leaving`, got: {error}"
1749        );
1750    }
1751
1752    /// A reference endpoint specifying a leaving direction is rejected.
1753    #[test]
1754    fn a_reference_endpoint_with_leaving_is_rejected() {
1755        let json = r#"{
1756            "next_id": 3,
1757            "shapes": [
1758                { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1759                  "size": { "width": 4, "height": 3 }, "stroke": "light" },
1760                { "kind": "connector", "id": 2,
1761                  "from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
1762                            "leaving": "right",
1763                            "terminal": { "kind": "arm" } },
1764                  "to": { "at": { "kind": "point", "x": 8, "y": 1 }, "leaving": "left",
1765                          "terminal": { "kind": "arm" } },
1766                  "stroke": "light" }
1767            ]
1768        }"#;
1769
1770        let error = refusal(json).to_string();
1771        assert!(
1772            error.contains(
1773                "a reference endpoint derives its leaving direction from its anchor and must not specify `leaving`"
1774            ),
1775            "expected error refusing leaving on reference endpoint, got: {error}"
1776        );
1777    }
1778
1779    /// Serializing a connector omits `leaving` on a reference endpoint and writes it on a point endpoint.
1780    #[test]
1781    fn serializing_a_connector_omits_leaving_on_a_reference_and_includes_it_on_a_point() {
1782        let json = r#"{
1783            "next_id": 3,
1784            "shapes": [
1785                { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
1786                  "size": { "width": 4, "height": 3 }, "stroke": "light" },
1787                { "kind": "connector", "id": 2,
1788                  "from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
1789                            "terminal": { "kind": "arm" } },
1790                  "to": { "at": { "kind": "point", "x": 8, "y": 1 }, "leaving": "left",
1791                          "terminal": { "kind": "arm" } },
1792                  "stroke": "light" }
1793            ]
1794        }"#;
1795
1796        let diagram = parse(json).expect("valid description parses");
1797        let serialized = super::serialize(&diagram).expect("serializes cleanly");
1798        let desc = serde_json::from_str::<serde_json::Value>(&serialized).expect("valid json");
1799        let shapes = desc["shapes"].as_array().expect("shapes is array");
1800        let from = &shapes[1]["from"];
1801        let to = &shapes[1]["to"];
1802
1803        assert!(
1804            from.get("leaving").is_none(),
1805            "reference endpoint must omit leaving when serialized: {serialized}"
1806        );
1807        assert_eq!(
1808            to["leaving"], "left",
1809            "point endpoint must include leaving when serialized: {serialized}"
1810        );
1811    }
1812}