Skip to main content

mockforge_foundation/state_machine/
visual_layout.rs

1//! Visual layout serialization for state machine graphs
2//!
3//! Provides structures and conversion utilities for storing and loading visual
4//! representations of state machines, compatible with React Flow format.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Visual layout for a state machine graph
10///
11/// Stores the visual representation of a state machine including node positions,
12/// edge routing, and visual metadata. This format is compatible with React Flow.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct VisualLayout {
16    /// Visual nodes representing states
17    pub nodes: Vec<VisualNode>,
18
19    /// Visual edges representing transitions
20    pub edges: Vec<VisualEdge>,
21
22    /// Optional viewport information (zoom, pan)
23    pub viewport: Option<Viewport>,
24}
25
26/// Visual representation of a state node
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub struct VisualNode {
30    /// Unique node identifier (typically matches state name)
31    pub id: String,
32
33    /// Node type: "state", "initial", "sub-scenario", "condition"
34    #[serde(rename = "type")]
35    pub node_type: String,
36
37    /// X position in pixels
38    pub position_x: f64,
39
40    /// Y position in pixels
41    pub position_y: f64,
42
43    /// Node width in pixels
44    #[serde(default = "default_width")]
45    pub width: f64,
46
47    /// Node height in pixels
48    #[serde(default = "default_height")]
49    pub height: f64,
50
51    /// Node label/text
52    pub label: String,
53
54    /// Additional visual properties
55    #[serde(default)]
56    pub style: HashMap<String, serde_json::Value>,
57
58    /// Node data (custom properties)
59    #[serde(default)]
60    pub data: HashMap<String, serde_json::Value>,
61}
62
63/// Visual representation of a transition edge
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct VisualEdge {
67    /// Unique edge identifier
68    pub id: String,
69
70    /// Source node ID
71    pub source: String,
72
73    /// Target node ID
74    pub target: String,
75
76    /// Edge label (condition, probability, etc.)
77    #[serde(default)]
78    pub label: Option<String>,
79
80    /// Edge type: "transition", "conditional", "default"
81    #[serde(rename = "type", default = "default_edge_type")]
82    pub edge_type: String,
83
84    /// Whether this edge is animated (for active transitions)
85    #[serde(default)]
86    pub animated: bool,
87
88    /// Edge style properties
89    #[serde(default)]
90    pub style: HashMap<String, serde_json::Value>,
91
92    /// Edge data (condition expression, probability, etc.)
93    #[serde(default)]
94    pub data: HashMap<String, serde_json::Value>,
95}
96
97/// Viewport information for the visual editor
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100pub struct Viewport {
101    /// Zoom level (1.0 = 100%)
102    pub zoom: f64,
103
104    /// Pan X offset
105    pub x: f64,
106
107    /// Pan Y offset
108    pub y: f64,
109}
110
111impl VisualLayout {
112    /// Create a new empty visual layout
113    pub fn new() -> Self {
114        Self {
115            nodes: Vec::new(),
116            edges: Vec::new(),
117            viewport: None,
118        }
119    }
120
121    /// Add a visual node
122    pub fn add_node(mut self, node: VisualNode) -> Self {
123        self.nodes.push(node);
124        self
125    }
126
127    /// Add a visual edge
128    pub fn add_edge(mut self, edge: VisualEdge) -> Self {
129        self.edges.push(edge);
130        self
131    }
132
133    /// Set viewport
134    pub fn with_viewport(mut self, viewport: Viewport) -> Self {
135        self.viewport = Some(viewport);
136        self
137    }
138
139    /// Convert to React Flow format (JSON)
140    ///
141    /// Returns a JSON object compatible with React Flow's node/edge format.
142    pub fn to_react_flow_json(&self) -> serde_json::Value {
143        serde_json::json!({
144            "nodes": self.nodes.iter().map(|n| {
145                let mut node_data = serde_json::Map::new();
146                node_data.insert("label".to_string(), serde_json::Value::String(n.label.clone()));
147                for (k, v) in &n.data {
148                    node_data.insert(k.clone(), v.clone());
149                }
150                serde_json::json!({
151                    "id": n.id,
152                    "type": n.node_type,
153                    "position": {
154                        "x": n.position_x,
155                        "y": n.position_y
156                    },
157                    "data": node_data,
158                    "style": n.style,
159                    "width": n.width,
160                    "height": n.height
161                })
162            }).collect::<Vec<_>>(),
163            "edges": self.edges.iter().map(|e| {
164                serde_json::json!({
165                    "id": e.id,
166                    "source": e.source,
167                    "target": e.target,
168                    "label": e.label,
169                    "type": e.edge_type,
170                    "animated": e.animated,
171                    "style": e.style,
172                    "data": e.data
173                })
174            }).collect::<Vec<_>>(),
175            "viewport": self.viewport.as_ref().map(|v| {
176                serde_json::json!({
177                    "zoom": v.zoom,
178                    "x": v.x,
179                    "y": v.y
180                })
181            })
182        })
183    }
184
185    /// Create from React Flow format (JSON)
186    ///
187    /// Parses a React Flow JSON object into a VisualLayout.
188    pub fn from_react_flow_json(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
189        let empty_vec: Vec<serde_json::Value> = Vec::new();
190        let nodes = value
191            .get("nodes")
192            .and_then(|n| n.as_array())
193            .unwrap_or(&empty_vec)
194            .iter()
195            .map(|n| {
196                let empty_map = serde_json::Map::new();
197                let position = n.get("position").and_then(|p| p.as_object()).unwrap_or(&empty_map);
198                let data = n.get("data").and_then(|d| d.as_object()).unwrap_or(&empty_map);
199                let style = n.get("style").and_then(|s| s.as_object()).unwrap_or(&empty_map);
200
201                VisualNode {
202                    id: n.get("id").and_then(|i| i.as_str()).unwrap_or("").to_string(),
203                    node_type: n
204                        .get("type")
205                        .and_then(|t| t.as_str())
206                        .unwrap_or("state")
207                        .to_string(),
208                    position_x: position.get("x").and_then(|x| x.as_f64()).unwrap_or(0.0),
209                    position_y: position.get("y").and_then(|y| y.as_f64()).unwrap_or(0.0),
210                    width: n.get("width").and_then(|w| w.as_f64()).unwrap_or(150.0),
211                    height: n.get("height").and_then(|h| h.as_f64()).unwrap_or(40.0),
212                    label: data.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string(),
213                    style: style.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
214                    data: data.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
215                }
216            })
217            .collect();
218
219        let empty_vec: Vec<serde_json::Value> = Vec::new();
220        let edges = value
221            .get("edges")
222            .and_then(|e| e.as_array())
223            .unwrap_or(&empty_vec)
224            .iter()
225            .map(|e| {
226                let empty_map = serde_json::Map::new();
227                let style = e.get("style").and_then(|s| s.as_object()).unwrap_or(&empty_map);
228                let data = e.get("data").and_then(|d| d.as_object()).unwrap_or(&empty_map);
229
230                VisualEdge {
231                    id: e.get("id").and_then(|i| i.as_str()).unwrap_or("").to_string(),
232                    source: e.get("source").and_then(|s| s.as_str()).unwrap_or("").to_string(),
233                    target: e.get("target").and_then(|t| t.as_str()).unwrap_or("").to_string(),
234                    label: e.get("label").and_then(|l| l.as_str()).map(|s| s.to_string()),
235                    edge_type: e
236                        .get("type")
237                        .and_then(|t| t.as_str())
238                        .unwrap_or("default")
239                        .to_string(),
240                    animated: e.get("animated").and_then(|a| a.as_bool()).unwrap_or(false),
241                    style: style.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
242                    data: data.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
243                }
244            })
245            .collect();
246
247        let viewport = value.get("viewport").and_then(|v| serde_json::from_value(v.clone()).ok());
248
249        Ok(Self {
250            nodes,
251            edges,
252            viewport,
253        })
254    }
255}
256
257impl Default for VisualLayout {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263fn default_width() -> f64 {
264    150.0
265}
266
267fn default_height() -> f64 {
268    40.0
269}
270
271fn default_edge_type() -> String {
272    "default".to_string()
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_visual_layout_creation() {
281        let layout = VisualLayout::new()
282            .add_node(VisualNode {
283                id: "state1".to_string(),
284                node_type: "state".to_string(),
285                position_x: 100.0,
286                position_y: 200.0,
287                width: 150.0,
288                height: 40.0,
289                label: "Pending".to_string(),
290                style: HashMap::new(),
291                data: HashMap::new(),
292            })
293            .add_edge(VisualEdge {
294                id: "edge1".to_string(),
295                source: "state1".to_string(),
296                target: "state2".to_string(),
297                label: Some("condition".to_string()),
298                edge_type: "transition".to_string(),
299                animated: false,
300                style: HashMap::new(),
301                data: HashMap::new(),
302            });
303
304        assert_eq!(layout.nodes.len(), 1);
305        assert_eq!(layout.edges.len(), 1);
306    }
307
308    #[test]
309    fn test_react_flow_conversion() {
310        let layout = VisualLayout::new().add_node(VisualNode {
311            id: "state1".to_string(),
312            node_type: "state".to_string(),
313            position_x: 100.0,
314            position_y: 200.0,
315            width: 150.0,
316            height: 40.0,
317            label: "Pending".to_string(),
318            style: HashMap::new(),
319            data: HashMap::new(),
320        });
321
322        let json = layout.to_react_flow_json();
323        assert!(json.get("nodes").is_some());
324        assert!(json.get("edges").is_some());
325
326        // Test round-trip conversion
327        let parsed = VisualLayout::from_react_flow_json(&json).unwrap();
328        assert_eq!(parsed.nodes.len(), 1);
329        assert_eq!(parsed.nodes[0].id, "state1");
330    }
331}