Skip to main content

synaptic_graph/
visualization.rs

1use std::fmt;
2use std::io::Write;
3use std::path::Path;
4
5use synaptic_core::SynapticError;
6
7use crate::compiled::CompiledGraph;
8use crate::state::State;
9use crate::{END, START};
10
11impl<S: State> CompiledGraph<S> {
12    /// Render the graph as a Mermaid flowchart string.
13    ///
14    /// - `__start__` and `__end__` are rendered as rounded nodes `([...])`
15    /// - User nodes are rendered as rectangles `[...]`
16    /// - Fixed edges use solid arrows `-->`
17    /// - Conditional edges with path_map use dashed arrows `-.->` with labels
18    /// - Conditional edges without path_map emit a Mermaid comment
19    pub fn draw_mermaid(&self) -> String {
20        let mut lines = vec!["graph TD".to_string()];
21
22        // Collect all node names (sorted for determinism)
23        let mut node_names: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
24        node_names.sort();
25
26        // Node definitions
27        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        // Entry edge from START
34        lines.push(format!("    {START} --> {}", self.entry_point));
35
36        // Fixed edges (sorted for determinism)
37        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        // Conditional edges (sorted by source for determinism)
48        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    /// Render the graph as a simple ASCII text summary.
81    pub fn draw_ascii(&self) -> String {
82        let mut lines = vec!["Graph:".to_string()];
83
84        // Nodes (sorted)
85        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        // Entry
90        lines.push(format!("  Entry: {START} -> {}", self.entry_point));
91
92        // Edges
93        lines.push("  Edges:".to_string());
94
95        // Fixed edges (sorted)
96        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        // Conditional edges (sorted by source)
107        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    /// Render the graph in Graphviz DOT format.
141    pub fn draw_dot(&self) -> String {
142        let mut lines = vec!["digraph G {".to_string()];
143        lines.push("    rankdir=TD;".to_string());
144
145        // Node definitions (sorted)
146        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        // Entry edge
156        lines.push(format!(
157            "    \"{START}\" -> \"{}\" [style=solid];",
158            self.entry_point
159        ));
160
161        // Fixed edges (sorted)
162        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        // Conditional edges (sorted)
173        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    /// Render the Mermaid diagram as an image via the mermaid.ink API.
202    ///
203    /// Requires internet access. The generated Mermaid text is URL-safe base64-encoded
204    /// and sent to `https://mermaid.ink/img/{encoded}`. The image (JPEG format) is
205    /// written to the specified file path.
206    ///
207    /// Note: mermaid.ink returns JPEG from the `/img/` endpoint. For SVG output,
208    /// use [`draw_mermaid_svg`](Self::draw_mermaid_svg) instead.
209    pub async fn draw_mermaid_png(&self, path: impl AsRef<Path>) -> Result<(), SynapticError> {
210        self.fetch_mermaid_ink("img", path).await
211    }
212
213    /// Render the Mermaid diagram as an SVG image via the mermaid.ink API.
214    ///
215    /// Requires internet access. The generated Mermaid text is URL-safe base64-encoded
216    /// and sent to `https://mermaid.ink/svg/{encoded}`. The SVG response is written
217    /// to the specified file path.
218    pub async fn draw_mermaid_svg(&self, path: impl AsRef<Path>) -> Result<(), SynapticError> {
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<(), SynapticError> {
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| SynapticError::Graph(format!("mermaid.ink request failed: {e}")))?;
236
237        if !response.status().is_success() {
238            return Err(SynapticError::Graph(format!(
239                "mermaid.ink returned status {}",
240                response.status()
241            )));
242        }
243
244        let bytes = response.bytes().await.map_err(|e| {
245            SynapticError::Graph(format!("failed to read mermaid.ink response: {e}"))
246        })?;
247
248        std::fs::write(path, &bytes)
249            .map_err(|e| SynapticError::Graph(format!("failed to write image file: {e}")))?;
250
251        Ok(())
252    }
253
254    /// Render the graph as a PNG image using the Graphviz `dot` command.
255    ///
256    /// Requires `dot` (Graphviz) to be installed and available in `$PATH`.
257    /// The DOT output is piped to `dot -Tpng` and the resulting PNG is written
258    /// to the specified file path.
259    pub fn draw_png(&self, path: impl AsRef<Path>) -> Result<(), SynapticError> {
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                SynapticError::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| SynapticError::Graph(format!("failed to write to dot stdin: {e}")))?;
280
281        let output = child
282            .wait_with_output()
283            .map_err(|e| SynapticError::Graph(format!("dot command failed: {e}")))?;
284
285        if !output.status.success() {
286            let stderr = String::from_utf8_lossy(&output.stderr);
287            return Err(SynapticError::Graph(format!(
288                "dot command failed: {stderr}"
289            )));
290        }
291
292        std::fs::write(path, &output.stdout)
293            .map_err(|e| SynapticError::Graph(format!("failed to write PNG file: {e}")))?;
294
295        Ok(())
296    }
297}
298
299impl<S: State> fmt::Display for CompiledGraph<S> {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        write!(f, "{}", self.draw_ascii())
302    }
303}