Skip to main content

dendryform_core/
node.rs

1//! Node type and consuming builder.
2
3use serde::{Deserialize, Serialize};
4
5use crate::color::Color;
6use crate::error::ValidationError;
7use crate::id::NodeId;
8use crate::kind::NodeKind;
9use crate::metadata::Metadata;
10use crate::tech::Tech;
11
12/// An individual card within a tier.
13///
14/// Nodes are the primary visual elements of a dendryform diagram. Each node
15/// has a colored top-bar, an icon, title, description, and optional technology
16/// badges.
17///
18/// Construct via [`NodeBuilder`] or deserialize from YAML/JSON.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub struct Node {
22    id: NodeId,
23    kind: NodeKind,
24    color: Color,
25    icon: String,
26    title: String,
27    description: String,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    tech: Vec<Tech>,
30    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
31    metadata: Metadata,
32}
33
34impl Node {
35    /// Creates a new [`NodeBuilder`].
36    pub fn builder() -> NodeBuilder {
37        NodeBuilder::default()
38    }
39
40    /// Returns the node's unique identifier.
41    pub fn id(&self) -> &NodeId {
42        &self.id
43    }
44
45    /// Returns the semantic kind.
46    pub fn kind(&self) -> NodeKind {
47        self.kind
48    }
49
50    /// Returns the accent color.
51    pub fn color(&self) -> Color {
52        self.color
53    }
54
55    /// Returns the icon character.
56    pub fn icon(&self) -> &str {
57        &self.icon
58    }
59
60    /// Returns the title.
61    pub fn title(&self) -> &str {
62        &self.title
63    }
64
65    /// Returns the description.
66    pub fn description(&self) -> &str {
67        &self.description
68    }
69
70    /// Returns the technology badges.
71    pub fn tech(&self) -> &[Tech] {
72        &self.tech
73    }
74
75    /// Returns the extensibility metadata.
76    pub fn metadata(&self) -> &Metadata {
77        &self.metadata
78    }
79}
80
81/// Consuming builder for [`Node`] (AP-11).
82///
83/// All required fields must be set before calling [`build`](NodeBuilder::build).
84///
85/// # Examples
86///
87/// ```
88/// use dendryform_core::{Node, NodeId, NodeKind, Color, Tech};
89///
90/// let node = Node::builder()
91///     .id(NodeId::new("web-app").unwrap())
92///     .kind(NodeKind::System)
93///     .color(Color::Blue)
94///     .icon("◇")
95///     .title("Web Application")
96///     .description("Browser-based frontend")
97///     .tech(vec![Tech::new("React"), Tech::new("TypeScript")])
98///     .build()
99///     .unwrap();
100///
101/// assert_eq!(node.id().as_str(), "web-app");
102/// ```
103#[derive(Debug, Default)]
104pub struct NodeBuilder {
105    id: Option<NodeId>,
106    kind: Option<NodeKind>,
107    color: Option<Color>,
108    icon: Option<String>,
109    title: Option<String>,
110    description: Option<String>,
111    tech: Vec<Tech>,
112    metadata: Metadata,
113}
114
115impl NodeBuilder {
116    /// Sets the node ID.
117    pub fn id(mut self, id: NodeId) -> Self {
118        self.id = Some(id);
119        self
120    }
121
122    /// Sets the semantic kind.
123    pub fn kind(mut self, kind: NodeKind) -> Self {
124        self.kind = Some(kind);
125        self
126    }
127
128    /// Sets the accent color.
129    pub fn color(mut self, color: Color) -> Self {
130        self.color = Some(color);
131        self
132    }
133
134    /// Sets the icon character.
135    pub fn icon(mut self, icon: &str) -> Self {
136        self.icon = Some(icon.to_owned());
137        self
138    }
139
140    /// Sets the title.
141    pub fn title(mut self, title: &str) -> Self {
142        self.title = Some(title.to_owned());
143        self
144    }
145
146    /// Sets the description.
147    pub fn description(mut self, description: &str) -> Self {
148        self.description = Some(description.to_owned());
149        self
150    }
151
152    /// Sets the technology badges.
153    pub fn tech(mut self, tech: Vec<Tech>) -> Self {
154        self.tech = tech;
155        self
156    }
157
158    /// Sets the extensibility metadata.
159    pub fn metadata(mut self, metadata: Metadata) -> Self {
160        self.metadata = metadata;
161        self
162    }
163
164    /// Builds the [`Node`], returning an error if any required field is missing.
165    pub fn build(self) -> Result<Node, ValidationError> {
166        Ok(Node {
167            id: self
168                .id
169                .ok_or(ValidationError::MissingField { field: "id" })?,
170            kind: self
171                .kind
172                .ok_or(ValidationError::MissingField { field: "kind" })?,
173            color: self
174                .color
175                .ok_or(ValidationError::MissingField { field: "color" })?,
176            icon: self
177                .icon
178                .ok_or(ValidationError::MissingField { field: "icon" })?,
179            title: self
180                .title
181                .ok_or(ValidationError::MissingField { field: "title" })?,
182            description: self.description.ok_or(ValidationError::MissingField {
183                field: "description",
184            })?,
185            tech: self.tech,
186            metadata: self.metadata,
187        })
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn sample_node() -> Node {
196        Node::builder()
197            .id(NodeId::new("web-app").unwrap())
198            .kind(NodeKind::System)
199            .color(Color::Blue)
200            .icon("◇")
201            .title("Web Application")
202            .description("Browser-based frontend")
203            .tech(vec![Tech::new("React")])
204            .build()
205            .unwrap()
206    }
207
208    #[test]
209    fn test_builder_all_fields() {
210        let node = sample_node();
211        assert_eq!(node.id().as_str(), "web-app");
212        assert_eq!(node.kind(), NodeKind::System);
213        assert_eq!(node.color(), Color::Blue);
214        assert_eq!(node.icon(), "◇");
215        assert_eq!(node.title(), "Web Application");
216        assert_eq!(node.description(), "Browser-based frontend");
217        assert_eq!(node.tech().len(), 1);
218    }
219
220    #[test]
221    fn test_builder_missing_id() {
222        let result = Node::builder()
223            .kind(NodeKind::System)
224            .color(Color::Blue)
225            .icon("x")
226            .title("Test")
227            .description("Test")
228            .build();
229        assert!(matches!(
230            result,
231            Err(ValidationError::MissingField { field: "id" })
232        ));
233    }
234
235    #[test]
236    fn test_builder_missing_kind() {
237        let result = Node::builder()
238            .id(NodeId::new("a").unwrap())
239            .color(Color::Blue)
240            .icon("x")
241            .title("Test")
242            .description("Test")
243            .build();
244        assert!(matches!(
245            result,
246            Err(ValidationError::MissingField { field: "kind" })
247        ));
248    }
249
250    #[test]
251    fn test_builder_missing_color() {
252        let result = Node::builder()
253            .id(NodeId::new("a").unwrap())
254            .kind(NodeKind::System)
255            .icon("x")
256            .title("Test")
257            .description("Test")
258            .build();
259        assert!(matches!(
260            result,
261            Err(ValidationError::MissingField { field: "color" })
262        ));
263    }
264
265    #[test]
266    fn test_builder_missing_icon() {
267        let result = Node::builder()
268            .id(NodeId::new("a").unwrap())
269            .kind(NodeKind::System)
270            .color(Color::Blue)
271            .title("Test")
272            .description("Test")
273            .build();
274        assert!(matches!(
275            result,
276            Err(ValidationError::MissingField { field: "icon" })
277        ));
278    }
279
280    #[test]
281    fn test_builder_missing_title() {
282        let result = Node::builder()
283            .id(NodeId::new("a").unwrap())
284            .kind(NodeKind::System)
285            .color(Color::Blue)
286            .icon("x")
287            .description("Test")
288            .build();
289        assert!(matches!(
290            result,
291            Err(ValidationError::MissingField { field: "title" })
292        ));
293    }
294
295    #[test]
296    fn test_builder_missing_description() {
297        let result = Node::builder()
298            .id(NodeId::new("a").unwrap())
299            .kind(NodeKind::System)
300            .color(Color::Blue)
301            .icon("x")
302            .title("Test")
303            .build();
304        assert!(matches!(
305            result,
306            Err(ValidationError::MissingField {
307                field: "description"
308            })
309        ));
310    }
311
312    #[test]
313    fn test_builder_with_metadata() {
314        let mut meta = Metadata::new();
315        meta.insert("owner", "team-a");
316        let node = Node::builder()
317            .id(NodeId::new("a").unwrap())
318            .kind(NodeKind::System)
319            .color(Color::Blue)
320            .icon("x")
321            .title("Test")
322            .description("Test")
323            .metadata(meta)
324            .build()
325            .unwrap();
326        assert_eq!(node.metadata().get("owner"), Some("team-a"));
327    }
328
329    #[test]
330    fn test_serde_round_trip() {
331        let node = sample_node();
332        let json = serde_json::to_string_pretty(&node).unwrap();
333        let deserialized: Node = serde_json::from_str(&json).unwrap();
334        assert_eq!(node, deserialized);
335    }
336
337    #[test]
338    fn test_node_debug() {
339        let node = sample_node();
340        let debug = format!("{node:?}");
341        assert!(debug.contains("web-app"));
342    }
343
344    #[test]
345    fn test_node_clone_eq() {
346        let node = sample_node();
347        let cloned = node.clone();
348        assert_eq!(node, cloned);
349    }
350}