Skip to main content

dendryform_layout/
compute.rs

1//! Layout computation — transforms a Diagram into a LayoutPlan.
2
3use dendryform_core::{Diagram, Layer, Tier, TierLayout};
4
5use crate::error::LayoutError;
6use crate::geometry::{
7    ConnectorGeometry, ContainerGeometry, FlowLabelsGeometry, HeaderGeometry, LayerGeometry,
8    LayoutPlan, LegendGeometry, NodeGeometry, TierGeometry, ViewportHint,
9};
10
11/// Computes a layout plan from a validated diagram.
12///
13/// Walks tiers top-to-bottom, assigns grid positions based on each tier's
14/// layout configuration, and recurses into containers for nested layouts.
15pub fn compute_layout(diagram: &Diagram) -> Result<LayoutPlan<'_>, LayoutError> {
16    let header = HeaderGeometry {
17        title_text: diagram.header().title().text().to_owned(),
18        title_accent: diagram.header().title().accent().to_owned(),
19        subtitle: diagram.header().subtitle().to_owned(),
20    };
21
22    let layers = compute_layers(diagram.layers(), false)?;
23
24    let legend = LegendGeometry {
25        entries: diagram.legend().to_vec(),
26    };
27
28    Ok(LayoutPlan {
29        viewport: ViewportHint::default(),
30        header,
31        layers,
32        legend,
33    })
34}
35
36/// Computes layer geometries for a list of layers.
37fn compute_layers<'a>(
38    layers: &'a [Layer],
39    is_internal: bool,
40) -> Result<Vec<LayerGeometry<'a>>, LayoutError> {
41    let mut result = Vec::with_capacity(layers.len());
42
43    for layer in layers {
44        match layer {
45            Layer::Tier(tier) => {
46                result.push(LayerGeometry::Tier(compute_tier(tier)?));
47            }
48            Layer::Connector(conn) => {
49                result.push(LayerGeometry::Connector(ConnectorGeometry {
50                    style: conn.style(),
51                    label: conn.label().map(|s| s.to_owned()),
52                    is_internal,
53                }));
54            }
55            Layer::FlowLabels(labels) => {
56                result.push(LayerGeometry::FlowLabels(FlowLabelsGeometry {
57                    items: labels.items().iter().map(|s| s.to_owned()).collect(),
58                }));
59            }
60            _ => {
61                // non_exhaustive: skip unknown layer variants gracefully
62            }
63        }
64    }
65
66    Ok(result)
67}
68
69/// Computes the geometry for a single tier.
70fn compute_tier(tier: &Tier) -> Result<TierGeometry<'_>, LayoutError> {
71    let columns = resolve_columns(tier.layout(), tier.nodes().len());
72
73    let nodes: Vec<NodeGeometry<'_>> = tier
74        .nodes()
75        .iter()
76        .enumerate()
77        .map(|(i, node)| NodeGeometry {
78            node,
79            grid_column: i % columns,
80            grid_row: i / columns,
81        })
82        .collect();
83
84    let container = if let Some(c) = tier.container() {
85        let nested_layers = compute_layers(c.layers(), true)?;
86        Some(ContainerGeometry {
87            label: c.label().to_owned(),
88            border: c.border(),
89            label_color: c.label_color(),
90            layers: nested_layers,
91        })
92    } else {
93        None
94    };
95
96    Ok(TierGeometry {
97        id: tier.id().clone(),
98        label: tier.label().map(|s| s.to_owned()),
99        layout: tier.layout().clone(),
100        columns,
101        nodes,
102        container,
103    })
104}
105
106/// Resolves the number of grid columns for a tier layout.
107fn resolve_columns(layout: &TierLayout, node_count: usize) -> usize {
108    match layout {
109        TierLayout::Single => 1,
110        TierLayout::Grid { columns } => *columns as usize,
111        TierLayout::Auto => node_count.clamp(1, 4),
112        _ => node_count.clamp(1, 4), // non_exhaustive fallback
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use dendryform_core::{
120        Color, Connector, ConnectorStyle, Container, ContainerBorder, Diagram, DiagramHeader, Edge,
121        FlowLabels, Layer, LegendEntry, Node, NodeId, NodeKind, RawDiagram, Tier, TierLayout,
122    };
123
124    fn test_node(id: &str) -> Node {
125        Node::builder()
126            .id(NodeId::new(id).unwrap())
127            .kind(NodeKind::System)
128            .color(Color::Blue)
129            .icon("◇")
130            .title(id)
131            .description("test node")
132            .build()
133            .unwrap()
134    }
135
136    fn make_diagram(layers: Vec<Layer>, edges: Vec<Edge>, legend: Vec<LegendEntry>) -> Diagram {
137        let raw = RawDiagram {
138            diagram: DiagramHeader::new(Title::new("test", "accent"), "subtitle", "dark"),
139            layers,
140            legend,
141            edges,
142        };
143        Diagram::try_from(raw).unwrap()
144    }
145
146    use dendryform_core::Title;
147
148    #[test]
149    fn test_single_tier_layout() {
150        let diagram = make_diagram(
151            vec![Layer::Tier(Tier::new(
152                NodeId::new("main").unwrap(),
153                vec![test_node("a"), test_node("b"), test_node("c")],
154            ))],
155            vec![],
156            vec![],
157        );
158
159        let plan = compute_layout(&diagram).unwrap();
160        assert_eq!(plan.header.title_text, "test");
161        assert_eq!(plan.header.title_accent, "accent");
162        assert_eq!(plan.header.subtitle, "subtitle");
163        assert_eq!(plan.layers.len(), 1);
164
165        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
166            assert_eq!(tier.nodes.len(), 3);
167            // Auto layout: 3 nodes → 3 columns
168            assert_eq!(tier.columns, 3);
169            assert_eq!(tier.nodes[0].grid_column, 0);
170            assert_eq!(tier.nodes[1].grid_column, 1);
171            assert_eq!(tier.nodes[2].grid_column, 2);
172            assert_eq!(tier.nodes[0].grid_row, 0);
173        } else {
174            panic!("expected tier layer");
175        }
176    }
177
178    #[test]
179    fn test_grid_layout_columns() {
180        let mut tier = Tier::new(
181            NodeId::new("grid").unwrap(),
182            vec![
183                test_node("a"),
184                test_node("b"),
185                test_node("c"),
186                test_node("d"),
187                test_node("e"),
188            ],
189        );
190        tier.set_layout(TierLayout::Grid { columns: 3 });
191        let diagram = make_diagram(vec![Layer::Tier(tier)], vec![], vec![]);
192
193        let plan = compute_layout(&diagram).unwrap();
194        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
195            assert_eq!(tier.columns, 3);
196            // 5 nodes in 3 columns: row 0 = [0,1,2], row 1 = [3,4]
197            assert_eq!(tier.nodes[0].grid_column, 0);
198            assert_eq!(tier.nodes[0].grid_row, 0);
199            assert_eq!(tier.nodes[3].grid_column, 0);
200            assert_eq!(tier.nodes[3].grid_row, 1);
201            assert_eq!(tier.nodes[4].grid_column, 1);
202            assert_eq!(tier.nodes[4].grid_row, 1);
203        } else {
204            panic!("expected tier layer");
205        }
206    }
207
208    #[test]
209    fn test_single_layout_one_column() {
210        let mut tier = Tier::new(NodeId::new("single").unwrap(), vec![test_node("a")]);
211        tier.set_layout(TierLayout::Single);
212        let diagram = make_diagram(vec![Layer::Tier(tier)], vec![], vec![]);
213
214        let plan = compute_layout(&diagram).unwrap();
215        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
216            assert_eq!(tier.columns, 1);
217        } else {
218            panic!("expected tier layer");
219        }
220    }
221
222    #[test]
223    fn test_auto_layout_caps_at_four() {
224        let tier = Tier::new(
225            NodeId::new("many").unwrap(),
226            vec![
227                test_node("a"),
228                test_node("b"),
229                test_node("c"),
230                test_node("d"),
231                test_node("e"),
232                test_node("f"),
233            ],
234        );
235        let diagram = make_diagram(vec![Layer::Tier(tier)], vec![], vec![]);
236
237        let plan = compute_layout(&diagram).unwrap();
238        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
239            assert_eq!(tier.columns, 4);
240        } else {
241            panic!("expected tier layer");
242        }
243    }
244
245    #[test]
246    fn test_connector_geometry() {
247        let diagram = make_diagram(
248            vec![
249                Layer::Tier(Tier::new(NodeId::new("top").unwrap(), vec![test_node("a")])),
250                Layer::Connector(Connector::with_label(ConnectorStyle::Line, "HTTPS")),
251                Layer::Tier(Tier::new(
252                    NodeId::new("bottom").unwrap(),
253                    vec![test_node("b")],
254                )),
255            ],
256            vec![],
257            vec![],
258        );
259
260        let plan = compute_layout(&diagram).unwrap();
261        assert_eq!(plan.layers.len(), 3);
262
263        if let LayerGeometry::Connector(conn) = &plan.layers[1] {
264            assert_eq!(conn.style, ConnectorStyle::Line);
265            assert_eq!(conn.label.as_deref(), Some("HTTPS"));
266            assert!(!conn.is_internal);
267        } else {
268            panic!("expected connector layer");
269        }
270    }
271
272    #[test]
273    fn test_container_nesting() {
274        let container = Container::new(
275            "server",
276            ContainerBorder::Solid,
277            Color::Green,
278            vec![Layer::Tier(Tier::new(
279                NodeId::new("inner").unwrap(),
280                vec![test_node("api")],
281            ))],
282        );
283        let diagram = make_diagram(
284            vec![Layer::Tier(Tier::with_container(
285                NodeId::new("server").unwrap(),
286                container,
287            ))],
288            vec![],
289            vec![],
290        );
291
292        let plan = compute_layout(&diagram).unwrap();
293        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
294            assert!(tier.container.is_some());
295            let c = tier.container.as_ref().unwrap();
296            assert_eq!(c.label, "server");
297            assert_eq!(c.border, ContainerBorder::Solid);
298            assert_eq!(c.label_color, Color::Green);
299            assert_eq!(c.layers.len(), 1);
300
301            if let LayerGeometry::Tier(inner) = &c.layers[0] {
302                assert_eq!(inner.nodes.len(), 1);
303                assert_eq!(inner.nodes[0].node.id().as_str(), "api");
304            } else {
305                panic!("expected inner tier");
306            }
307        } else {
308            panic!("expected tier layer");
309        }
310    }
311
312    #[test]
313    fn test_internal_connector_flag() {
314        let container = Container::new(
315            "server",
316            ContainerBorder::Solid,
317            Color::Green,
318            vec![
319                Layer::Tier(Tier::new(NodeId::new("top").unwrap(), vec![test_node("a")])),
320                Layer::Connector(Connector::new(ConnectorStyle::Dots)),
321                Layer::Tier(Tier::new(
322                    NodeId::new("bottom").unwrap(),
323                    vec![test_node("b")],
324                )),
325            ],
326        );
327        let diagram = make_diagram(
328            vec![Layer::Tier(Tier::with_container(
329                NodeId::new("server").unwrap(),
330                container,
331            ))],
332            vec![],
333            vec![],
334        );
335
336        let plan = compute_layout(&diagram).unwrap();
337        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
338            let c = tier.container.as_ref().unwrap();
339            if let LayerGeometry::Connector(conn) = &c.layers[1] {
340                assert!(conn.is_internal, "container connectors should be internal");
341                assert_eq!(conn.style, ConnectorStyle::Dots);
342            } else {
343                panic!("expected connector");
344            }
345        } else {
346            panic!("expected tier");
347        }
348    }
349
350    #[test]
351    fn test_flow_labels_geometry() {
352        let diagram = make_diagram(
353            vec![
354                Layer::Tier(Tier::new(NodeId::new("top").unwrap(), vec![test_node("a")])),
355                Layer::FlowLabels(FlowLabels::new(vec![
356                    "SQL queries".to_owned(),
357                    "cache reads".to_owned(),
358                ])),
359                Layer::Tier(Tier::new(
360                    NodeId::new("bottom").unwrap(),
361                    vec![test_node("b")],
362                )),
363            ],
364            vec![],
365            vec![],
366        );
367
368        let plan = compute_layout(&diagram).unwrap();
369        if let LayerGeometry::FlowLabels(fl) = &plan.layers[1] {
370            assert_eq!(fl.items.len(), 2);
371            assert_eq!(fl.items[0], "SQL queries");
372        } else {
373            panic!("expected flow labels");
374        }
375    }
376
377    #[test]
378    fn test_legend_geometry() {
379        let diagram = make_diagram(
380            vec![Layer::Tier(Tier::new(
381                NodeId::new("main").unwrap(),
382                vec![test_node("a")],
383            ))],
384            vec![],
385            vec![
386                LegendEntry::new(Color::Blue, "Clients"),
387                LegendEntry::new(Color::Green, "Servers"),
388            ],
389        );
390
391        let plan = compute_layout(&diagram).unwrap();
392        assert_eq!(plan.legend.entries.len(), 2);
393        assert_eq!(plan.legend.entries[0].label(), "Clients");
394        assert_eq!(plan.legend.entries[1].color(), Color::Green);
395    }
396
397    #[test]
398    fn test_viewport_defaults() {
399        let diagram = make_diagram(
400            vec![Layer::Tier(Tier::new(
401                NodeId::new("main").unwrap(),
402                vec![test_node("a")],
403            ))],
404            vec![],
405            vec![],
406        );
407
408        let plan = compute_layout(&diagram).unwrap();
409        assert_eq!(plan.viewport.width, 1100.0);
410        assert_eq!(plan.viewport.padding_x, 32.0);
411    }
412
413    #[test]
414    fn test_taproot_layout() {
415        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
416        let diagram: Diagram = dendryform_parse::parse_yaml(yaml).unwrap();
417        let plan = compute_layout(&diagram).unwrap();
418
419        assert_eq!(plan.header.title_accent, "taproot");
420        assert_eq!(plan.layers.len(), 5);
421        assert_eq!(plan.legend.entries.len(), 6);
422
423        // First layer: client tier with 1 node, single layout
424        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
425            assert_eq!(tier.nodes.len(), 1);
426            assert_eq!(tier.columns, 1);
427        } else {
428            panic!("expected client tier");
429        }
430
431        // Second layer: connector
432        assert!(matches!(plan.layers[1], LayerGeometry::Connector(_)));
433
434        // Third layer: server tier with container
435        if let LayerGeometry::Tier(tier) = &plan.layers[2] {
436            assert!(tier.container.is_some());
437            let c = tier.container.as_ref().unwrap();
438            assert_eq!(c.label, "taproot server · cloud run");
439            // Container has: edge tier, dots, tools tier, dots, intelligence tier, infrastructure tier
440            assert!(c.layers.len() >= 4);
441        } else {
442            panic!("expected server tier");
443        }
444
445        // Fourth layer: flow labels
446        if let LayerGeometry::FlowLabels(fl) = &plan.layers[3] {
447            assert_eq!(fl.items.len(), 3);
448        } else {
449            panic!("expected flow labels");
450        }
451
452        // Fifth layer: external services tier
453        if let LayerGeometry::Tier(tier) = &plan.layers[4] {
454            assert_eq!(tier.nodes.len(), 3);
455            assert_eq!(tier.columns, 3);
456        } else {
457            panic!("expected external services tier");
458        }
459    }
460
461    #[test]
462    fn test_ai_kasu_layout() {
463        let yaml = include_str!("../../../examples/ai-kasu/architecture.yaml");
464        let diagram: Diagram = dendryform_parse::parse_yaml(yaml).unwrap();
465        let plan = compute_layout(&diagram).unwrap();
466
467        assert_eq!(plan.header.title_accent, "ai-kasu");
468        assert_eq!(plan.layers.len(), 7);
469        assert_eq!(plan.legend.entries.len(), 6);
470
471        // First layer: MCP Clients tier with 3 nodes in 3-column grid
472        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
473            assert_eq!(tier.nodes.len(), 3);
474            assert_eq!(tier.columns, 3);
475        } else {
476            panic!("expected clients tier");
477        }
478
479        // Second layer: connector
480        assert!(matches!(plan.layers[1], LayerGeometry::Connector(_)));
481
482        // Third layer: server tier with nested container (solid + dashed)
483        if let LayerGeometry::Tier(tier) = &plan.layers[2] {
484            assert!(tier.container.is_some());
485            let c = tier.container.as_ref().unwrap();
486            assert_eq!(c.label, "kasu-server · rust binary");
487        } else {
488            panic!("expected server tier");
489        }
490    }
491
492    #[test]
493    fn test_oxur_lisp_layout() {
494        let yaml = include_str!("../../../examples/oxur-lisp/architecture.yaml");
495        let diagram: Diagram = dendryform_parse::parse_yaml(yaml).unwrap();
496        let plan = compute_layout(&diagram).unwrap();
497
498        assert_eq!(plan.header.title_accent, "oxur");
499        assert_eq!(plan.layers.len(), 9);
500        assert_eq!(plan.legend.entries.len(), 6);
501
502        // First layer: source input (single node)
503        if let LayerGeometry::Tier(tier) = &plan.layers[0] {
504            assert_eq!(tier.nodes.len(), 1);
505            assert_eq!(tier.columns, 1);
506        } else {
507            panic!("expected source input tier");
508        }
509
510        // Second layer: connector
511        assert!(matches!(plan.layers[1], LayerGeometry::Connector(_)));
512
513        // Third layer: pipeline container
514        if let LayerGeometry::Tier(tier) = &plan.layers[2] {
515            assert!(tier.container.is_some());
516            let c = tier.container.as_ref().unwrap();
517            assert_eq!(c.label, "oxur compilation pipeline");
518        } else {
519            panic!("expected pipeline tier");
520        }
521    }
522}