Skip to main content

dendryform_core/
diagram.rs

1//! Diagram types — the top-level document structure.
2
3use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::edge::Edge;
8use crate::error::ValidationError;
9use crate::id::NodeId;
10use crate::layer::Layer;
11use crate::legend::LegendEntry;
12
13/// Maximum allowed container nesting depth.
14const MAX_NESTING_DEPTH: usize = 3;
15
16/// The title block of a diagram.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub struct Title {
20    /// The main title text (appears after the accent word).
21    text: String,
22    /// The accent word rendered in the accent color.
23    accent: String,
24}
25
26impl Title {
27    /// Creates a new title.
28    pub fn new(text: &str, accent: &str) -> Self {
29        Self {
30            text: text.to_owned(),
31            accent: accent.to_owned(),
32        }
33    }
34
35    /// Returns the main title text.
36    pub fn text(&self) -> &str {
37        &self.text
38    }
39
40    /// Returns the accent word.
41    pub fn accent(&self) -> &str {
42        &self.accent
43    }
44}
45
46/// Diagram metadata and theme configuration.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub struct DiagramHeader {
50    title: Title,
51    subtitle: String,
52    theme: String,
53}
54
55impl DiagramHeader {
56    /// Creates a new diagram header.
57    pub fn new(title: Title, subtitle: &str, theme: &str) -> Self {
58        Self {
59            title,
60            subtitle: subtitle.to_owned(),
61            theme: theme.to_owned(),
62        }
63    }
64
65    /// Returns the title block.
66    pub fn title(&self) -> &Title {
67        &self.title
68    }
69
70    /// Returns the subtitle.
71    pub fn subtitle(&self) -> &str {
72        &self.subtitle
73    }
74
75    /// Returns the theme identifier.
76    pub fn theme(&self) -> &str {
77        &self.theme
78    }
79}
80
81/// The raw, unchecked deserialization target for a diagram YAML/JSON file.
82///
83/// Serde populates this directly. Use [`Diagram::try_from`] to validate
84/// invariants and produce a checked [`Diagram`].
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub struct RawDiagram {
88    /// Diagram metadata.
89    pub diagram: DiagramHeader,
90    /// Ordered visual layers.
91    pub layers: Vec<Layer>,
92    /// Legend entries.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub legend: Vec<LegendEntry>,
95    /// Semantic edges.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub edges: Vec<Edge>,
98}
99
100/// A validated diagram that cannot represent an invalid state.
101///
102/// Invariants enforced:
103/// - No duplicate node IDs across the entire diagram
104/// - All edge `from`/`to` references point to existing node IDs
105/// - No empty tiers (every tier has nodes or a container)
106/// - Container nesting depth does not exceed the maximum
107///
108/// Construct via `Diagram::try_from(raw_diagram)`.
109#[derive(Debug, Clone, PartialEq, Serialize)]
110#[serde(transparent)]
111pub struct Diagram(RawDiagram);
112
113impl Diagram {
114    /// Returns the diagram header (title, subtitle, theme).
115    pub fn header(&self) -> &DiagramHeader {
116        &self.0.diagram
117    }
118
119    /// Returns the ordered visual layers.
120    pub fn layers(&self) -> &[Layer] {
121        &self.0.layers
122    }
123
124    /// Returns the legend entries.
125    pub fn legend(&self) -> &[LegendEntry] {
126        &self.0.legend
127    }
128
129    /// Returns the semantic edges.
130    pub fn edges(&self) -> &[Edge] {
131        &self.0.edges
132    }
133
134    /// Collects all node IDs from the diagram (including nested containers).
135    fn collect_all_node_ids(layers: &[Layer]) -> Vec<&NodeId> {
136        let mut ids = Vec::new();
137        Self::collect_node_ids_recursive(layers, &mut ids);
138        ids
139    }
140
141    fn collect_node_ids_recursive<'a>(layers: &'a [Layer], ids: &mut Vec<&'a NodeId>) {
142        for layer in layers {
143            if let Layer::Tier(tier) = layer {
144                for node in tier.nodes() {
145                    ids.push(node.id());
146                }
147                if let Some(container) = tier.container() {
148                    Self::collect_node_ids_recursive(container.layers(), ids);
149                }
150            }
151        }
152    }
153
154    /// Validates that no empty tiers exist (including in nested containers).
155    fn check_empty_tiers(layers: &[Layer]) -> Result<(), ValidationError> {
156        for layer in layers {
157            if let Layer::Tier(tier) = layer {
158                if tier.is_empty() {
159                    return Err(ValidationError::EmptyTier {
160                        id: tier.id().to_string(),
161                    });
162                }
163                if let Some(container) = tier.container() {
164                    Self::check_empty_tiers(container.layers())?;
165                }
166            }
167        }
168        Ok(())
169    }
170
171    /// Validates container nesting depth.
172    fn check_nesting_depth(layers: &[Layer], current_depth: usize) -> Result<(), ValidationError> {
173        for layer in layers {
174            if let Layer::Tier(tier) = layer {
175                if let Some(container) = tier.container() {
176                    let depth = current_depth + 1;
177                    if depth > MAX_NESTING_DEPTH {
178                        return Err(ValidationError::NestingTooDeep {
179                            max_depth: MAX_NESTING_DEPTH,
180                            actual_depth: depth,
181                        });
182                    }
183                    Self::check_nesting_depth(container.layers(), depth)?;
184                }
185            }
186        }
187        Ok(())
188    }
189}
190
191impl TryFrom<RawDiagram> for Diagram {
192    type Error = ValidationError;
193
194    fn try_from(raw: RawDiagram) -> Result<Self, Self::Error> {
195        // Check for empty tiers.
196        Self::check_empty_tiers(&raw.layers)?;
197
198        // Check nesting depth.
199        Self::check_nesting_depth(&raw.layers, 0)?;
200
201        // Collect all node IDs and check for duplicates.
202        let all_ids = Self::collect_all_node_ids(&raw.layers);
203        let mut seen = HashSet::new();
204        for id in &all_ids {
205            if !seen.insert(id.as_str()) {
206                return Err(ValidationError::DuplicateNodeId { id: id.to_string() });
207            }
208        }
209
210        // Check that all edge references point to existing nodes.
211        for edge in &raw.edges {
212            if !seen.contains(edge.from_id().as_str()) {
213                return Err(ValidationError::DanglingEdgeReference {
214                    id: edge.from_id().to_string(),
215                    field: "from",
216                });
217            }
218            if !seen.contains(edge.to_id().as_str()) {
219                return Err(ValidationError::DanglingEdgeReference {
220                    id: edge.to_id().to_string(),
221                    field: "to",
222                });
223            }
224        }
225
226        Ok(Self(raw))
227    }
228}
229
230impl<'de> Deserialize<'de> for Diagram {
231    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
232    where
233        D: serde::Deserializer<'de>,
234    {
235        let raw = RawDiagram::deserialize(deserializer)?;
236        Diagram::try_from(raw).map_err(serde::de::Error::custom)
237    }
238}
239
240// Helper to build test fixtures without repeating boilerplate.
241#[cfg(test)]
242fn test_node(id: &str) -> crate::node::Node {
243    crate::node::Node::builder()
244        .id(NodeId::new(id).unwrap())
245        .kind(crate::kind::NodeKind::System)
246        .color(crate::color::Color::Blue)
247        .icon("◇")
248        .title(id)
249        .description("test node")
250        .build()
251        .unwrap()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::color::Color;
258    use crate::connector::{Connector, ConnectorStyle};
259    use crate::container::{Container, ContainerBorder};
260    use crate::kind::EdgeKind;
261    use crate::layer::Layer;
262    use crate::node::Node;
263    use crate::tier::Tier;
264
265    fn simple_raw(nodes: Vec<Node>, edges: Vec<Edge>) -> RawDiagram {
266        RawDiagram {
267            diagram: DiagramHeader::new(Title::new("test", "test"), "a test diagram", "dark"),
268            layers: vec![Layer::Tier(Tier::new(NodeId::new("main").unwrap(), nodes))],
269            legend: vec![],
270            edges,
271        }
272    }
273
274    #[test]
275    fn test_valid_diagram() {
276        let raw = simple_raw(
277            vec![test_node("app"), test_node("db")],
278            vec![Edge::new(
279                NodeId::new("app").unwrap(),
280                NodeId::new("db").unwrap(),
281                EdgeKind::Uses,
282            )],
283        );
284        let diagram = Diagram::try_from(raw);
285        assert!(diagram.is_ok());
286    }
287
288    #[test]
289    fn test_duplicate_node_id_rejected() {
290        let raw = simple_raw(vec![test_node("app"), test_node("app")], vec![]);
291        let err = Diagram::try_from(raw).unwrap_err();
292        assert!(matches!(err, ValidationError::DuplicateNodeId { id } if id == "app"));
293    }
294
295    #[test]
296    fn test_dangling_edge_from_rejected() {
297        let raw = simple_raw(
298            vec![test_node("app")],
299            vec![Edge::new(
300                NodeId::new("ghost").unwrap(),
301                NodeId::new("app").unwrap(),
302                EdgeKind::Uses,
303            )],
304        );
305        let err = Diagram::try_from(raw).unwrap_err();
306        assert!(
307            matches!(err, ValidationError::DanglingEdgeReference { id, field } if id == "ghost" && field == "from")
308        );
309    }
310
311    #[test]
312    fn test_dangling_edge_to_rejected() {
313        let raw = simple_raw(
314            vec![test_node("app")],
315            vec![Edge::new(
316                NodeId::new("app").unwrap(),
317                NodeId::new("ghost").unwrap(),
318                EdgeKind::Uses,
319            )],
320        );
321        let err = Diagram::try_from(raw).unwrap_err();
322        assert!(
323            matches!(err, ValidationError::DanglingEdgeReference { id, field } if id == "ghost" && field == "to")
324        );
325    }
326
327    #[test]
328    fn test_empty_tier_rejected() {
329        let raw = RawDiagram {
330            diagram: DiagramHeader::new(Title::new("test", "test"), "test", "dark"),
331            layers: vec![Layer::Tier(Tier::new(
332                NodeId::new("empty").unwrap(),
333                vec![],
334            ))],
335            legend: vec![],
336            edges: vec![],
337        };
338        let err = Diagram::try_from(raw).unwrap_err();
339        assert!(matches!(err, ValidationError::EmptyTier { id } if id == "empty"));
340    }
341
342    #[test]
343    fn test_tier_with_container_not_empty() {
344        let container = Container::new(
345            "server",
346            ContainerBorder::Solid,
347            Color::Green,
348            vec![Layer::Tier(Tier::new(
349                NodeId::new("inner").unwrap(),
350                vec![test_node("api")],
351            ))],
352        );
353        let raw = RawDiagram {
354            diagram: DiagramHeader::new(Title::new("test", "test"), "test", "dark"),
355            layers: vec![Layer::Tier(Tier::with_container(
356                NodeId::new("server").unwrap(),
357                container,
358            ))],
359            legend: vec![],
360            edges: vec![],
361        };
362        assert!(Diagram::try_from(raw).is_ok());
363    }
364
365    #[test]
366    fn test_connector_layer_does_not_affect_validation() {
367        let raw = RawDiagram {
368            diagram: DiagramHeader::new(Title::new("test", "test"), "test", "dark"),
369            layers: vec![
370                Layer::Tier(Tier::new(NodeId::new("top").unwrap(), vec![test_node("a")])),
371                Layer::Connector(Connector::with_label(ConnectorStyle::Line, "HTTPS")),
372                Layer::Tier(Tier::new(
373                    NodeId::new("bottom").unwrap(),
374                    vec![test_node("b")],
375                )),
376            ],
377            legend: vec![],
378            edges: vec![],
379        };
380        assert!(Diagram::try_from(raw).is_ok());
381    }
382
383    #[test]
384    fn test_serde_round_trip() {
385        let raw = simple_raw(vec![test_node("app")], vec![]);
386        let diagram = Diagram::try_from(raw).unwrap();
387        let json = serde_json::to_string_pretty(&diagram).unwrap();
388        let deserialized: Diagram = serde_json::from_str(&json).unwrap();
389        assert_eq!(diagram, deserialized);
390    }
391
392    #[test]
393    fn test_nesting_too_deep_rejected() {
394        // Build 5-deep nesting to exceed MAX_NESTING_DEPTH = 3.
395        // Depths: top(1) -> l1t(2) -> l2t(3) -> l3t(4) — depth 4 > 3, triggers error.
396        let deepest_tier = Tier::new(NodeId::new("deep").unwrap(), vec![test_node("d")]);
397        let level4 = Container::new(
398            "l4",
399            ContainerBorder::Dashed,
400            Color::Blue,
401            vec![Layer::Tier(deepest_tier)],
402        );
403        let level3 = Container::new(
404            "l3",
405            ContainerBorder::Dashed,
406            Color::Blue,
407            vec![Layer::Tier(Tier::with_container(
408                NodeId::new("l3t").unwrap(),
409                level4,
410            ))],
411        );
412        let level2 = Container::new(
413            "l2",
414            ContainerBorder::Dashed,
415            Color::Blue,
416            vec![Layer::Tier(Tier::with_container(
417                NodeId::new("l2t").unwrap(),
418                level3,
419            ))],
420        );
421        let level1 = Container::new(
422            "l1",
423            ContainerBorder::Dashed,
424            Color::Blue,
425            vec![Layer::Tier(Tier::with_container(
426                NodeId::new("l1t").unwrap(),
427                level2,
428            ))],
429        );
430        let top_tier = Tier::with_container(NodeId::new("top").unwrap(), level1);
431
432        let raw = RawDiagram {
433            diagram: DiagramHeader::new(Title::new("test", "test"), "test", "dark"),
434            layers: vec![Layer::Tier(top_tier)],
435            legend: vec![],
436            edges: vec![],
437        };
438        let err = Diagram::try_from(raw).unwrap_err();
439        assert!(matches!(err, ValidationError::NestingTooDeep { .. }));
440    }
441
442    #[test]
443    fn test_diagram_accessors() {
444        let raw = simple_raw(
445            vec![test_node("app")],
446            vec![Edge::new(
447                NodeId::new("app").unwrap(),
448                NodeId::new("app").unwrap(),
449                EdgeKind::Uses,
450            )],
451        );
452        let diagram = Diagram::try_from(raw).unwrap();
453
454        assert_eq!(diagram.header().title().text(), "test");
455        assert_eq!(diagram.header().title().accent(), "test");
456        assert_eq!(diagram.header().subtitle(), "a test diagram");
457        assert_eq!(diagram.header().theme(), "dark");
458        assert_eq!(diagram.layers().len(), 1);
459        assert_eq!(diagram.edges().len(), 1);
460        assert!(diagram.legend().is_empty());
461    }
462
463    #[test]
464    fn test_nested_container_node_ids_collected() {
465        let container = Container::new(
466            "server",
467            ContainerBorder::Solid,
468            Color::Green,
469            vec![Layer::Tier(Tier::new(
470                NodeId::new("inner").unwrap(),
471                vec![test_node("api")],
472            ))],
473        );
474        let raw = RawDiagram {
475            diagram: DiagramHeader::new(Title::new("test", "test"), "test", "dark"),
476            layers: vec![Layer::Tier(Tier::with_container(
477                NodeId::new("server").unwrap(),
478                container,
479            ))],
480            legend: vec![],
481            edges: vec![Edge::new(
482                NodeId::new("api").unwrap(),
483                NodeId::new("api").unwrap(),
484                EdgeKind::Uses,
485            )],
486        };
487        // Edge referencing nested "api" should pass validation
488        assert!(Diagram::try_from(raw).is_ok());
489    }
490}