rust_relations_explorer/visualization/
mod.rs

1use crate::errors::KnowledgeGraphError;
2use crate::graph::{ItemType, KnowledgeGraph, RelationshipType};
3use std::collections::HashSet;
4use std::fmt::Write as _;
5
6#[derive(Debug, Clone, Copy)]
7pub enum DotTheme {
8    Light,
9    Dark,
10}
11
12#[derive(Debug, Clone, Copy)]
13pub enum RankDir {
14    LR,
15    TB,
16}
17
18#[derive(Debug, Clone, Copy)]
19pub enum EdgeStyle {
20    Curved,
21    Ortho,
22    Polyline,
23}
24
25#[derive(Debug, Clone, Copy)]
26pub struct DotOptions {
27    pub clusters: bool,
28    pub legend: bool,
29    pub theme: DotTheme,
30    pub rankdir: RankDir,
31    pub splines: EdgeStyle,
32    pub rounded: bool,
33}
34
35impl Default for DotOptions {
36    fn default() -> Self {
37        Self {
38            clusters: true,
39            legend: true,
40            theme: DotTheme::Light,
41            rankdir: RankDir::LR,
42            splines: EdgeStyle::Polyline,
43            rounded: true,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy)]
49pub struct SvgOptions {
50    pub dot: DotOptions,
51    pub interactive: bool,
52}
53
54impl Default for SvgOptions {
55    fn default() -> Self {
56        Self { dot: DotOptions::default(), interactive: true }
57    }
58}
59
60#[derive(Debug, Default)]
61pub struct SvgGenerator;
62
63impl SvgGenerator {
64    #[must_use]
65    pub fn new() -> Self {
66        Self {}
67    }
68
69    /// Generate an SVG rendering using Graphviz.
70    ///
71    /// # Errors
72    /// Returns `KnowledgeGraphError::Visualization` if invoking Graphviz fails,
73    /// if the process exits with a non-success status, or if its output is not valid UTF-8.
74    pub fn generate_svg_with_options(
75        &self,
76        graph: &KnowledgeGraph,
77        opts: SvgOptions,
78    ) -> Result<String, KnowledgeGraphError> {
79        let dot = DotGenerator::new().generate_dot_with_options(graph, opts.dot)?;
80        // Render with Graphviz `dot -Tsvg`
81        let output = std::process::Command::new("dot")
82            .arg("-Tsvg")
83            .stdin(std::process::Stdio::piped())
84            .stdout(std::process::Stdio::piped())
85            .spawn()
86            .and_then(|mut child| {
87                use std::io::Write;
88                if let Some(stdin) = child.stdin.as_mut() {
89                    stdin.write_all(dot.as_bytes())?;
90                }
91                child.wait_with_output()
92            })
93            .map_err(|e| {
94                KnowledgeGraphError::Visualization(format!("Failed to run graphviz 'dot': {e}"))
95            })?;
96        if !output.status.success() {
97            return Err(KnowledgeGraphError::Visualization(format!(
98                "Graphviz 'dot' failed with code {:?}",
99                output.status.code()
100            )));
101        }
102        let mut svg = String::from_utf8(output.stdout).map_err(|e| {
103            KnowledgeGraphError::Visualization(format!("Invalid UTF-8 from dot: {e}"))
104        })?;
105        if opts.interactive {
106            svg = enhance_svg(&svg);
107        }
108        Ok(svg)
109    }
110}
111
112fn enhance_svg(svg: &str) -> String {
113    // Inject minimal CSS/JS for hover highlight and clickable nodes
114    let injection = r"
115<style>
116svg .node:hover ellipse, svg .node:hover polygon, svg .node:hover path, svg .node:hover rect { filter: brightness(1.15); stroke-width: 2; }
117svg .edge:hover path { stroke-width: 2.2; }
118</style>
119<script><![CDATA[
120(function(){
121  document.querySelectorAll('g.node a').forEach(function(a){
122    a.addEventListener('click', function(ev){
123      ev.preventDefault();
124      const href = a.getAttribute('xlink:href') || a.getAttribute('href');
125      if (href) { console.log('clicked', href); }
126    });
127  });
128})();
129]]></script>
130";
131    if let Some(pos) = svg.rfind("</svg>") {
132        let mut out = String::with_capacity(svg.len() + injection.len());
133        out.push_str(&svg[..pos]);
134        out.push_str(injection);
135        out.push_str(&svg[pos..]);
136        out
137    } else {
138        let mut out = svg.to_string();
139        out.push_str(injection);
140        out
141    }
142}
143
144#[derive(Debug, Default)]
145pub struct DotGenerator;
146
147impl DotGenerator {
148    #[must_use]
149    pub fn new() -> Self {
150        Self {}
151    }
152
153    /// Generate DOT with default options.
154    ///
155    /// # Errors
156    /// Returns a `KnowledgeGraphError` if DOT generation fails for any reason.
157    pub fn generate_dot(&self, graph: &KnowledgeGraph) -> Result<String, KnowledgeGraphError> {
158        self.generate_dot_with_options(graph, DotOptions::default())
159    }
160
161    /// Generate DOT with the given `opts`.
162    ///
163    /// # Errors
164    /// Returns a `KnowledgeGraphError` if DOT generation fails for any reason.
165    pub fn generate_dot_with_options(
166        &self,
167        graph: &KnowledgeGraph,
168        opts: DotOptions,
169    ) -> Result<String, KnowledgeGraphError> {
170        let mut s = String::new();
171        s.push_str("digraph KnowledgeRS\n{");
172        s.push('\n');
173        let rank = match opts.rankdir {
174            RankDir::LR => "LR",
175            RankDir::TB => "TB",
176        };
177        let splines = match opts.splines {
178            EdgeStyle::Curved => "curved",
179            EdgeStyle::Ortho => "ortho",
180            EdgeStyle::Polyline => "polyline",
181        };
182        let node_style = if opts.rounded { "filled,rounded" } else { "filled" };
183        let _ = write!(
184            s,
185            "  rankdir={rank};\n  graph [fontname=Helvetica, splines={splines}] ;\n  node [shape=box, fontsize=10, style=\"{node_style}\"] ;\n  edge [fontname=Helvetica, fontsize=9];\n"
186        );
187
188        if opts.clusters {
189            // Build hierarchical clusters from module roots
190            let mut visited: HashSet<String> = HashSet::new();
191            // Identify roots: files without a module parent
192            let mut roots: Vec<_> = graph
193                .files
194                .keys()
195                .filter(|p| graph.get_module_parent(p).is_none())
196                .cloned()
197                .collect();
198            // Stable order for determinism
199            roots.sort();
200            for root in roots {
201                self.write_module_cluster(graph, &root, &mut s, &mut visited, opts.theme);
202            }
203        } else {
204            // No clusters: emit all nodes flat
205            let mut paths: Vec<_> = graph.files.keys().cloned().collect();
206            paths.sort();
207            for path in paths {
208                if let Some(file) = graph.files.get(&path) {
209                    for item in &file.items {
210                        let node_id = sanitize_id(&item.id.0);
211                        let (fill, shape) = style_for_item_with_theme(&item.item_type, opts.theme);
212                        let url = format!("item://{node_id}");
213                        let tooltip = escape_label(&item.name);
214                        let _ = writeln!(
215                            s,
216                            "  \"{node_id}\" [label=\"{}\", fillcolor=\"{fill}\", shape=\"{shape}\", URL=\"{url}\", tooltip=\"{tooltip}\"];",
217                            escape_label(&item.name)
218                        );
219                    }
220                }
221            }
222        }
223
224        // Emit edges (relationships)
225        for rel in &graph.relationships {
226            let from = sanitize_id(&rel.from_item.0);
227            let to = sanitize_id(&rel.to_item.0);
228            let (label, color, style) = match &rel.relationship_type {
229                RelationshipType::Uses { import_type } => {
230                    (format!("uses:{import_type}"), "#1f77b4", "dashed")
231                }
232                RelationshipType::Implements { trait_name } => {
233                    (format!("impl:{trait_name}"), "#2ca02c", "dotted")
234                }
235                RelationshipType::Contains { containment_type } => {
236                    (format!("contains:{containment_type}"), "#7f7f7f", "solid")
237                }
238                RelationshipType::Extends { extension_type } => {
239                    (format!("extends:{extension_type}"), "#9467bd", "dashed")
240                }
241                RelationshipType::Calls { call_type } => {
242                    (format!("calls:{call_type}"), "#d62728", "solid")
243                }
244            };
245            let penwidth = 0.8_f64.max(rel.strength).min(3.0);
246            let _ = writeln!(
247                s,
248                "  \"{from}\" -> \"{to}\" [label=\"{}\", color=\"{color}\", style=\"{style}\", penwidth={penwidth}];",
249                escape_label(&label)
250            );
251        }
252
253        if opts.legend {
254            // Legend cluster
255            s.push_str("  subgraph cluster_legend {\n    label=\"Legend\";\n    color=grey;\n");
256            let legend_items = [
257                ("Module", ItemType::Module { is_inline: false }),
258                ("Function", ItemType::Function { is_async: false, is_const: false }),
259                ("Struct", ItemType::Struct { is_tuple: false }),
260                ("Enum", ItemType::Enum { variant_count: 0 }),
261                ("Trait", ItemType::Trait { is_object_safe: false }),
262            ];
263            for (name, t) in legend_items {
264                let (fill, shape) = style_for_item_with_theme(&t, opts.theme);
265                let id = sanitize_id(&format!("legend_{name}"));
266                let _ = writeln!(
267                    s,
268                    "    \"{id}\" [label=\"{name}\", fillcolor=\"{fill}\", shape=\"{shape}\"]; "
269                );
270            }
271            s.push_str("  }\n");
272        }
273
274        s.push_str("}\n");
275        Ok(s)
276    }
277
278    #[allow(clippy::only_used_in_recursion)]
279    fn write_module_cluster(
280        &self,
281        graph: &KnowledgeGraph,
282        path: &std::path::PathBuf,
283        out: &mut String,
284        visited: &mut HashSet<String>,
285        theme: DotTheme,
286    ) {
287        let key = path.to_string_lossy().to_string();
288        if !visited.insert(key.clone()) {
289            return;
290        }
291        let cluster_id = format!("cluster_{}", sanitize_id(&key));
292        let label = path.file_name().and_then(|p| p.to_str()).unwrap_or("");
293        let _ = write!(
294            out,
295            "  subgraph \"{cluster_id}\" {{\n    label=\"{}\";\n    color=lightgrey;\n",
296            escape_label(label)
297        );
298
299        if let Some(file) = graph.files.get(path) {
300            for item in &file.items {
301                let node_id = sanitize_id(&item.id.0);
302                let (fill, shape) = style_for_item_with_theme(&item.item_type, theme);
303                let url = format!("item://{node_id}");
304                let tooltip = escape_label(&item.name);
305                let _ = writeln!(
306                    out,
307                    "    \"{node_id}\" [label=\"{}\", fillcolor=\"{fill}\", shape=\"{shape}\", URL=\"{url}\", tooltip=\"{tooltip}\"];",
308                    escape_label(&item.name)
309                );
310            }
311        }
312
313        // Children
314        for child in graph.get_module_children(path) {
315            self.write_module_cluster(graph, child, out, visited, theme);
316        }
317        out.push_str("  }\n");
318    }
319}
320
321fn sanitize_id(s: &str) -> String {
322    s.chars()
323        .map(|c| match c {
324            'a'..='z' | 'A'..='Z' | '0'..='9' | '_' => c,
325            _ => '_',
326        })
327        .collect()
328}
329
330fn escape_label(s: &str) -> String {
331    s.replace('"', "\\\"")
332}
333
334fn style_for_item_with_theme(t: &ItemType, theme: DotTheme) -> (&'static str, &'static str) {
335    match (theme, t) {
336        (DotTheme::Light, ItemType::Module { .. }) => ("#e0f3ff", "component"),
337        (DotTheme::Light, ItemType::Function { .. }) => ("#e8ffe0", "oval"),
338        (DotTheme::Light, ItemType::Struct { .. }) => ("#fff4e0", "box"),
339        (DotTheme::Light, ItemType::Enum { .. }) => ("#ffe0f0", "hexagon"),
340        (DotTheme::Light, ItemType::Trait { .. }) => ("#f0e0ff", "parallelogram"),
341        (DotTheme::Light, ItemType::Impl { .. }) => ("#f0fff0", "box3d"),
342        (DotTheme::Light, ItemType::Const) => ("#ffffe0", "note"),
343        (DotTheme::Light, ItemType::Static { .. }) => ("#ffffe0", "folder"),
344        (DotTheme::Light, ItemType::Type) => ("#f0ffff", "box"),
345        (DotTheme::Light, ItemType::Macro) => ("#e0ffe8", "cds"),
346
347        (DotTheme::Dark, ItemType::Module { .. }) => ("#124559", "component"),
348        (DotTheme::Dark, ItemType::Function { .. }) => ("#0b6e4f", "oval"),
349        (DotTheme::Dark, ItemType::Struct { .. }) => ("#7a4c00", "box"),
350        (DotTheme::Dark, ItemType::Enum { .. }) => ("#6a1e44", "hexagon"),
351        (DotTheme::Dark, ItemType::Trait { .. }) => ("#3c2a5a", "parallelogram"),
352        (DotTheme::Dark, ItemType::Impl { .. }) => ("#1a5e1a", "box3d"),
353        (DotTheme::Dark, ItemType::Const) => ("#6b6b00", "note"),
354        (DotTheme::Dark, ItemType::Static { .. }) => ("#6b6b00", "folder"),
355        (DotTheme::Dark, ItemType::Type) => ("#004f4f", "box"),
356        (DotTheme::Dark, ItemType::Macro) => ("#0f5e3a", "cds"),
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::graph::ItemType;
364
365    #[test]
366    fn test_sanitize_id_basic() {
367        let s = "file:/path/to:thing.rs#1";
368        let got = sanitize_id(s);
369        // Allowed: letters, numbers, underscore; others -> underscore
370        assert_eq!(got, "file__path_to_thing_rs_1");
371        assert_eq!(sanitize_id("abc_DEF012"), "abc_DEF012");
372    }
373
374    #[test]
375    fn test_escape_label_quotes() {
376        let s = "a\"b\"c";
377        let got = escape_label(s);
378        assert_eq!(got, "a\\\"b\\\"c");
379    }
380
381    #[test]
382    fn test_style_for_item_with_theme_all_variants() {
383        // Light theme expectations
384        let cases_light: Vec<(ItemType, (&str, &str))> = vec![
385            (ItemType::Module { is_inline: false }, ("#e0f3ff", "component")),
386            (ItemType::Function { is_async: false, is_const: false }, ("#e8ffe0", "oval")),
387            (ItemType::Struct { is_tuple: false }, ("#fff4e0", "box")),
388            (ItemType::Enum { variant_count: 0 }, ("#ffe0f0", "hexagon")),
389            (ItemType::Trait { is_object_safe: false }, ("#f0e0ff", "parallelogram")),
390            (ItemType::Impl { trait_name: None, type_name: "T".into() }, ("#f0fff0", "box3d")),
391            (ItemType::Const, ("#ffffe0", "note")),
392            (ItemType::Static { is_mut: false }, ("#ffffe0", "folder")),
393            (ItemType::Type, ("#f0ffff", "box")),
394            (ItemType::Macro, ("#e0ffe8", "cds")),
395        ];
396        for (t, expected) in cases_light {
397            assert_eq!(style_for_item_with_theme(&t, DotTheme::Light), expected);
398        }
399
400        // Dark theme expectations
401        let cases_dark: Vec<(ItemType, (&str, &str))> = vec![
402            (ItemType::Module { is_inline: false }, ("#124559", "component")),
403            (ItemType::Function { is_async: false, is_const: false }, ("#0b6e4f", "oval")),
404            (ItemType::Struct { is_tuple: false }, ("#7a4c00", "box")),
405            (ItemType::Enum { variant_count: 0 }, ("#6a1e44", "hexagon")),
406            (ItemType::Trait { is_object_safe: false }, ("#3c2a5a", "parallelogram")),
407            (ItemType::Impl { trait_name: None, type_name: "T".into() }, ("#1a5e1a", "box3d")),
408            (ItemType::Const, ("#6b6b00", "note")),
409            (ItemType::Static { is_mut: false }, ("#6b6b00", "folder")),
410            (ItemType::Type, ("#004f4f", "box")),
411            (ItemType::Macro, ("#0f5e3a", "cds")),
412        ];
413        for (t, expected) in cases_dark {
414            assert_eq!(style_for_item_with_theme(&t, DotTheme::Dark), expected);
415        }
416    }
417
418    #[test]
419    fn test_enhance_svg_injection() {
420        let minimal = "<svg></svg>";
421        let out = enhance_svg(minimal);
422        assert!(out.contains("<style>"));
423        assert!(out.ends_with("</svg>"));
424
425        // No closing tag case
426        let no_close = "<svg>";
427        let out2 = enhance_svg(no_close);
428        assert!(out2.contains("<style>"));
429    }
430}