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 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 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 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 pub fn generate_dot(&self, graph: &KnowledgeGraph) -> Result<String, KnowledgeGraphError> {
158 self.generate_dot_with_options(graph, DotOptions::default())
159 }
160
161 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 let mut visited: HashSet<String> = HashSet::new();
191 let mut roots: Vec<_> = graph
193 .files
194 .keys()
195 .filter(|p| graph.get_module_parent(p).is_none())
196 .cloned()
197 .collect();
198 roots.sort();
200 for root in roots {
201 self.write_module_cluster(graph, &root, &mut s, &mut visited, opts.theme);
202 }
203 } else {
204 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 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 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 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 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 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 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 let no_close = "<svg>";
427 let out2 = enhance_svg(no_close);
428 assert!(out2.contains("<style>"));
429 }
430}