Skip to main content

cognis_graph/viz/
dot.rs

1//! GraphViz DOT renderer.
2//!
3//! The output is valid `dot` source — pipe through `dot -Tsvg -o graph.svg`
4//! or paste into <https://dreampuf.github.io/GraphvizOnline/>.
5
6use crate::compiled::CompiledGraph;
7use crate::state::GraphState;
8
9use super::extract_edges;
10
11impl<S: GraphState> CompiledGraph<S> {
12    /// Render the graph as a GraphViz `digraph` source string.
13    ///
14    /// Layout:
15    /// - Every registered node is a rectangle.
16    /// - The configured start node is highlighted with a bold border.
17    /// - A synthetic `__END__` node is included as a stadium-shaped sink.
18    /// - Static edges declared via `Graph::edge` are drawn as arrows.
19    ///
20    /// Dynamic routing (via `Goto::Send` / `Goto::Multiple` returned from a
21    /// node) is **not** captured — only what the builder declared statically.
22    pub fn to_dot(&self) -> String {
23        let mut out = String::from("digraph G {\n");
24        if let Some(v) = self.version() {
25            out.push_str(&format!("    label=\"version: {}\";\n", escape(v)));
26            out.push_str("    labelloc=t;\n");
27        }
28        out.push_str("    rankdir=TB;\n");
29        out.push_str("    node [shape=box, style=rounded];\n");
30
31        let mut names: Vec<&String> = self.graph.nodes.keys().collect();
32        names.sort();
33
34        // Node declarations. Annotations surface as a `tooltip` attribute,
35        // which dot-rendered SVG shows on hover.
36        for name in &names {
37            let id = node_id(name);
38            let label = escape(name);
39            let tooltip = render_annotations(self.annotations(name));
40            let mut attrs = format!("label=\"{label}\"");
41            if Some(*name) == self.graph.start.as_ref() {
42                attrs.push_str(", penwidth=2.0");
43            }
44            if !tooltip.is_empty() {
45                attrs.push_str(&format!(", tooltip=\"{}\"", escape(&tooltip)));
46            }
47            out.push_str(&format!("    {id} [{attrs}];\n"));
48        }
49        out.push_str(
50            "    __END__ [label=\"END\", shape=oval, style=filled, fillcolor=\"#eeeeee\"];\n",
51        );
52
53        // Static edges.
54        for e in extract_edges(self) {
55            out.push_str(&format!(
56                "    {} -> {};\n",
57                node_id(&e.from),
58                node_id(&e.to)
59            ));
60        }
61
62        out.push_str("}\n");
63        out
64    }
65}
66
67fn render_annotations(map: &std::collections::HashMap<String, serde_json::Value>) -> String {
68    if map.is_empty() {
69        return String::new();
70    }
71    let mut keys: Vec<&String> = map.keys().collect();
72    keys.sort();
73    keys.into_iter()
74        .map(|k| format!("{k}: {}", map[k]))
75        .collect::<Vec<_>>()
76        .join(" | ")
77}
78
79fn node_id(name: &str) -> String {
80    let mut s = String::with_capacity(name.len());
81    for c in name.chars() {
82        if c.is_ascii_alphanumeric() || c == '_' {
83            s.push(c);
84        } else {
85            s.push('_');
86        }
87    }
88    if s.is_empty() {
89        s.push_str("node");
90    }
91    s
92}
93
94fn escape(s: &str) -> String {
95    s.replace('"', "\\\"")
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::builder::Graph;
102    use crate::goto::Goto;
103    use crate::node::{node_fn, NodeOut};
104
105    #[derive(Default, Clone)]
106    struct S;
107    #[derive(Default)]
108    struct SU;
109    impl GraphState for S {
110        type Update = SU;
111        fn apply(&mut self, _: Self::Update) {}
112    }
113
114    fn build() -> CompiledGraph<S> {
115        Graph::<S>::new()
116            .node(
117                "a",
118                node_fn::<S, _, _>("a", |_s, _c| async move {
119                    Ok(NodeOut {
120                        update: SU,
121                        goto: Goto::node("b"),
122                    })
123                }),
124            )
125            .node(
126                "b",
127                node_fn::<S, _, _>("b", |_s, _c| async move {
128                    Ok(NodeOut {
129                        update: SU,
130                        goto: Goto::end(),
131                    })
132                }),
133            )
134            .edge("a", "b")
135            .start_at("a")
136            .compile()
137            .unwrap()
138    }
139
140    #[test]
141    fn renders_basic_digraph() {
142        let g = build();
143        let d = g.to_dot();
144        assert!(d.starts_with("digraph G {\n"));
145        assert!(
146            d.contains("a [label=\"a\", penwidth=2.0];"),
147            "start node bold:\n{d}"
148        );
149        assert!(d.contains("b [label=\"b\"];"));
150        assert!(d.contains("a -> b;"));
151        assert!(d.contains("__END__"));
152        assert!(d.trim_end().ends_with('}'));
153    }
154
155    #[test]
156    fn escapes_special_chars_in_node_labels() {
157        let g = Graph::<S>::new()
158            .node(
159                "node-with-hyphen",
160                node_fn::<S, _, _>("node-with-hyphen", |_, _| async move {
161                    Ok(NodeOut {
162                        update: SU,
163                        goto: Goto::end(),
164                    })
165                }),
166            )
167            .start_at("node-with-hyphen")
168            .compile()
169            .unwrap();
170        let d = g.to_dot();
171        // ID is sanitized, label is preserved.
172        assert!(d.contains("node_with_hyphen [label=\"node-with-hyphen\""));
173    }
174
175    #[test]
176    fn version_renders_as_label_when_set() {
177        let g = Graph::<S>::new()
178            .node(
179                "a",
180                node_fn::<S, _, _>("a", |_, _| async move {
181                    Ok(NodeOut {
182                        update: SU,
183                        goto: Goto::end(),
184                    })
185                }),
186            )
187            .start_at("a")
188            .with_version("v1.2.3")
189            .compile()
190            .unwrap();
191        let d = g.to_dot();
192        assert!(d.contains("label=\"version: v1.2.3\""), "got:\n{d}");
193        assert!(d.contains("labelloc=t"));
194    }
195
196    #[test]
197    fn annotation_renders_as_tooltip() {
198        let g = Graph::<S>::new()
199            .node(
200                "embed",
201                node_fn::<S, _, _>("embed", |_, _| async move {
202                    Ok(NodeOut {
203                        update: SU,
204                        goto: Goto::end(),
205                    })
206                }),
207            )
208            .annotate("embed", "owner", "rag-team")
209            .annotate("embed", "slo_ms", 5000)
210            .start_at("embed")
211            .compile()
212            .unwrap();
213        let d = g.to_dot();
214        // Tooltip combines both annotations alphabetically by key.
215        assert!(
216            d.contains("tooltip=\"owner: \\\"rag-team\\\" | slo_ms: 5000\""),
217            "got:\n{d}"
218        );
219    }
220
221    #[test]
222    fn annotate_unknown_node_is_silent_noop() {
223        let g = Graph::<S>::new()
224            .node(
225                "a",
226                node_fn::<S, _, _>("a", |_, _| async move {
227                    Ok(NodeOut {
228                        update: SU,
229                        goto: Goto::end(),
230                    })
231                }),
232            )
233            .annotate("ghost", "x", "y")
234            .start_at("a")
235            .compile()
236            .unwrap();
237        let d = g.to_dot();
238        assert!(!d.contains("tooltip"));
239    }
240}