Skip to main content

open_gpui_canvas/
json_canvas.rs

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}