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}