1use crate::compiled::CompiledGraph;
7use crate::state::GraphState;
8
9use super::extract_edges;
10
11impl<S: GraphState> CompiledGraph<S> {
12 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 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 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 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 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}