synaptic_graph/
visualization.rs1use std::fmt;
2use std::io::Write;
3use std::path::Path;
4
5use synaptic_core::SynapseError;
6
7use crate::compiled::CompiledGraph;
8use crate::state::State;
9use crate::{END, START};
10
11impl<S: State> CompiledGraph<S> {
12 pub fn draw_mermaid(&self) -> String {
20 let mut lines = vec!["graph TD".to_string()];
21
22 let mut node_names: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
24 node_names.sort();
25
26 lines.push(format!(" {START}([\"{START}\"])"));
28 for name in &node_names {
29 lines.push(format!(" {name}[\"{name}\"]"));
30 }
31 lines.push(format!(" {END}([\"{END}\"])"));
32
33 lines.push(format!(" {START} --> {}", self.entry_point));
35
36 let mut fixed: Vec<(&str, &str)> = self
38 .edges
39 .iter()
40 .map(|e| (e.source.as_str(), e.target.as_str()))
41 .collect();
42 fixed.sort();
43 for (source, target) in fixed {
44 lines.push(format!(" {source} --> {target}"));
45 }
46
47 let mut cond_sources: Vec<&str> = self
49 .conditional_edges
50 .iter()
51 .map(|ce| ce.source.as_str())
52 .collect();
53 cond_sources.sort();
54
55 for source in cond_sources {
56 let ce = self
57 .conditional_edges
58 .iter()
59 .find(|ce| ce.source == source)
60 .unwrap();
61 match &ce.path_map {
62 Some(path_map) => {
63 let mut entries: Vec<(&String, &String)> = path_map.iter().collect();
64 entries.sort_by_key(|(label, _)| label.to_string());
65 for (label, target) in entries {
66 lines.push(format!(" {source} -.-> |{label}| {target}"));
67 }
68 }
69 None => {
70 lines.push(format!(
71 " %% {source} has conditional edge (path_map not provided)"
72 ));
73 }
74 }
75 }
76
77 lines.join("\n")
78 }
79
80 pub fn draw_ascii(&self) -> String {
82 let mut lines = vec!["Graph:".to_string()];
83
84 let mut node_names: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
86 node_names.sort();
87 lines.push(format!(" Nodes: {}", node_names.join(", ")));
88
89 lines.push(format!(" Entry: {START} -> {}", self.entry_point));
91
92 lines.push(" Edges:".to_string());
94
95 let mut fixed: Vec<(&str, &str)> = self
97 .edges
98 .iter()
99 .map(|e| (e.source.as_str(), e.target.as_str()))
100 .collect();
101 fixed.sort();
102 for (source, target) in fixed {
103 lines.push(format!(" {source} -> {target}"));
104 }
105
106 let mut cond_sources: Vec<&str> = self
108 .conditional_edges
109 .iter()
110 .map(|ce| ce.source.as_str())
111 .collect();
112 cond_sources.sort();
113
114 for source in cond_sources {
115 let ce = self
116 .conditional_edges
117 .iter()
118 .find(|ce| ce.source == source)
119 .unwrap();
120 match &ce.path_map {
121 Some(path_map) => {
122 let mut targets: Vec<&String> = path_map.values().collect();
123 targets.sort();
124 targets.dedup();
125 let targets_str = targets.iter().map(|t| t.as_str()).collect::<Vec<_>>();
126 lines.push(format!(
127 " {source} -> {} [conditional]",
128 targets_str.join(" | ")
129 ));
130 }
131 None => {
132 lines.push(format!(" {source} -> ??? [conditional]"));
133 }
134 }
135 }
136
137 lines.join("\n")
138 }
139
140 pub fn draw_dot(&self) -> String {
142 let mut lines = vec!["digraph G {".to_string()];
143 lines.push(" rankdir=TD;".to_string());
144
145 let mut node_names: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
147 node_names.sort();
148
149 lines.push(format!(" \"{START}\" [shape=oval];"));
150 for name in &node_names {
151 lines.push(format!(" \"{name}\" [shape=box];"));
152 }
153 lines.push(format!(" \"{END}\" [shape=oval];"));
154
155 lines.push(format!(
157 " \"{START}\" -> \"{}\" [style=solid];",
158 self.entry_point
159 ));
160
161 let mut fixed: Vec<(&str, &str)> = self
163 .edges
164 .iter()
165 .map(|e| (e.source.as_str(), e.target.as_str()))
166 .collect();
167 fixed.sort();
168 for (source, target) in fixed {
169 lines.push(format!(" \"{source}\" -> \"{target}\" [style=solid];"));
170 }
171
172 let mut cond_sources: Vec<&str> = self
174 .conditional_edges
175 .iter()
176 .map(|ce| ce.source.as_str())
177 .collect();
178 cond_sources.sort();
179
180 for source in cond_sources {
181 let ce = self
182 .conditional_edges
183 .iter()
184 .find(|ce| ce.source == source)
185 .unwrap();
186 if let Some(ref path_map) = ce.path_map {
187 let mut entries: Vec<(&String, &String)> = path_map.iter().collect();
188 entries.sort_by_key(|(label, _)| label.to_string());
189 for (label, target) in entries {
190 lines.push(format!(
191 " \"{source}\" -> \"{target}\" [style=dashed, label=\"{label}\"];",
192 ));
193 }
194 }
195 }
196
197 lines.push("}".to_string());
198 lines.join("\n")
199 }
200
201 pub async fn draw_mermaid_png(&self, path: impl AsRef<Path>) -> Result<(), SynapseError> {
210 self.fetch_mermaid_ink("img", path).await
211 }
212
213 pub async fn draw_mermaid_svg(&self, path: impl AsRef<Path>) -> Result<(), SynapseError> {
219 self.fetch_mermaid_ink("svg", path).await
220 }
221
222 async fn fetch_mermaid_ink(
223 &self,
224 endpoint: &str,
225 path: impl AsRef<Path>,
226 ) -> Result<(), SynapseError> {
227 use base64::Engine;
228
229 let mermaid = self.draw_mermaid();
230 let encoded = base64::engine::general_purpose::URL_SAFE.encode(mermaid.as_bytes());
231 let url = format!("https://mermaid.ink/{endpoint}/{encoded}");
232
233 let response = reqwest::get(&url)
234 .await
235 .map_err(|e| SynapseError::Graph(format!("mermaid.ink request failed: {e}")))?;
236
237 if !response.status().is_success() {
238 return Err(SynapseError::Graph(format!(
239 "mermaid.ink returned status {}",
240 response.status()
241 )));
242 }
243
244 let bytes = response.bytes().await.map_err(|e| {
245 SynapseError::Graph(format!("failed to read mermaid.ink response: {e}"))
246 })?;
247
248 std::fs::write(path, &bytes)
249 .map_err(|e| SynapseError::Graph(format!("failed to write image file: {e}")))?;
250
251 Ok(())
252 }
253
254 pub fn draw_png(&self, path: impl AsRef<Path>) -> Result<(), SynapseError> {
260 let dot = self.draw_dot();
261
262 let mut child = std::process::Command::new("dot")
263 .args(["-Tpng"])
264 .stdin(std::process::Stdio::piped())
265 .stdout(std::process::Stdio::piped())
266 .stderr(std::process::Stdio::piped())
267 .spawn()
268 .map_err(|e| {
269 SynapseError::Graph(format!(
270 "failed to run 'dot' command (is Graphviz installed?): {e}"
271 ))
272 })?;
273
274 child
275 .stdin
276 .take()
277 .unwrap()
278 .write_all(dot.as_bytes())
279 .map_err(|e| SynapseError::Graph(format!("failed to write to dot stdin: {e}")))?;
280
281 let output = child
282 .wait_with_output()
283 .map_err(|e| SynapseError::Graph(format!("dot command failed: {e}")))?;
284
285 if !output.status.success() {
286 let stderr = String::from_utf8_lossy(&output.stderr);
287 return Err(SynapseError::Graph(format!("dot command failed: {stderr}")));
288 }
289
290 std::fs::write(path, &output.stdout)
291 .map_err(|e| SynapseError::Graph(format!("failed to write PNG file: {e}")))?;
292
293 Ok(())
294 }
295}
296
297impl<S: State> fmt::Display for CompiledGraph<S> {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 write!(f, "{}", self.draw_ascii())
300 }
301}