1use anyhow::Result;
4use codeprism_core::{Edge, EdgeKind, Node, NodeKind};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fmt;
8
9#[derive(Debug, Clone)]
11pub struct GraphVizExporter {
12 config: GraphVizConfig,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GraphVizConfig {
18 pub graph_name: String,
19 pub graph_type: GraphType,
20 pub node_options: NodeStyle,
21 pub edge_options: EdgeStyle,
22 pub layout_engine: LayoutEngine,
23 pub include_node_labels: bool,
24 pub include_edge_labels: bool,
25 pub max_label_length: usize,
26 pub group_by_type: bool,
27}
28
29impl Default for GraphVizConfig {
30 fn default() -> Self {
31 Self {
32 graph_name: "ast_graph".to_string(),
33 graph_type: GraphType::Directed,
34 node_options: NodeStyle::default(),
35 edge_options: EdgeStyle::default(),
36 layout_engine: LayoutEngine::Dot,
37 include_node_labels: true,
38 include_edge_labels: true,
39 max_label_length: 20,
40 group_by_type: false,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
47pub enum GraphType {
48 Directed,
49 Undirected,
50}
51
52#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
54pub enum LayoutEngine {
55 Dot,
56 Neato,
57 Circo,
58 Fdp,
59 Sfdp,
60 Twopi,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct NodeStyle {
66 pub shape: String,
67 pub color: String,
68 pub fillcolor: String,
69 pub style: String,
70 pub fontname: String,
71 pub fontsize: u32,
72 pub node_type_colors: HashMap<String, String>,
73}
74
75impl Default for NodeStyle {
76 fn default() -> Self {
77 let mut node_type_colors = HashMap::new();
78 node_type_colors.insert("Function".to_string(), "#e1f5fe".to_string());
79 node_type_colors.insert("Class".to_string(), "#f3e5f5".to_string());
80 node_type_colors.insert("Variable".to_string(), "#fff3e0".to_string());
81 node_type_colors.insert("Import".to_string(), "#e8f5e8".to_string());
82
83 Self {
84 shape: "box".to_string(),
85 color: "black".to_string(),
86 fillcolor: "#f5f5f5".to_string(),
87 style: "filled".to_string(),
88 fontname: "Arial".to_string(),
89 fontsize: 10,
90 node_type_colors,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct EdgeStyle {
98 pub color: String,
99 pub style: String,
100 pub arrowhead: String,
101 pub fontname: String,
102 pub fontsize: u32,
103 pub edge_type_colors: HashMap<String, String>,
104}
105
106impl Default for EdgeStyle {
107 fn default() -> Self {
108 let mut edge_type_colors = HashMap::new();
109 edge_type_colors.insert("Calls".to_string(), "#2196f3".to_string());
110 edge_type_colors.insert("Imports".to_string(), "#4caf50".to_string());
111 edge_type_colors.insert("Reads".to_string(), "#ff9800".to_string());
112 edge_type_colors.insert("Writes".to_string(), "#f44336".to_string());
113
114 Self {
115 color: "gray".to_string(),
116 style: "solid".to_string(),
117 arrowhead: "normal".to_string(),
118 fontname: "Arial".to_string(),
119 fontsize: 8,
120 edge_type_colors,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct GraphVizOptions {
128 pub title: Option<String>,
129 pub subtitle: Option<String>,
130 pub highlight_nodes: Vec<String>,
131 pub highlight_edges: Vec<String>,
132 pub filter_node_types: Option<Vec<NodeKind>>,
133 pub filter_edge_types: Option<Vec<EdgeKind>>,
134 pub cluster_by_file: bool,
135 pub show_spans: bool,
136}
137
138impl GraphVizExporter {
139 pub fn new() -> Self {
141 Self {
142 config: GraphVizConfig::default(),
143 }
144 }
145
146 pub fn with_config(config: GraphVizConfig) -> Self {
148 Self { config }
149 }
150
151 pub fn export_nodes_and_edges(&self, nodes: &[Node], edges: &[Edge]) -> Result<String> {
153 self.export_with_options(nodes, edges, &GraphVizOptions::default())
154 }
155
156 pub fn export_with_options(
158 &self,
159 nodes: &[Node],
160 edges: &[Edge],
161 options: &GraphVizOptions,
162 ) -> Result<String> {
163 let mut dot = String::new();
164
165 let graph_keyword = match self.config.graph_type {
167 GraphType::Directed => "digraph",
168 GraphType::Undirected => "graph",
169 };
170
171 dot.push_str(&format!(
172 "{} {} {{\n",
173 graph_keyword, self.config.graph_name
174 ));
175
176 self.write_graph_attributes(&mut dot, options);
178
179 let filtered_nodes: Vec<_> = if let Some(ref filter_types) = options.filter_node_types {
181 nodes
182 .iter()
183 .filter(|n| filter_types.contains(&n.kind))
184 .collect()
185 } else {
186 nodes.iter().collect()
187 };
188
189 if options.cluster_by_file {
191 self.write_clustered_nodes(&mut dot, &filtered_nodes, options)?;
192 } else {
193 self.write_nodes(&mut dot, &filtered_nodes, options)?;
194 }
195
196 let filtered_edges: Vec<_> = if let Some(ref filter_types) = options.filter_edge_types {
198 edges
199 .iter()
200 .filter(|e| filter_types.contains(&e.kind))
201 .collect()
202 } else {
203 edges.iter().collect()
204 };
205
206 self.write_edges(&mut dot, &filtered_edges, &filtered_nodes, options)?;
207
208 dot.push_str("}\n");
209
210 Ok(dot)
211 }
212
213 fn write_graph_attributes(&self, dot: &mut String, options: &GraphVizOptions) {
215 dot.push_str(" // Graph attributes\n");
216 dot.push_str(&format!(" layout=\"{:?}\";\n", self.config.layout_engine).to_lowercase());
217 dot.push_str(" rankdir=\"TB\";\n");
218 dot.push_str(" splines=\"ortho\";\n");
219 dot.push_str(" nodesep=\"0.5\";\n");
220 dot.push_str(" ranksep=\"1.0\";\n");
221
222 if let Some(ref title) = options.title {
224 dot.push_str(&format!(" label=\"{}\";\n", self.escape_label(title)));
225 dot.push_str(" fontsize=\"16\";\n");
226 dot.push_str(" fontname=\"Arial Bold\";\n");
227 }
228
229 dot.push_str(" // Default node attributes\n");
231 dot.push_str(&format!(" node [shape=\"{}\", style=\"{}\", fillcolor=\"{}\", color=\"{}\", fontname=\"{}\", fontsize=\"{}\"];\n",
232 self.config.node_options.shape,
233 self.config.node_options.style,
234 self.config.node_options.fillcolor,
235 self.config.node_options.color,
236 self.config.node_options.fontname,
237 self.config.node_options.fontsize
238 ));
239
240 dot.push_str(" // Default edge attributes\n");
241 dot.push_str(&format!(" edge [color=\"{}\", style=\"{}\", arrowhead=\"{}\", fontname=\"{}\", fontsize=\"{}\"];\n",
242 self.config.edge_options.color,
243 self.config.edge_options.style,
244 self.config.edge_options.arrowhead,
245 self.config.edge_options.fontname,
246 self.config.edge_options.fontsize
247 ));
248
249 dot.push('\n');
250 }
251
252 fn write_nodes(
254 &self,
255 dot: &mut String,
256 nodes: &[&Node],
257 options: &GraphVizOptions,
258 ) -> Result<()> {
259 dot.push_str(" // Nodes\n");
260
261 for node in nodes {
262 let node_id = self.sanitize_id(&node.id.to_hex());
263 let mut attributes = Vec::new();
264
265 if self.config.include_node_labels {
267 let label = self.create_node_label(node, options);
268 attributes.push(format!("label=\"{}\"", self.escape_label(&label)));
269 }
270
271 let node_type_str = format!("{:?}", node.kind);
273 if let Some(color) = self
274 .config
275 .node_options
276 .node_type_colors
277 .get(&node_type_str)
278 {
279 attributes.push(format!("fillcolor=\"{color}\""));
280 }
281
282 if options.highlight_nodes.contains(&node.id.to_hex()) {
284 attributes.push("penwidth=\"3\"".to_string());
285 attributes.push("color=\"red\"".to_string());
286 }
287
288 if attributes.is_empty() {
290 dot.push_str(&format!(" {node_id};\n"));
291 } else {
292 dot.push_str(&format!(" {} [{}];\n", node_id, attributes.join(", ")));
293 }
294 }
295
296 dot.push('\n');
297 Ok(())
298 }
299
300 fn write_clustered_nodes(
302 &self,
303 dot: &mut String,
304 nodes: &[&Node],
305 options: &GraphVizOptions,
306 ) -> Result<()> {
307 let mut file_groups: HashMap<_, Vec<_>> = HashMap::new();
308
309 for node in nodes {
310 let file_path = node.file.to_string_lossy();
311 file_groups
312 .entry(file_path.to_string())
313 .or_default()
314 .push(*node);
315 }
316
317 dot.push_str(" // Clustered nodes by file\n");
318
319 for (i, (file_path, file_nodes)) in file_groups.iter().enumerate() {
320 dot.push_str(&format!(" subgraph cluster_{i} {{\n"));
321 dot.push_str(&format!(
322 " label=\"{}\";\n",
323 self.escape_label(file_path)
324 ));
325 dot.push_str(" style=\"filled\";\n");
326 dot.push_str(" fillcolor=\"#f0f0f0\";\n");
327 dot.push_str(" color=\"gray\";\n");
328
329 for node in file_nodes {
330 let node_id = self.sanitize_id(&node.id.to_hex());
331 let label = if self.config.include_node_labels {
332 self.create_node_label(node, options)
333 } else {
334 node.name.clone()
335 };
336
337 dot.push_str(&format!(
338 " {} [label=\"{}\"];\n",
339 node_id,
340 self.escape_label(&label)
341 ));
342 }
343
344 dot.push_str(" }\n");
345 }
346
347 dot.push('\n');
348 Ok(())
349 }
350
351 fn write_edges(
353 &self,
354 dot: &mut String,
355 edges: &[&Edge],
356 nodes: &[&Node],
357 options: &GraphVizOptions,
358 ) -> Result<()> {
359 let node_ids: std::collections::HashSet<_> = nodes.iter().map(|n| &n.id).collect();
360
361 dot.push_str(" // Edges\n");
362
363 let edge_connector = match self.config.graph_type {
364 GraphType::Directed => "->",
365 GraphType::Undirected => "--",
366 };
367
368 for edge in edges {
369 if !node_ids.contains(&edge.source) || !node_ids.contains(&edge.target) {
371 continue;
372 }
373
374 let source_id = self.sanitize_id(&edge.source.to_hex());
375 let target_id = self.sanitize_id(&edge.target.to_hex());
376
377 let mut attributes = Vec::new();
378
379 if self.config.include_edge_labels {
381 let label = format!("{:?}", edge.kind);
382 attributes.push(format!("label=\"{}\"", self.escape_label(&label)));
383 }
384
385 let edge_type_str = format!("{:?}", edge.kind);
387 if let Some(color) = self
388 .config
389 .edge_options
390 .edge_type_colors
391 .get(&edge_type_str)
392 {
393 attributes.push(format!("color=\"{color}\""));
394 }
395
396 let edge_id = format!("{}->{}", edge.source.to_hex(), edge.target.to_hex());
398 if options.highlight_edges.contains(&edge_id) {
399 attributes.push("penwidth=\"3\"".to_string());
400 attributes.push("color=\"red\"".to_string());
401 }
402
403 if attributes.is_empty() {
405 dot.push_str(&format!(" {source_id} {edge_connector} {target_id};\n"));
406 } else {
407 dot.push_str(&format!(
408 " {} {} {} [{}];\n",
409 source_id,
410 edge_connector,
411 target_id,
412 attributes.join(", ")
413 ));
414 }
415 }
416
417 dot.push('\n');
418 Ok(())
419 }
420
421 fn create_node_label(&self, node: &Node, options: &GraphVizOptions) -> String {
423 let mut label = node.name.clone();
424
425 if label.len() > self.config.max_label_length {
426 label.truncate(self.config.max_label_length - 3);
427 label.push_str("...");
428 }
429
430 if self.config.group_by_type {
432 label = format!("{}\n({:?})", label, node.kind);
433 }
434
435 if options.show_spans {
437 label = format!(
438 "{}\n[{}..{}]",
439 label, node.span.start_byte, node.span.end_byte
440 );
441 }
442
443 label
444 }
445
446 fn sanitize_id(&self, id: &str) -> String {
448 format!("node_{}", id.replace('-', "_"))
449 }
450
451 fn escape_label(&self, label: &str) -> String {
453 label
454 .replace('\\', "\\\\")
455 .replace('"', "\\\"")
456 .replace('\n', "\\n")
457 .replace('\r', "\\r")
458 .replace('\t', "\\t")
459 }
460
461 pub fn export_syntax_tree(&self, tree: &tree_sitter::Tree, source: &str) -> Result<String> {
463 let root_node = tree.root_node();
464 let mut dot = String::new();
465
466 dot.push_str(&format!("digraph {} {{\n", self.config.graph_name));
467 dot.push_str(" rankdir=\"TB\";\n");
468 dot.push_str(" node [shape=\"box\", style=\"filled\", fillcolor=\"lightblue\"];\n");
469
470 self.export_syntax_node_recursive(&mut dot, &root_node, source, 0)?;
471
472 dot.push_str("}\n");
473 Ok(dot)
474 }
475
476 fn export_syntax_node_recursive(
478 &self,
479 dot: &mut String,
480 node: &tree_sitter::Node,
481 source: &str,
482 depth: usize,
483 ) -> Result<()> {
484 let node_id = format!("syntax_{}_{}", depth, node.start_byte());
485 let mut label = node.kind().to_string();
486
487 if node.child_count() == 0 {
489 if let Ok(text) = node.utf8_text(source.as_bytes()) {
490 if text.len() <= 20 {
491 label = format!("{}\\n\"{}\"", label, self.escape_label(text));
492 } else {
493 label = format!("{}\\n\"{}...\"", label, self.escape_label(&text[..17]));
494 }
495 }
496 }
497
498 dot.push_str(&format!(" {node_id} [label=\"{label}\"];\n"));
499
500 for i in 0..node.child_count() {
502 if let Some(child) = node.child(i) {
503 let child_id = format!("syntax_{}_{}", depth + 1, child.start_byte());
504 dot.push_str(&format!(" {node_id} -> {child_id};\n"));
505 self.export_syntax_node_recursive(dot, &child, source, depth + 1)?;
506 }
507 }
508
509 Ok(())
510 }
511}
512
513impl Default for GraphVizExporter {
514 fn default() -> Self {
515 Self::new()
516 }
517}
518
519impl fmt::Display for LayoutEngine {
520 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521 match self {
522 LayoutEngine::Dot => write!(f, "dot"),
523 LayoutEngine::Neato => write!(f, "neato"),
524 LayoutEngine::Circo => write!(f, "circo"),
525 LayoutEngine::Fdp => write!(f, "fdp"),
526 LayoutEngine::Sfdp => write!(f, "sfdp"),
527 LayoutEngine::Twopi => write!(f, "twopi"),
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use codeprism_core::{EdgeKind, Language, NodeKind, Span};
536 use std::path::PathBuf;
537
538 fn create_test_node(_id: u64, name: &str, kind: NodeKind) -> Node {
539 let path = PathBuf::from("test.rs");
540 let span = Span::new(0, 10, 1, 1, 1, 10);
541 let repo_id = "test_repo";
542
543 Node {
544 id: codeprism_core::NodeId::new(repo_id, &path, &span, &kind),
545 kind,
546 name: name.to_string(),
547 file: path,
548 span,
549 lang: Language::Rust,
550 metadata: Default::default(),
551 signature: Default::default(),
552 }
553 }
554
555 fn create_test_edge(source_id: &str, target_id: &str, kind: EdgeKind) -> Edge {
556 Edge {
557 source: codeprism_core::NodeId::from_hex(source_id).unwrap(),
558 target: codeprism_core::NodeId::from_hex(target_id).unwrap(),
559 kind,
560 }
561 }
562
563 #[test]
564 fn test_graphviz_exporter_creation() {
565 let exporter = GraphVizExporter::new();
566 assert_eq!(exporter.config.graph_name, "ast_graph");
567 assert!(matches!(exporter.config.graph_type, GraphType::Directed));
568 }
569
570 #[test]
571 fn test_sanitize_id() {
572 let exporter = GraphVizExporter::new();
573 let sanitized = exporter.sanitize_id("abc-def-123");
574 assert_eq!(sanitized, "node_abc_def_123");
575 }
576
577 #[test]
578 fn test_escape_label() {
579 let exporter = GraphVizExporter::new();
580 let escaped = exporter.escape_label("test\n\"quoted\"");
581 assert_eq!(escaped, "test\\n\\\"quoted\\\"");
582 }
583
584 #[test]
585 fn test_export_simple_graph() {
586 let exporter = GraphVizExporter::new();
587 let nodes = vec![
588 create_test_node(1, "main", NodeKind::Function),
589 create_test_node(2, "helper", NodeKind::Function),
590 ];
591
592 let main_id = nodes[0].id.to_hex();
593 let helper_id = nodes[1].id.to_hex();
594
595 let edges = vec![create_test_edge(&main_id, &helper_id, EdgeKind::Calls)];
596
597 let dot = exporter.export_nodes_and_edges(&nodes, &edges).unwrap();
598 assert!(dot.contains("digraph ast_graph"));
599 assert!(dot.contains("node_"));
600 assert!(dot.contains("->"));
601 }
602}