1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
//! Node types and shape definitions.
use serde::{Deserialize, Serialize};
use super::style::NodeStyle;
/// Shape of a node in the diagram.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Shape {
// === Box-style shapes ===
/// Rectangle shape: `[text]`
#[default]
Rectangle,
/// Rounded rectangle shape: `(text)`
Round,
/// Stadium shape: `([text])` (renders as Round)
Stadium,
/// Subroutine shape: `[[text]]` (double vertical borders)
Subroutine,
/// Cylinder/database shape: [(text)] (curved sides)
Cylinder,
/// Document shape (wavy bottom): @{shape: doc}
Document,
/// Stacked documents (fallbacks to Document): @{shape: docs}
Documents,
/// Tagged document (folded corner + wavy bottom): @{shape: tag-doc}
TaggedDocument,
/// Card with folded corner: @{shape: card}
Card,
/// Tagged rectangle (fallbacks to Card): @{shape: tag-rect}
TaggedRect,
// === Angular shapes ===
/// Diamond/decision shape: {text}
Diamond,
/// Hexagon shape: {{text}} (renders as Diamond)
Hexagon,
/// Trapezoid shape: [/text\] (fallbacks to Rectangle)
Trapezoid,
/// Inverse trapezoid shape: [\text/] (fallbacks to Rectangle)
InvTrapezoid,
/// Parallelogram (lean right): @{shape: sl-rect} (fallbacks to Rectangle)
Parallelogram,
/// Inverted parallelogram (lean left): @{shape: inv-parallelogram} (fallbacks to Rectangle)
InvParallelogram,
/// Manual input (sloped top): @{shape: manual} (fallbacks to Rectangle)
ManualInput,
/// Asymmetric/flag shape: >text] (fallbacks to Rectangle)
Asymmetric,
// === Circular shapes ===
/// Circle shape: ((text)) (renders as Round)
Circle,
/// Double circle shape: (((text))) (renders as Round)
DoubleCircle,
/// Small circle (junction point): @{shape: sm-circ} (glyph when unlabeled)
SmallCircle,
/// Framed circle (junction point): @{shape: fr-circ} (glyph when unlabeled)
FramedCircle,
/// Crossed circle (inhibit): @{shape: cross-circ} (glyph when unlabeled)
CrossedCircle,
// === Special shapes ===
/// Text block with no border: @{shape: text}
TextBlock,
/// Fork/join bar: @{shape: fork}
ForkJoin,
/// Note rectangle (for state diagram notes)
NoteRect,
}
/// A node in the flowchart diagram.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Node {
/// Unique identifier for this node.
pub id: String,
/// Display label (defaults to id if not specified).
pub label: String,
/// Shape of the node.
pub shape: Shape,
/// Parent subgraph ID, if this node belongs to a subgraph.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
/// Optional style hints carried with the node.
#[serde(skip_serializing_if = "NodeStyle::is_empty", default)]
pub style: NodeStyle,
/// Original Mermaid class names applied to this node, preserved so the
/// SVG renderer can emit them on the `<g class="node default ...">`
/// wrapper. Order is preserved in application order; duplicates are not
/// stored.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub class_names: Vec<String>,
}
impl Node {
/// Separator marker for multi-line labels (e.g., between class name and members).
/// Rendered as a horizontal rule inside box shapes.
pub const SEPARATOR: &'static str = "---";
/// Create a new node with just an ID (label defaults to ID, shape to Rectangle).
pub fn new(id: impl Into<String>) -> Self {
let id = id.into();
Self {
label: id.clone(),
id,
shape: Shape::default(),
parent: None,
style: NodeStyle::default(),
class_names: Vec::new(),
}
}
/// Set the label for this node.
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
/// Set the shape for this node.
pub fn with_shape(mut self, shape: Shape) -> Self {
self.shape = shape;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_node_parent_default_none() {
let node = Node::new("A");
assert_eq!(node.parent, None);
}
#[test]
fn test_node_parent_set() {
let mut node = Node::new("A");
node.parent = Some("sg1".to_string());
assert_eq!(node.parent, Some("sg1".to_string()));
}
#[test]
fn node_default_has_empty_class_names() {
let node = Node::new("A");
assert!(node.class_names.is_empty());
}
#[test]
fn node_class_names_serialize_when_present() {
let mut node = Node::new("A");
node.class_names = vec!["highlight".to_string(), "thickBorder".to_string()];
let json = serde_json::to_string(&node).unwrap();
assert!(
json.contains(r#""class_names":["highlight","thickBorder"]"#),
"{json}"
);
}
#[test]
fn node_class_names_omitted_when_empty() {
let node = Node::new("A");
let json = serde_json::to_string(&node).unwrap();
assert!(!json.contains("class_names"), "{json}");
}
}