1use crate::{
2 CanvasDocument, CanvasDocumentBuilder, CanvasEdge, CanvasEndpoint, CanvasHandle, CanvasNode,
3 CanvasStyle, CanvasValue, DocumentError, NodeId,
4};
5use indexmap::IndexMap;
6use open_gpui::{Pixels, Point, point, px, size};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::{fmt, str::FromStr};
10use thiserror::Error;
11
12const TEXT_NODE_TYPE: &str = "text";
13const FILE_NODE_TYPE: &str = "file";
14const LINK_NODE_TYPE: &str = "link";
15const GROUP_NODE_TYPE: &str = "group";
16
17const TEXT_FIELD: &str = "text";
18const FILE_FIELD: &str = "file";
19const SUBPATH_FIELD: &str = "subpath";
20const URL_FIELD: &str = "url";
21const LABEL_FIELD: &str = "label";
22const BACKGROUND_FIELD: &str = "background";
23const BACKGROUND_STYLE_FIELD: &str = "backgroundStyle";
24const FROM_END_FIELD: &str = "fromEnd";
25const TO_END_FIELD: &str = "toEnd";
26
27#[derive(Debug, Error)]
28pub enum JsonCanvasError {
29 #[error(transparent)]
30 Json(#[from] serde_json::Error),
31 #[error(transparent)]
32 Document(#[from] DocumentError),
33 #[error("node `{node_id}` is missing required JSON Canvas field `{field}` for type `{kind}`")]
34 MissingNodeField {
35 node_id: String,
36 kind: String,
37 field: &'static str,
38 },
39 #[error("node `{node_id}` has unsupported JSON Canvas type `{kind}`")]
40 UnsupportedNodeKind { node_id: String, kind: String },
41 #[error("node `{node_id}` has invalid JSON Canvas size")]
42 InvalidNodeSize { node_id: String },
43 #[error("record `{id}` has invalid `{field}` geometry")]
44 InvalidGeometry { id: String, field: &'static str },
45}
46
47#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
48pub struct JsonCanvas {
49 #[serde(default)]
50 pub nodes: Vec<JsonCanvasNode>,
51 #[serde(default)]
52 pub edges: Vec<JsonCanvasEdge>,
53}
54
55impl JsonCanvas {
56 pub fn parse(input: &str) -> Result<Self, JsonCanvasError> {
57 Ok(serde_json::from_str(input)?)
58 }
59
60 pub fn to_string_pretty(&self) -> Result<String, JsonCanvasError> {
61 Ok(serde_json::to_string_pretty(self)?)
62 }
63
64 pub fn into_document(self) -> Result<CanvasDocument, JsonCanvasError> {
65 self.try_into()
66 }
67
68 pub fn from_document(document: &CanvasDocument) -> Result<Self, JsonCanvasError> {
69 document.try_into()
70 }
71}
72
73impl FromStr for JsonCanvas {
74 type Err = JsonCanvasError;
75
76 fn from_str(input: &str) -> Result<Self, Self::Err> {
77 Self::parse(input)
78 }
79}
80
81impl TryFrom<JsonCanvas> for CanvasDocument {
82 type Error = JsonCanvasError;
83
84 fn try_from(canvas: JsonCanvas) -> Result<Self, Self::Error> {
85 let mut nodes = IndexMap::new();
86
87 for (index, node) in canvas.nodes.into_iter().enumerate() {
88 let id = NodeId::from(node.id.clone());
89 if nodes.contains_key(&id) {
90 return Err(DocumentError::DuplicateNode(id).into());
91 }
92 let canvas_node = node.into_canvas_node(index as i32)?;
93 nodes.insert(id, canvas_node);
94 }
95
96 for edge in &canvas.edges {
97 if let Some(side) = edge.from_side {
98 ensure_side_handle(&mut nodes, &edge.from_node, side);
99 }
100 if let Some(side) = edge.to_side {
101 ensure_side_handle(&mut nodes, &edge.to_node, side);
102 }
103 }
104
105 let mut builder = CanvasDocumentBuilder::new();
106 for (_, node) in nodes {
107 builder.add_node(node)?;
108 }
109
110 for edge in canvas.edges {
111 builder.add_edge(edge.into_canvas_edge())?;
112 }
113
114 builder.build().map_err(Into::into)
115 }
116}
117
118impl TryFrom<&CanvasDocument> for JsonCanvas {
119 type Error = JsonCanvasError;
120
121 fn try_from(document: &CanvasDocument) -> Result<Self, Self::Error> {
122 let mut nodes = document.nodes().collect::<Vec<_>>();
123 nodes.sort_by(|a, b| a.z_index.cmp(&b.z_index));
124
125 Ok(Self {
126 nodes: nodes
127 .into_iter()
128 .map(JsonCanvasNode::from_canvas_node)
129 .collect::<Result<Vec<_>, _>>()?,
130 edges: document
131 .edges()
132 .map(|edge| JsonCanvasEdge::from_canvas_edge(document, edge))
133 .collect::<Result<Vec<_>, _>>()?,
134 })
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub struct JsonCanvasNode {
140 pub id: String,
141 #[serde(rename = "type")]
142 pub kind: String,
143 pub x: i64,
144 pub y: i64,
145 pub width: i64,
146 pub height: i64,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub color: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub text: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub file: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub subpath: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub url: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub label: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub background: Option<String>,
161 #[serde(rename = "backgroundStyle")]
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub background_style: Option<String>,
164 #[serde(flatten)]
165 pub extra: CanvasValue,
166}
167
168impl JsonCanvasNode {
169 fn into_canvas_node(self, z_index: i32) -> Result<CanvasNode, JsonCanvasError> {
170 self.validate_required_fields()?;
171
172 if self.width < 0 || self.height < 0 {
173 return Err(JsonCanvasError::InvalidNodeSize { node_id: self.id });
174 }
175
176 let mut data = self.extra;
177 insert_string(&mut data, TEXT_FIELD, self.text);
178 insert_string(&mut data, FILE_FIELD, self.file);
179 insert_string(&mut data, SUBPATH_FIELD, self.subpath);
180 insert_string(&mut data, URL_FIELD, self.url);
181 insert_string(&mut data, LABEL_FIELD, self.label);
182 insert_string(&mut data, BACKGROUND_FIELD, self.background);
183 insert_string(&mut data, BACKGROUND_STYLE_FIELD, self.background_style);
184
185 let mut node = CanvasNode::new(
186 self.id,
187 point(px(self.x as f32), px(self.y as f32)),
188 size(px(self.width as f32), px(self.height as f32)),
189 );
190 node.kind = self.kind;
191 node.z_index = z_index;
192 node.data = data;
193 node.style = CanvasStyle {
194 fill: self.color,
195 ..CanvasStyle::default()
196 };
197 Ok(node)
198 }
199
200 fn from_canvas_node(node: &CanvasNode) -> Result<Self, JsonCanvasError> {
201 let mut extra = node.data.clone();
202 let text = remove_string(&mut extra, TEXT_FIELD);
203 let file = remove_string(&mut extra, FILE_FIELD);
204 let subpath = remove_string(&mut extra, SUBPATH_FIELD);
205 let url = remove_string(&mut extra, URL_FIELD);
206 let label = remove_string(&mut extra, LABEL_FIELD);
207 let background = remove_string(&mut extra, BACKGROUND_FIELD);
208 let background_style = remove_string(&mut extra, BACKGROUND_STYLE_FIELD);
209
210 let json_node = Self {
211 id: node.id.as_str().to_string(),
212 kind: node.kind.clone(),
213 x: pixel_to_i64(&node.id, "x", node.position.x)?,
214 y: pixel_to_i64(&node.id, "y", node.position.y)?,
215 width: nonnegative_pixel_to_i64(&node.id, "width", node.size.width)?,
216 height: nonnegative_pixel_to_i64(&node.id, "height", node.size.height)?,
217 color: node.style.fill.clone(),
218 text,
219 file,
220 subpath,
221 url,
222 label,
223 background,
224 background_style,
225 extra,
226 };
227 json_node.validate_required_fields()?;
228 Ok(json_node)
229 }
230
231 fn validate_required_fields(&self) -> Result<(), JsonCanvasError> {
232 match self.kind.as_str() {
233 TEXT_NODE_TYPE if self.text.is_none() => self.missing_field(TEXT_FIELD),
234 FILE_NODE_TYPE if self.file.is_none() => self.missing_field(FILE_FIELD),
235 LINK_NODE_TYPE if self.url.is_none() => self.missing_field(URL_FIELD),
236 TEXT_NODE_TYPE | FILE_NODE_TYPE | LINK_NODE_TYPE | GROUP_NODE_TYPE => Ok(()),
237 _ => Err(JsonCanvasError::UnsupportedNodeKind {
238 node_id: self.id.clone(),
239 kind: self.kind.clone(),
240 }),
241 }
242 }
243
244 fn missing_field(&self, field: &'static str) -> Result<(), JsonCanvasError> {
245 Err(JsonCanvasError::MissingNodeField {
246 node_id: self.id.clone(),
247 kind: self.kind.clone(),
248 field,
249 })
250 }
251}
252
253#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum JsonCanvasSide {
256 Top,
257 Right,
258 Bottom,
259 Left,
260}
261
262impl JsonCanvasSide {
263 pub fn as_str(self) -> &'static str {
264 match self {
265 Self::Top => "top",
266 Self::Right => "right",
267 Self::Bottom => "bottom",
268 Self::Left => "left",
269 }
270 }
271
272 pub fn handle_id(self) -> String {
273 format!("json_canvas:{}", self.as_str())
274 }
275
276 fn handle_position(self, node: &CanvasNode) -> Point<Pixels> {
277 match self {
278 Self::Top => point(node.size.width * 0.5, Pixels::ZERO),
279 Self::Right => point(node.size.width, node.size.height * 0.5),
280 Self::Bottom => point(node.size.width * 0.5, node.size.height),
281 Self::Left => point(Pixels::ZERO, node.size.height * 0.5),
282 }
283 }
284}
285
286impl fmt::Display for JsonCanvasSide {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 f.write_str(self.as_str())
289 }
290}
291
292#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum JsonCanvasEndpointShape {
295 None,
296 Arrow,
297}
298
299impl JsonCanvasEndpointShape {
300 pub fn as_str(self) -> &'static str {
301 match self {
302 Self::None => "none",
303 Self::Arrow => "arrow",
304 }
305 }
306}
307
308impl fmt::Display for JsonCanvasEndpointShape {
309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310 f.write_str(self.as_str())
311 }
312}
313
314#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
315pub struct JsonCanvasEdge {
316 pub id: String,
317 #[serde(rename = "fromNode")]
318 pub from_node: String,
319 #[serde(rename = "fromSide")]
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub from_side: Option<JsonCanvasSide>,
322 #[serde(rename = "fromEnd")]
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub from_end: Option<JsonCanvasEndpointShape>,
325 #[serde(rename = "toNode")]
326 pub to_node: String,
327 #[serde(rename = "toSide")]
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub to_side: Option<JsonCanvasSide>,
330 #[serde(rename = "toEnd")]
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub to_end: Option<JsonCanvasEndpointShape>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub color: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub label: Option<String>,
337 #[serde(flatten)]
338 pub extra: CanvasValue,
339}
340
341impl JsonCanvasEdge {
342 fn into_canvas_edge(self) -> CanvasEdge {
343 let mut data = self.extra;
344 insert_string(
345 &mut data,
346 FROM_END_FIELD,
347 self.from_end.map(|shape| shape.as_str().to_string()),
348 );
349 insert_string(
350 &mut data,
351 TO_END_FIELD,
352 self.to_end.map(|shape| shape.as_str().to_string()),
353 );
354 insert_string(&mut data, LABEL_FIELD, self.label);
355
356 let mut edge = CanvasEdge::new(
357 self.id,
358 CanvasEndpoint {
359 node_id: NodeId::from(self.from_node),
360 handle_id: self.from_side.map(|side| side.handle_id().into()),
361 },
362 CanvasEndpoint {
363 node_id: NodeId::from(self.to_node),
364 handle_id: self.to_side.map(|side| side.handle_id().into()),
365 },
366 );
367 edge.data = data;
368 edge.style = CanvasStyle {
369 stroke: self.color,
370 ..CanvasStyle::default()
371 };
372 edge
373 }
374
375 fn from_canvas_edge(
376 document: &CanvasDocument,
377 edge: &CanvasEdge,
378 ) -> Result<Self, JsonCanvasError> {
379 let mut extra = edge.data.clone();
380 let from_end = remove_endpoint_shape(&mut extra, FROM_END_FIELD);
381 let to_end = remove_endpoint_shape(&mut extra, TO_END_FIELD);
382 let label = remove_string(&mut extra, LABEL_FIELD);
383
384 Ok(Self {
385 id: edge.id.as_str().to_string(),
386 from_node: edge.source.node_id.as_str().to_string(),
387 from_side: endpoint_side(document, &edge.source),
388 from_end,
389 to_node: edge.target.node_id.as_str().to_string(),
390 to_side: endpoint_side(document, &edge.target),
391 to_end,
392 color: edge.style.stroke.clone(),
393 label,
394 extra,
395 })
396 }
397}
398
399pub fn document_from_json_canvas_str(input: &str) -> Result<CanvasDocument, JsonCanvasError> {
400 JsonCanvas::parse(input)?.into_document()
401}
402
403pub fn document_to_json_canvas_string(
404 document: &CanvasDocument,
405) -> Result<String, JsonCanvasError> {
406 JsonCanvas::from_document(document)?.to_string_pretty()
407}
408
409fn ensure_side_handle(
410 nodes: &mut IndexMap<NodeId, CanvasNode>,
411 node_id: &str,
412 side: JsonCanvasSide,
413) {
414 let Some(node) = nodes.get_mut(&NodeId::from(node_id)) else {
415 return;
416 };
417 let handle_id = side.handle_id();
418 if node
419 .handles
420 .iter()
421 .any(|handle| handle.id.as_str() == handle_id)
422 {
423 return;
424 }
425
426 node.handles
427 .push(CanvasHandle::new(handle_id, side.handle_position(node)));
428}
429
430fn endpoint_side(document: &CanvasDocument, endpoint: &CanvasEndpoint) -> Option<JsonCanvasSide> {
431 let handle_id = endpoint.handle_id.as_ref()?;
432 side_from_handle_id(handle_id.as_str()).or_else(|| {
433 let node = document.node(&endpoint.node_id)?;
434 let handle = node.handle(Some(handle_id))?;
435 side_from_handle_position(node, handle)
436 })
437}
438
439fn side_from_handle_id(handle_id: &str) -> Option<JsonCanvasSide> {
440 match handle_id.strip_prefix("json_canvas:")? {
441 "top" => Some(JsonCanvasSide::Top),
442 "right" => Some(JsonCanvasSide::Right),
443 "bottom" => Some(JsonCanvasSide::Bottom),
444 "left" => Some(JsonCanvasSide::Left),
445 _ => None,
446 }
447}
448
449fn side_from_handle_position(node: &CanvasNode, handle: &CanvasHandle) -> Option<JsonCanvasSide> {
450 let top = handle.position.y.abs();
451 let right = (node.size.width - handle.position.x).abs();
452 let bottom = (node.size.height - handle.position.y).abs();
453 let left = handle.position.x.abs();
454 let mut sides = [
455 (top, JsonCanvasSide::Top),
456 (right, JsonCanvasSide::Right),
457 (bottom, JsonCanvasSide::Bottom),
458 (left, JsonCanvasSide::Left),
459 ];
460 sides.sort_by(|(a, _), (b, _)| a.cmp(b));
461 Some(sides[0].1)
462}
463
464fn insert_string(data: &mut CanvasValue, key: &'static str, value: Option<String>) {
465 if let Some(value) = value {
466 data.insert(key.to_string(), Value::String(value));
467 }
468}
469
470fn remove_string(data: &mut CanvasValue, key: &'static str) -> Option<String> {
471 match data.remove(key) {
472 Some(Value::String(value)) => Some(value),
473 Some(value) => {
474 data.insert(key.to_string(), value);
475 None
476 }
477 None => None,
478 }
479}
480
481fn remove_endpoint_shape(
482 data: &mut CanvasValue,
483 key: &'static str,
484) -> Option<JsonCanvasEndpointShape> {
485 match data.remove(key) {
486 Some(Value::String(value)) if value == "none" => Some(JsonCanvasEndpointShape::None),
487 Some(Value::String(value)) if value == "arrow" => Some(JsonCanvasEndpointShape::Arrow),
488 Some(value) => {
489 data.insert(key.to_string(), value);
490 None
491 }
492 None => None,
493 }
494}
495
496fn pixel_to_i64(
497 id: impl fmt::Display,
498 field: &'static str,
499 value: Pixels,
500) -> Result<i64, JsonCanvasError> {
501 if !value.as_f32().is_finite() {
502 return Err(JsonCanvasError::InvalidGeometry {
503 id: id.to_string(),
504 field,
505 });
506 }
507
508 Ok(value.as_f32().round() as i64)
509}
510
511fn nonnegative_pixel_to_i64(
512 id: impl fmt::Display,
513 field: &'static str,
514 value: Pixels,
515) -> Result<i64, JsonCanvasError> {
516 if !value.as_f32().is_finite() || value < Pixels::ZERO {
517 return Err(JsonCanvasError::InvalidGeometry {
518 id: id.to_string(),
519 field,
520 });
521 }
522
523 Ok(value.as_f32().round() as i64)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::test_support::document_fixture;
530 use crate::{EdgeId, HandleId};
531 use open_gpui::{point, px, size};
532 use serde_json::json;
533
534 #[test]
535 fn imports_json_canvas_text_file_group_and_edges() {
536 let input = r##"{
537 "nodes": [
538 {
539 "id": "note",
540 "type": "text",
541 "x": -10,
542 "y": 20,
543 "width": 200,
544 "height": 120,
545 "color": "#ff0000",
546 "text": "Hello"
547 },
548 {
549 "id": "file",
550 "type": "file",
551 "x": 300,
552 "y": 20,
553 "width": 180,
554 "height": 90,
555 "file": "docs/spec.md",
556 "subpath": "#section"
557 },
558 {
559 "id": "group",
560 "type": "group",
561 "x": -40,
562 "y": 0,
563 "width": 560,
564 "height": 180,
565 "label": "Cluster"
566 }
567 ],
568 "edges": [
569 {
570 "id": "edge",
571 "fromNode": "note",
572 "fromSide": "right",
573 "fromEnd": "none",
574 "toNode": "file",
575 "toSide": "left",
576 "toEnd": "arrow",
577 "color": "1",
578 "label": "reads"
579 }
580 ]
581 }"##;
582
583 let document = document_from_json_canvas_str(input).unwrap();
584
585 let note = document.node(&NodeId::from("note")).unwrap();
586 assert_eq!(note.kind, TEXT_NODE_TYPE);
587 assert_eq!(note.position, point(px(-10.0), px(20.0)));
588 assert_eq!(note.size, size(px(200.0), px(120.0)));
589 assert_eq!(note.z_index, 0);
590 assert_eq!(note.style.fill.as_deref(), Some("#ff0000"));
591 assert_eq!(note.data.get(TEXT_FIELD), Some(&json!("Hello")));
592 assert!(
593 note.handle(Some(&HandleId::from("json_canvas:right")))
594 .is_some()
595 );
596
597 let file = document.node(&NodeId::from("file")).unwrap();
598 assert_eq!(file.data.get(FILE_FIELD), Some(&json!("docs/spec.md")));
599 assert_eq!(file.data.get(SUBPATH_FIELD), Some(&json!("#section")));
600 assert!(
601 file.handle(Some(&HandleId::from("json_canvas:left")))
602 .is_some()
603 );
604
605 let edge = document.edge(&EdgeId::from("edge")).unwrap();
606 assert_eq!(edge.source.node_id, NodeId::from("note"));
607 assert_eq!(
608 edge.source.handle_id,
609 Some(HandleId::from("json_canvas:right"))
610 );
611 assert_eq!(edge.target.node_id, NodeId::from("file"));
612 assert_eq!(
613 edge.target.handle_id,
614 Some(HandleId::from("json_canvas:left"))
615 );
616 assert_eq!(edge.style.stroke.as_deref(), Some("1"));
617 assert_eq!(edge.data.get(LABEL_FIELD), Some(&json!("reads")));
618 assert_eq!(edge.data.get(FROM_END_FIELD), Some(&json!("none")));
619 assert_eq!(edge.data.get(TO_END_FIELD), Some(&json!("arrow")));
620 }
621
622 #[test]
623 fn preserves_unknown_json_canvas_extra_payload() {
624 let input = r##"{
625 "nodes": [
626 {
627 "id": "note",
628 "type": "text",
629 "x": 0,
630 "y": 0,
631 "width": 120,
632 "height": 80,
633 "text": "Hello",
634 "customNode": { "priority": 2 }
635 },
636 {
637 "id": "target",
638 "type": "text",
639 "x": 200,
640 "y": 0,
641 "width": 120,
642 "height": 80,
643 "text": "Target"
644 }
645 ],
646 "edges": [
647 {
648 "id": "edge",
649 "fromNode": "note",
650 "toNode": "target",
651 "label": "relates",
652 "customEdge": ["kept"]
653 }
654 ]
655 }"##;
656
657 let document = document_from_json_canvas_str(input).unwrap();
658
659 assert_eq!(
660 document
661 .node(&NodeId::from("note"))
662 .unwrap()
663 .data
664 .get("customNode"),
665 Some(&json!({ "priority": 2 }))
666 );
667 assert_eq!(
668 document
669 .edge(&EdgeId::from("edge"))
670 .unwrap()
671 .data
672 .get("customEdge"),
673 Some(&json!(["kept"]))
674 );
675
676 let exported = JsonCanvas::from_document(&document).unwrap();
677 let note = exported
678 .nodes
679 .iter()
680 .find(|node| node.id == "note")
681 .unwrap();
682 let edge = exported
683 .edges
684 .iter()
685 .find(|edge| edge.id == "edge")
686 .unwrap();
687
688 assert_eq!(
689 note.extra.get("customNode"),
690 Some(&json!({ "priority": 2 }))
691 );
692 assert_eq!(edge.extra.get("customEdge"), Some(&json!(["kept"])));
693 }
694
695 #[test]
696 fn exports_json_canvas_nodes_by_z_index() {
697 let mut front = CanvasNode::new(
698 "front",
699 point(px(100.0), px(0.0)),
700 size(px(120.0), px(80.0)),
701 );
702 front.kind = TEXT_NODE_TYPE.to_string();
703 front.z_index = 10;
704 front.data.insert(TEXT_FIELD.to_string(), json!("Front"));
705 let mut back = CanvasNode::new("back", point(px(0.0), px(0.0)), size(px(120.0), px(80.0)));
706 back.kind = GROUP_NODE_TYPE.to_string();
707 back.z_index = -1;
708 back.data.insert(LABEL_FIELD.to_string(), json!("Back"));
709
710 let document = document_fixture().node(front).node(back).build();
711
712 let json_canvas = JsonCanvas::from_document(&document).unwrap();
713
714 assert_eq!(json_canvas.nodes[0].id, "back");
715 assert_eq!(json_canvas.nodes[0].label.as_deref(), Some("Back"));
716 assert_eq!(json_canvas.nodes[1].id, "front");
717 assert_eq!(json_canvas.nodes[1].text.as_deref(), Some("Front"));
718 }
719
720 #[test]
721 fn exports_json_canvas_edge_sides_from_handles() {
722 let mut source = CanvasNode::new(
723 "source",
724 point(px(0.0), px(0.0)),
725 size(px(100.0), px(100.0)),
726 );
727 source.kind = TEXT_NODE_TYPE.to_string();
728 source.data.insert(TEXT_FIELD.to_string(), json!("Source"));
729 source.handles.push(CanvasHandle::new(
730 "json_canvas:right",
731 point(px(100.0), px(50.0)),
732 ));
733 let mut target = CanvasNode::new(
734 "target",
735 point(px(200.0), px(0.0)),
736 size(px(100.0), px(100.0)),
737 );
738 target.kind = TEXT_NODE_TYPE.to_string();
739 target.data.insert(TEXT_FIELD.to_string(), json!("Target"));
740 target.handles.push(CanvasHandle::new(
741 "json_canvas:left",
742 point(px(0.0), px(50.0)),
743 ));
744
745 let mut edge = CanvasEdge::new(
746 "edge",
747 CanvasEndpoint::new("source", Some("json_canvas:right")),
748 CanvasEndpoint::new("target", Some("json_canvas:left")),
749 );
750 edge.style.stroke = Some("2".to_string());
751 edge.data
752 .insert(LABEL_FIELD.to_string(), json!("depends on"));
753 edge.data.insert(
754 TO_END_FIELD.to_string(),
755 json!(JsonCanvasEndpointShape::Arrow.as_str()),
756 );
757 let document = document_fixture()
758 .node(source)
759 .node(target)
760 .edge(edge)
761 .build();
762
763 let json_canvas = JsonCanvas::from_document(&document).unwrap();
764 let edge = &json_canvas.edges[0];
765
766 assert_eq!(edge.from_side, Some(JsonCanvasSide::Right));
767 assert_eq!(edge.to_side, Some(JsonCanvasSide::Left));
768 assert_eq!(edge.to_end, Some(JsonCanvasEndpointShape::Arrow));
769 assert_eq!(edge.color.as_deref(), Some("2"));
770 assert_eq!(edge.label.as_deref(), Some("depends on"));
771 }
772
773 #[test]
774 fn rejects_missing_required_node_fields() {
775 let input = r#"{
776 "nodes": [
777 { "id": "note", "type": "text", "x": 0, "y": 0, "width": 100, "height": 100 }
778 ]
779 }"#;
780
781 let err = document_from_json_canvas_str(input).unwrap_err();
782
783 assert!(matches!(
784 err,
785 JsonCanvasError::MissingNodeField {
786 node_id,
787 kind,
788 field: TEXT_FIELD
789 } if node_id == "note" && kind == TEXT_NODE_TYPE
790 ));
791 }
792
793 #[test]
794 fn rejects_unsupported_node_types() {
795 let input = r#"{
796 "nodes": [
797 { "id": "shape", "type": "shape", "x": 0, "y": 0, "width": 100, "height": 100 }
798 ]
799 }"#;
800
801 let err = document_from_json_canvas_str(input).unwrap_err();
802
803 assert!(matches!(
804 err,
805 JsonCanvasError::UnsupportedNodeKind { node_id, kind }
806 if node_id == "shape" && kind == "shape"
807 ));
808 }
809
810 #[test]
811 fn rejects_duplicate_node_ids_on_import() {
812 let input = r#"{
813 "nodes": [
814 { "id": "note", "type": "text", "x": 0, "y": 0, "width": 100, "height": 100, "text": "A" },
815 { "id": "note", "type": "text", "x": 120, "y": 0, "width": 100, "height": 100, "text": "B" }
816 ]
817 }"#;
818
819 let err = document_from_json_canvas_str(input).unwrap_err();
820
821 assert!(matches!(
822 err,
823 JsonCanvasError::Document(DocumentError::DuplicateNode(id)) if id == NodeId::from("note")
824 ));
825 }
826
827 #[test]
828 fn rejects_edges_with_missing_nodes_on_import() {
829 let input = r#"{
830 "nodes": [
831 { "id": "note", "type": "text", "x": 0, "y": 0, "width": 100, "height": 100, "text": "A" }
832 ],
833 "edges": [
834 { "id": "edge", "fromNode": "note", "toNode": "missing" }
835 ]
836 }"#;
837
838 let err = document_from_json_canvas_str(input).unwrap_err();
839
840 assert!(matches!(
841 err,
842 JsonCanvasError::Document(DocumentError::MissingNode(id)) if id == NodeId::from("missing")
843 ));
844 }
845
846 #[test]
847 fn rejects_exporting_incomplete_json_canvas_nodes() {
848 let mut node = CanvasNode::new("note", point(px(0.0), px(0.0)), size(px(100.0), px(100.0)));
849 node.kind = TEXT_NODE_TYPE.to_string();
850 let document = document_fixture().node(node).build();
851
852 let err = JsonCanvas::from_document(&document).unwrap_err();
853
854 assert!(matches!(
855 err,
856 JsonCanvasError::MissingNodeField {
857 node_id,
858 kind,
859 field: TEXT_FIELD
860 } if node_id == "note" && kind == TEXT_NODE_TYPE
861 ));
862 }
863}