1use std::collections::{HashMap, HashSet, VecDeque};
35use std::fmt::Write;
36
37use crate::graph::node::Language;
38use crate::graph::unified::concurrent::GraphSnapshot;
39use crate::graph::unified::edge::EdgeKind;
40use crate::graph::unified::node::NodeId;
41use crate::graph::unified::node::kind::NodeKind;
42use crate::graph::unified::storage::arena::NodeEntry;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum Direction {
51 #[default]
53 LeftToRight,
54 TopToBottom,
56}
57
58impl Direction {
59 #[must_use]
61 pub const fn as_str(self) -> &'static str {
62 match self {
63 Self::LeftToRight => "LR",
64 Self::TopToBottom => "TB",
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum EdgeFilter {
72 Calls,
74 Imports,
76 Exports,
78 References,
80 Inherits,
82 Implements,
84 FfiCall,
86 HttpRequest,
88 DbQuery,
90}
91
92impl EdgeFilter {
93 #[must_use]
95 pub fn matches(&self, kind: &EdgeKind) -> bool {
96 matches!(
97 (self, kind),
98 (EdgeFilter::Calls, EdgeKind::Calls { .. })
99 | (EdgeFilter::Imports, EdgeKind::Imports { .. })
100 | (EdgeFilter::Exports, EdgeKind::Exports { .. })
101 | (EdgeFilter::References, EdgeKind::References)
102 | (EdgeFilter::Inherits, EdgeKind::Inherits)
103 | (EdgeFilter::Implements, EdgeKind::Implements)
104 | (EdgeFilter::FfiCall, EdgeKind::FfiCall { .. })
105 | (EdgeFilter::HttpRequest, EdgeKind::HttpRequest { .. })
106 | (EdgeFilter::DbQuery, EdgeKind::DbQuery { .. })
107 )
108 }
109}
110
111#[must_use]
117pub fn language_color(lang: Language) -> &'static str {
118 match lang {
119 Language::Rust => "#dea584",
120 Language::JavaScript => "#f7df1e",
121 Language::TypeScript => "#3178c6",
122 Language::Python => "#3572A5",
123 Language::Go => "#00ADD8",
124 Language::Java => "#b07219",
125 Language::Ruby => "#701516",
126 Language::Php => "#4F5D95",
127 Language::Cpp => "#f34b7d",
128 Language::C => "#555555",
129 Language::Swift => "#F05138",
130 Language::Kotlin => "#A97BFF",
131 Language::Scala => "#c22d40",
132 Language::Sql | Language::Plsql => "#e38c00",
133 Language::Shell => "#89e051",
134 Language::Lua => "#000080",
135 Language::Perl => "#0298c3",
136 Language::Dart => "#00B4AB",
137 Language::Groovy => "#4298b8",
138 Language::Http => "#005C9C",
139 Language::Css => "#563d7c",
140 Language::Elixir => "#6e4a7e",
141 Language::R => "#198CE7",
142 Language::Haskell => "#5e5086",
143 Language::Html => "#e34c26",
144 Language::Svelte => "#ff3e00",
145 Language::Vue => "#41b883",
146 Language::Zig => "#ec915c",
147 Language::Terraform => "#5c4ee5",
148 Language::Puppet => "#302B6D",
149 Language::Pulumi => "#6d2df5",
150 Language::Apex => "#1797c0",
151 Language::Abap => "#E8274B",
152 Language::ServiceNow => "#62d84e",
153 Language::CSharp => "#178600",
154 }
155}
156
157#[must_use]
159pub const fn default_language_color() -> &'static str {
160 "#cccccc"
161}
162
163#[must_use]
165pub fn node_shape(kind: &NodeKind) -> &'static str {
166 match kind {
167 NodeKind::Class | NodeKind::Struct => "component",
168 NodeKind::Interface | NodeKind::Trait => "ellipse",
169 NodeKind::Module => "folder",
170 NodeKind::Variable | NodeKind::Constant => "note",
171 NodeKind::Enum | NodeKind::EnumVariant => "hexagon",
172 NodeKind::Type => "diamond",
173 NodeKind::Macro => "parallelogram",
174 _ => "box",
175 }
176}
177
178#[must_use]
180pub fn edge_style(kind: &EdgeKind) -> (&'static str, &'static str) {
181 match kind {
182 EdgeKind::Calls { .. } => ("solid", "#333333"),
183 EdgeKind::Imports { .. } => ("dashed", "#0066cc"),
184 EdgeKind::Exports { .. } => ("dashed", "#00cc66"),
185 EdgeKind::References => ("dotted", "#666666"),
186 EdgeKind::Inherits => ("solid", "#990099"),
187 EdgeKind::Implements => ("dashed", "#990099"),
188 EdgeKind::FfiCall { .. } => ("bold", "#ff6600"),
189 EdgeKind::HttpRequest { .. } => ("bold", "#cc0000"),
190 EdgeKind::DbQuery { .. } => ("bold", "#009900"),
191 _ => ("solid", "#666666"),
192 }
193}
194
195#[must_use]
197pub fn edge_label(
198 kind: &EdgeKind,
199 strings: &crate::graph::unified::storage::interner::StringInterner,
200) -> String {
201 match kind {
202 EdgeKind::Calls {
203 argument_count,
204 is_async,
205 } => {
206 if *is_async {
207 format!("async call({argument_count})")
208 } else {
209 format!("call({argument_count})")
210 }
211 }
212 EdgeKind::Imports { alias, is_wildcard } => {
213 if *is_wildcard {
214 "import *".to_string()
215 } else if let Some(alias_id) = alias {
216 let alias_str = strings
217 .resolve(*alias_id)
218 .map_or_else(|| "?".to_string(), |s| s.to_string());
219 format!("import as {alias_str}")
220 } else {
221 "import".to_string()
222 }
223 }
224 EdgeKind::Exports {
225 kind: export_kind,
226 alias,
227 } => {
228 let kind_str = match export_kind {
229 crate::graph::unified::edge::ExportKind::Direct => "export",
230 crate::graph::unified::edge::ExportKind::Reexport => "re-export",
231 crate::graph::unified::edge::ExportKind::Default => "default export",
232 crate::graph::unified::edge::ExportKind::Namespace => "export *",
233 };
234 if let Some(alias_id) = alias {
235 let alias_str = strings
236 .resolve(*alias_id)
237 .map_or_else(|| "?".to_string(), |s| s.to_string());
238 format!("{kind_str} as {alias_str}")
239 } else {
240 kind_str.to_string()
241 }
242 }
243 EdgeKind::References => "ref".to_string(),
244 EdgeKind::Inherits => "extends".to_string(),
245 EdgeKind::Implements => "implements".to_string(),
246 EdgeKind::FfiCall { convention } => format!("ffi:{convention:?}"),
247 EdgeKind::HttpRequest { method, .. } => method.as_str().to_string(),
248 EdgeKind::DbQuery { query_type, .. } => format!("{query_type:?}"),
249 _ => String::new(),
250 }
251}
252
253#[must_use]
255pub fn escape_dot(s: &str) -> String {
256 s.replace('\\', "\\\\")
257 .replace('"', "\\\"")
258 .replace('\n', "\\n")
259}
260
261#[must_use]
263pub fn escape_d2(s: &str) -> String {
264 s.replace('\\', "\\\\")
265 .replace('"', "\\\"")
266 .replace('\n', " ")
267}
268
269#[derive(Debug, Clone)]
275pub struct DotConfig {
276 pub filter_languages: HashSet<Language>,
278 pub filter_edges: HashSet<EdgeFilter>,
280 pub filter_files: HashSet<String>,
282 pub highlight_cross_language: bool,
284 pub max_depth: Option<usize>,
286 pub root_nodes: HashSet<NodeId>,
288 pub direction: Direction,
290 pub show_details: bool,
292 pub show_edge_labels: bool,
294}
295
296impl Default for DotConfig {
297 fn default() -> Self {
298 Self {
299 filter_languages: HashSet::new(),
300 filter_edges: HashSet::new(),
301 filter_files: HashSet::new(),
302 highlight_cross_language: false,
303 max_depth: None,
304 root_nodes: HashSet::new(),
305 direction: Direction::LeftToRight,
306 show_details: true,
307 show_edge_labels: true,
308 }
309 }
310}
311
312impl DotConfig {
313 #[must_use]
315 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
316 self.highlight_cross_language = enabled;
317 self
318 }
319
320 #[must_use]
322 pub fn with_details(mut self, enabled: bool) -> Self {
323 self.show_details = enabled;
324 self
325 }
326
327 #[must_use]
329 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
330 self.show_edge_labels = enabled;
331 self
332 }
333
334 #[must_use]
336 pub fn with_direction(mut self, direction: Direction) -> Self {
337 self.direction = direction;
338 self
339 }
340
341 #[must_use]
343 pub fn filter_language(mut self, lang: Language) -> Self {
344 self.filter_languages.insert(lang);
345 self
346 }
347
348 #[must_use]
350 pub fn filter_edge(mut self, edge: EdgeFilter) -> Self {
351 self.filter_edges.insert(edge);
352 self
353 }
354
355 #[must_use]
357 pub fn with_max_depth(mut self, depth: usize) -> Self {
358 self.max_depth = Some(depth);
359 self
360 }
361}
362
363pub struct UnifiedDotExporter<'a> {
365 graph: &'a GraphSnapshot,
366 config: DotConfig,
367}
368
369impl<'a> UnifiedDotExporter<'a> {
370 #[must_use]
372 pub fn new(graph: &'a GraphSnapshot) -> Self {
373 Self {
374 graph,
375 config: DotConfig::default(),
376 }
377 }
378
379 #[must_use]
381 pub fn with_config(graph: &'a GraphSnapshot, config: DotConfig) -> Self {
382 Self { graph, config }
383 }
384
385 #[must_use]
387 pub fn export(&self) -> String {
388 let mut dot = String::from("digraph CodeGraph {\n");
389
390 let rankdir = self.config.direction.as_str();
392 writeln!(dot, " rankdir={rankdir};").expect("write to String never fails");
393 dot.push_str(" node [shape=box, style=filled];\n");
394 dot.push_str(" overlap=false;\n");
395 dot.push_str(" splines=true;\n\n");
396
397 let visible_nodes = self.filter_nodes();
399
400 for node_id in &visible_nodes {
402 if let Some(entry) = self.graph.get_node(*node_id) {
403 self.export_node(&mut dot, *node_id, entry);
404 }
405 }
406
407 dot.push('\n');
408
409 for (from, to, kind) in self.graph.iter_edges() {
411 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
412 continue;
413 }
414
415 if !self.edge_allowed(&kind) {
416 continue;
417 }
418
419 self.export_edge(&mut dot, from, to, &kind);
420 }
421
422 dot.push_str("}\n");
423 dot
424 }
425
426 fn filter_nodes(&self) -> HashSet<NodeId> {
428 if self.config.root_nodes.is_empty() && self.config.max_depth.is_none() {
430 return self
431 .graph
432 .iter_nodes()
433 .filter(|(id, entry)| self.should_include_node(*id, entry))
434 .map(|(id, _)| id)
435 .collect();
436 }
437
438 let adjacency = self.build_adjacency();
440 let depth_limit = self.config.max_depth.unwrap_or(usize::MAX);
441 let mut visible = HashSet::new();
442
443 let starting_nodes: Vec<NodeId> = if self.config.root_nodes.is_empty() {
444 self.graph.iter_nodes().map(|(id, _)| id).collect()
445 } else {
446 self.config.root_nodes.iter().copied().collect()
447 };
448
449 for node_id in starting_nodes {
450 if visible.contains(&node_id) {
451 continue;
452 }
453 self.collect_nodes(&mut visible, &adjacency, node_id, depth_limit);
454 }
455
456 visible
457 }
458
459 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
461 if !self.config.filter_languages.is_empty() {
463 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
464 if !self.config.filter_languages.contains(&lang) {
465 return false;
466 }
467 } else {
468 return false;
469 }
470 }
471
472 if !self.config.filter_files.is_empty() {
474 if let Some(path) = self.graph.files().resolve(entry.file) {
475 let path_str = path.to_string_lossy();
476 if !self
477 .config
478 .filter_files
479 .iter()
480 .any(|f| path_str.contains(f))
481 {
482 return false;
483 }
484 } else {
485 return false;
486 }
487 }
488
489 true
490 }
491
492 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
494 if self.config.filter_edges.is_empty() {
495 return true;
496 }
497 self.config.filter_edges.iter().any(|f| f.matches(kind))
498 }
499
500 fn build_adjacency(&self) -> HashMap<NodeId, Vec<NodeId>> {
502 let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
503 for (from, to, _) in self.graph.iter_edges() {
504 adjacency.entry(from).or_default().push(to);
505 adjacency.entry(to).or_default().push(from);
506 }
507 adjacency
508 }
509
510 fn collect_nodes(
512 &self,
513 visible: &mut HashSet<NodeId>,
514 adjacency: &HashMap<NodeId, Vec<NodeId>>,
515 start: NodeId,
516 depth_limit: usize,
517 ) {
518 let mut queue = VecDeque::new();
519 queue.push_back((start, 0usize));
520
521 while let Some((node_id, depth)) = queue.pop_front() {
522 if depth > depth_limit {
523 continue;
524 }
525
526 let Some(entry) = self.graph.get_node(node_id) else {
527 continue;
528 };
529
530 if !self.should_include_node(node_id, entry) {
531 continue;
532 }
533
534 if !visible.insert(node_id) {
535 continue;
536 }
537
538 if depth == depth_limit {
539 continue;
540 }
541
542 if let Some(neighbors) = adjacency.get(&node_id) {
543 for neighbor in neighbors {
544 queue.push_back((*neighbor, depth + 1));
545 }
546 }
547 }
548 }
549
550 fn export_node(&self, dot: &mut String, node_id: NodeId, entry: &NodeEntry) {
552 let lang = self.graph.files().language_for_file(entry.file);
553 let color = lang.map_or(default_language_color(), language_color);
554 let shape = node_shape(&entry.kind);
555
556 let name = self
558 .graph
559 .strings()
560 .resolve(entry.name)
561 .unwrap_or_else(|| std::sync::Arc::from("?"));
562 let qualified_name = entry
563 .qualified_name
564 .and_then(|id| self.graph.strings().resolve(id))
565 .unwrap_or_else(|| std::sync::Arc::clone(&name));
566
567 let label = if self.config.show_details {
569 let file = self
570 .graph
571 .files()
572 .resolve(entry.file)
573 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
574 format!("{}\\n{}:{}", qualified_name, file, entry.start_line)
575 } else {
576 qualified_name.to_string()
577 };
578
579 let label = escape_dot(&label);
580 let node_key = format!("n{}", node_id.index());
581
582 writeln!(
583 dot,
584 " \"{node_key}\" [label=\"{label}\", fillcolor=\"{color}\", shape=\"{shape}\"];",
585 )
586 .expect("write to String never fails");
587 }
588
589 fn export_edge(&self, dot: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
591 let (style, base_color) = edge_style(kind);
592 let color = if self.config.highlight_cross_language
593 && let (Some(from_entry), Some(to_entry)) =
594 (self.graph.get_node(from), self.graph.get_node(to))
595 {
596 let from_lang = self.graph.files().language_for_file(from_entry.file);
597 let to_lang = self.graph.files().language_for_file(to_entry.file);
598 if from_lang == to_lang {
599 base_color
600 } else {
601 "red"
602 }
603 } else {
604 base_color
605 };
606
607 let label = if self.config.show_edge_labels {
608 edge_label(kind, self.graph.strings())
609 } else {
610 String::new()
611 };
612
613 let from_key = format!("n{}", from.index());
614 let to_key = format!("n{}", to.index());
615
616 if label.is_empty() {
617 writeln!(
618 dot,
619 " \"{from_key}\" -> \"{to_key}\" [style=\"{style}\", color=\"{color}\"];"
620 )
621 .expect("write to String never fails");
622 } else {
623 writeln!(
624 dot,
625 " \"{from_key}\" -> \"{to_key}\" [style=\"{style}\", color=\"{color}\", label=\"{}\"];",
626 escape_dot(&label)
627 )
628 .expect("write to String never fails");
629 }
630 }
631}
632
633#[derive(Debug, Clone)]
639pub struct D2Config {
640 pub filter_languages: HashSet<Language>,
642 pub filter_edges: HashSet<EdgeFilter>,
644 pub highlight_cross_language: bool,
646 pub show_details: bool,
648 pub show_edge_labels: bool,
650 pub direction: Direction,
652}
653
654impl Default for D2Config {
655 fn default() -> Self {
656 Self {
657 filter_languages: HashSet::new(),
658 filter_edges: HashSet::new(),
659 highlight_cross_language: false,
660 show_details: true,
661 show_edge_labels: true,
662 direction: Direction::LeftToRight,
663 }
664 }
665}
666
667impl D2Config {
668 #[must_use]
670 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
671 self.highlight_cross_language = enabled;
672 self
673 }
674
675 #[must_use]
677 pub fn with_details(mut self, enabled: bool) -> Self {
678 self.show_details = enabled;
679 self
680 }
681
682 #[must_use]
684 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
685 self.show_edge_labels = enabled;
686 self
687 }
688}
689
690pub struct UnifiedD2Exporter<'a> {
692 graph: &'a GraphSnapshot,
693 config: D2Config,
694}
695
696impl<'a> UnifiedD2Exporter<'a> {
697 #[must_use]
699 pub fn new(graph: &'a GraphSnapshot) -> Self {
700 Self {
701 graph,
702 config: D2Config::default(),
703 }
704 }
705
706 #[must_use]
708 pub fn with_config(graph: &'a GraphSnapshot, config: D2Config) -> Self {
709 Self { graph, config }
710 }
711
712 #[must_use]
714 pub fn export(&self) -> String {
715 let mut d2 = String::new();
716
717 writeln!(d2, "direction: {}", self.config.direction.as_str())
719 .expect("write to String never fails");
720 d2.push('\n');
721
722 let visible_nodes: HashSet<NodeId> = self
724 .graph
725 .iter_nodes()
726 .filter(|(id, entry)| self.should_include_node(*id, entry))
727 .map(|(id, _)| id)
728 .collect();
729
730 for node_id in &visible_nodes {
732 if let Some(entry) = self.graph.get_node(*node_id) {
733 self.export_node(&mut d2, *node_id, entry);
734 }
735 }
736
737 d2.push('\n');
738
739 for (from, to, kind) in self.graph.iter_edges() {
741 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
742 continue;
743 }
744
745 if !self.edge_allowed(&kind) {
746 continue;
747 }
748
749 self.export_edge(&mut d2, from, to, &kind);
750 }
751
752 d2
753 }
754
755 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
757 if !self.config.filter_languages.is_empty() {
758 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
759 if !self.config.filter_languages.contains(&lang) {
760 return false;
761 }
762 } else {
763 return false;
764 }
765 }
766 true
767 }
768
769 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
771 if self.config.filter_edges.is_empty() {
772 return true;
773 }
774 self.config.filter_edges.iter().any(|f| f.matches(kind))
775 }
776
777 fn export_node(&self, d2: &mut String, node_id: NodeId, entry: &NodeEntry) {
779 let lang = self.graph.files().language_for_file(entry.file);
780 let color = lang.map_or(default_language_color(), language_color);
781 let shape = match entry.kind {
782 NodeKind::Class | NodeKind::Struct => "class",
783 NodeKind::Interface | NodeKind::Trait => "oval",
784 NodeKind::Module => "package",
785 _ => "rectangle",
786 };
787
788 let name = self
789 .graph
790 .strings()
791 .resolve(entry.name)
792 .unwrap_or_else(|| std::sync::Arc::from("?"));
793 let qualified_name = entry
794 .qualified_name
795 .and_then(|id| self.graph.strings().resolve(id))
796 .unwrap_or_else(|| std::sync::Arc::clone(&name));
797
798 let label = if self.config.show_details {
799 let file = self
800 .graph
801 .files()
802 .resolve(entry.file)
803 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
804 format!("{} ({file}:{})", qualified_name.as_ref(), entry.start_line)
805 } else {
806 qualified_name.to_string()
807 };
808
809 let node_key = format!("n{}", node_id.index());
810 let label = escape_d2(&label);
811
812 writeln!(d2, "{node_key}: \"{label}\" {{").expect("write to String never fails");
813 writeln!(d2, " shape: {shape}").expect("write to String never fails");
814 writeln!(d2, " style.fill: \"{color}\"").expect("write to String never fails");
815 writeln!(d2, "}}").expect("write to String never fails");
816 }
817
818 fn export_edge(&self, d2: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
820 let (style, base_color) = edge_style(kind);
821 let color = if self.config.highlight_cross_language
822 && let (Some(from_entry), Some(to_entry)) =
823 (self.graph.get_node(from), self.graph.get_node(to))
824 {
825 let from_lang = self.graph.files().language_for_file(from_entry.file);
826 let to_lang = self.graph.files().language_for_file(to_entry.file);
827 if from_lang == to_lang {
828 base_color
829 } else {
830 "#ff0000"
831 }
832 } else {
833 base_color
834 };
835
836 let from_key = format!("n{}", from.index());
837 let to_key = format!("n{}", to.index());
838
839 let arrow = match kind {
840 EdgeKind::Inherits | EdgeKind::Implements => "<->",
841 _ => "->",
842 };
843
844 if self.config.show_edge_labels {
845 let label = edge_label(kind, self.graph.strings());
846 if label.is_empty() {
847 writeln!(d2, "{from_key} {arrow} {to_key}: {{")
848 .expect("write to String never fails");
849 } else {
850 writeln!(d2, "{from_key} {arrow} {to_key}: \"{label}\" {{")
851 .expect("write to String never fails");
852 }
853 } else {
854 writeln!(d2, "{from_key} {arrow} {to_key}: {{").expect("write to String never fails");
855 }
856
857 let d2_style = match style {
858 "dashed" => "stroke-dash: 3",
859 "dotted" => "stroke-dash: 1",
860 "bold" => "stroke-width: 3",
861 _ => "",
862 };
863
864 if !d2_style.is_empty() {
865 writeln!(d2, " style.{d2_style}").expect("write to String never fails");
866 }
867 writeln!(d2, " style.stroke: \"{color}\"").expect("write to String never fails");
868 writeln!(d2, "}}").expect("write to String never fails");
869 }
870}
871
872#[derive(Debug, Clone)]
878pub struct MermaidConfig {
879 pub filter_languages: HashSet<Language>,
881 pub filter_edges: HashSet<EdgeFilter>,
883 pub highlight_cross_language: bool,
885 pub show_edge_labels: bool,
887 pub direction: Direction,
889}
890
891impl Default for MermaidConfig {
892 fn default() -> Self {
893 Self {
894 filter_languages: HashSet::new(),
895 filter_edges: HashSet::new(),
896 highlight_cross_language: false,
897 show_edge_labels: true,
898 direction: Direction::LeftToRight,
899 }
900 }
901}
902
903impl MermaidConfig {
904 #[must_use]
906 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
907 self.highlight_cross_language = enabled;
908 self
909 }
910
911 #[must_use]
913 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
914 self.show_edge_labels = enabled;
915 self
916 }
917}
918
919pub struct UnifiedMermaidExporter<'a> {
921 graph: &'a GraphSnapshot,
922 config: MermaidConfig,
923}
924
925impl<'a> UnifiedMermaidExporter<'a> {
926 #[must_use]
928 pub fn new(graph: &'a GraphSnapshot) -> Self {
929 Self {
930 graph,
931 config: MermaidConfig::default(),
932 }
933 }
934
935 #[must_use]
937 pub fn with_config(graph: &'a GraphSnapshot, config: MermaidConfig) -> Self {
938 Self { graph, config }
939 }
940
941 #[must_use]
943 pub fn export(&self) -> String {
944 let mut mermaid = String::new();
945
946 writeln!(mermaid, "graph {}", self.config.direction.as_str())
948 .expect("write to String never fails");
949
950 let visible_nodes: HashSet<NodeId> = self
952 .graph
953 .iter_nodes()
954 .filter(|(id, entry)| self.should_include_node(*id, entry))
955 .map(|(id, _)| id)
956 .collect();
957
958 for node_id in &visible_nodes {
960 if let Some(entry) = self.graph.get_node(*node_id) {
961 self.export_node(&mut mermaid, *node_id, entry);
962 }
963 }
964
965 for (from, to, kind) in self.graph.iter_edges() {
967 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
968 continue;
969 }
970
971 if !self.edge_allowed(&kind) {
972 continue;
973 }
974
975 self.export_edge(&mut mermaid, from, to, &kind);
976 }
977
978 mermaid
979 }
980
981 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
983 if !self.config.filter_languages.is_empty() {
984 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
985 if !self.config.filter_languages.contains(&lang) {
986 return false;
987 }
988 } else {
989 return false;
990 }
991 }
992 true
993 }
994
995 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
997 if self.config.filter_edges.is_empty() {
998 return true;
999 }
1000 self.config.filter_edges.iter().any(|f| f.matches(kind))
1001 }
1002
1003 fn export_node(&self, mermaid: &mut String, node_id: NodeId, entry: &NodeEntry) {
1005 let name = self
1006 .graph
1007 .strings()
1008 .resolve(entry.name)
1009 .unwrap_or_else(|| std::sync::Arc::from("?"));
1010 let qualified_name = entry
1011 .qualified_name
1012 .and_then(|id| self.graph.strings().resolve(id))
1013 .unwrap_or_else(|| std::sync::Arc::clone(&name));
1014
1015 let node_key = format!("n{}", node_id.index());
1016 let label = Self::escape_mermaid(&qualified_name);
1017
1018 let (open, close) = match entry.kind {
1020 NodeKind::Class | NodeKind::Struct => ("[[", "]]"),
1021 NodeKind::Interface | NodeKind::Trait => ("([", "])"),
1022 NodeKind::Module => ("{{", "}}"),
1023 _ => ("[", "]"),
1024 };
1025
1026 writeln!(mermaid, " {node_key}{open}\"{label}\"{close}")
1027 .expect("write to String never fails");
1028 }
1029
1030 fn export_edge(&self, mermaid: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
1032 let from_key = format!("n{}", from.index());
1033 let to_key = format!("n{}", to.index());
1034
1035 let arrow = match kind {
1036 EdgeKind::Imports { .. } | EdgeKind::Exports { .. } => "-.->",
1037 EdgeKind::Calls { is_async: true, .. } => "==>",
1038 _ => "-->",
1039 };
1040
1041 if self.config.show_edge_labels {
1042 let label = edge_label(kind, self.graph.strings());
1043 if label.is_empty() {
1044 writeln!(mermaid, " {from_key} {arrow} {to_key}")
1045 .expect("write to String never fails");
1046 } else {
1047 let label = Self::escape_mermaid(&label);
1048 writeln!(mermaid, " {from_key} {arrow}|\"{label}\"| {to_key}")
1049 .expect("write to String never fails");
1050 }
1051 } else {
1052 writeln!(mermaid, " {from_key} {arrow} {to_key}")
1053 .expect("write to String never fails");
1054 }
1055 }
1056
1057 fn escape_mermaid(s: &str) -> String {
1059 s.replace('"', "#quot;")
1060 .replace('<', "<")
1061 .replace('>', ">")
1062 }
1063}
1064
1065#[derive(Debug, Clone, Default)]
1071pub struct JsonConfig {
1072 pub include_details: bool,
1074 pub include_edge_metadata: bool,
1076}
1077
1078impl JsonConfig {
1079 #[must_use]
1081 pub fn with_details(mut self, enabled: bool) -> Self {
1082 self.include_details = enabled;
1083 self
1084 }
1085
1086 #[must_use]
1088 pub fn with_edge_metadata(mut self, enabled: bool) -> Self {
1089 self.include_edge_metadata = enabled;
1090 self
1091 }
1092}
1093
1094pub struct UnifiedJsonExporter<'a> {
1096 graph: &'a GraphSnapshot,
1097 config: JsonConfig,
1098}
1099
1100impl<'a> UnifiedJsonExporter<'a> {
1101 #[must_use]
1103 pub fn new(graph: &'a GraphSnapshot) -> Self {
1104 Self {
1105 graph,
1106 config: JsonConfig::default(),
1107 }
1108 }
1109
1110 #[must_use]
1112 pub fn with_config(graph: &'a GraphSnapshot, config: JsonConfig) -> Self {
1113 Self { graph, config }
1114 }
1115
1116 #[must_use]
1118 pub fn export(&self) -> serde_json::Value {
1119 let nodes = self.export_nodes();
1120 let edges = self.export_edges();
1121
1122 serde_json::json!({
1123 "nodes": nodes,
1124 "edges": edges,
1125 "metadata": {
1126 "node_count": nodes.len(),
1127 "edge_count": edges.len(),
1128 }
1129 })
1130 }
1131
1132 fn export_nodes(&self) -> Vec<serde_json::Value> {
1133 self.graph
1134 .iter_nodes()
1135 .map(|(node_id, entry)| self.export_node(node_id, entry))
1136 .collect()
1137 }
1138
1139 fn export_node(&self, node_id: NodeId, entry: &NodeEntry) -> serde_json::Value {
1140 let name = self
1141 .graph
1142 .strings()
1143 .resolve(entry.name)
1144 .map_or_else(|| "?".to_string(), |s| s.to_string());
1145 let qualified_name = entry
1146 .qualified_name
1147 .and_then(|id| self.graph.strings().resolve(id))
1148 .map_or_else(|| name.clone(), |s| s.to_string());
1149 let file = self
1150 .graph
1151 .files()
1152 .resolve(entry.file)
1153 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
1154 let lang = self
1155 .graph
1156 .files()
1157 .language_for_file(entry.file)
1158 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
1159
1160 let mut node = serde_json::json!({
1161 "id": format!("n{}", node_id.index()),
1162 "name": name,
1163 "qualified_name": qualified_name,
1164 "kind": format!("{:?}", entry.kind),
1165 "file": file,
1166 "language": lang,
1167 "line": entry.start_line,
1168 });
1169
1170 if self.config.include_details {
1171 self.append_node_details(entry, &mut node);
1172 }
1173
1174 node
1175 }
1176
1177 fn append_node_details(&self, entry: &NodeEntry, node: &mut serde_json::Value) {
1178 if let Some(sig_id) = entry.signature
1179 && let Some(sig) = self.graph.strings().resolve(sig_id)
1180 {
1181 node["signature"] = serde_json::Value::String(sig.to_string());
1182 }
1183 if let Some(doc_id) = entry.doc
1184 && let Some(doc) = self.graph.strings().resolve(doc_id)
1185 {
1186 node["doc"] = serde_json::Value::String(doc.to_string());
1187 }
1188 if let Some(vis_id) = entry.visibility
1189 && let Some(vis) = self.graph.strings().resolve(vis_id)
1190 {
1191 node["visibility"] = serde_json::Value::String(vis.to_string());
1192 }
1193 node["is_async"] = serde_json::Value::Bool(entry.is_async);
1194 node["is_static"] = serde_json::Value::Bool(entry.is_static);
1195 }
1196
1197 fn export_edges(&self) -> Vec<serde_json::Value> {
1198 self.graph
1199 .iter_edges()
1200 .map(|(from, to, kind)| self.export_edge(from, to, &kind))
1201 .collect()
1202 }
1203
1204 fn export_edge(&self, from: NodeId, to: NodeId, kind: &EdgeKind) -> serde_json::Value {
1205 let from_key = format!("n{}", from.index());
1206 let to_key = format!("n{}", to.index());
1207
1208 let mut edge = serde_json::json!({
1209 "from": from_key,
1210 "to": to_key,
1211 "kind": Self::edge_kind_name(kind),
1212 });
1213
1214 if self.config.include_edge_metadata {
1215 self.append_edge_metadata(kind, &mut edge);
1216 }
1217
1218 edge
1219 }
1220
1221 fn append_edge_metadata(&self, kind: &EdgeKind, edge: &mut serde_json::Value) {
1222 match kind {
1223 EdgeKind::Calls {
1224 argument_count,
1225 is_async,
1226 } => {
1227 edge["argument_count"] = serde_json::Value::Number((*argument_count).into());
1228 edge["is_async"] = serde_json::Value::Bool(*is_async);
1229 }
1230 EdgeKind::Imports { alias, is_wildcard } => {
1231 edge["is_wildcard"] = serde_json::Value::Bool(*is_wildcard);
1232 if let Some(alias_id) = alias
1233 && let Some(alias_str) = self.graph.strings().resolve(*alias_id)
1234 {
1235 edge["alias"] = serde_json::Value::String(alias_str.to_string());
1236 }
1237 }
1238 EdgeKind::Exports {
1239 kind: export_kind,
1240 alias,
1241 } => {
1242 edge["export_kind"] = serde_json::Value::String(format!("{export_kind:?}"));
1243 if let Some(alias_id) = alias
1244 && let Some(alias_str) = self.graph.strings().resolve(*alias_id)
1245 {
1246 edge["alias"] = serde_json::Value::String(alias_str.to_string());
1247 }
1248 }
1249 EdgeKind::HttpRequest { method, url } => {
1250 edge["method"] = serde_json::Value::String(method.as_str().to_string());
1251 if let Some(url_id) = url
1252 && let Some(url_str) = self.graph.strings().resolve(*url_id)
1253 {
1254 edge["url"] = serde_json::Value::String(url_str.to_string());
1255 }
1256 }
1257 _ => {}
1258 }
1259 }
1260
1261 fn edge_kind_name(kind: &EdgeKind) -> &'static str {
1263 match kind {
1264 EdgeKind::Defines => "defines",
1265 EdgeKind::Contains => "contains",
1266 EdgeKind::Calls { .. } => "calls",
1267 EdgeKind::References => "references",
1268 EdgeKind::Imports { .. } => "imports",
1269 EdgeKind::Exports { .. } => "exports",
1270 EdgeKind::TypeOf { .. } => "type_of",
1271 EdgeKind::Inherits => "inherits",
1272 EdgeKind::Implements => "implements",
1273 EdgeKind::FfiCall { .. } => "ffi_call",
1274 EdgeKind::HttpRequest { .. } => "http_request",
1275 EdgeKind::GrpcCall { .. } => "grpc_call",
1276 EdgeKind::WebAssemblyCall => "wasm_call",
1277 EdgeKind::DbQuery { .. } => "db_query",
1278 EdgeKind::TableRead { .. } => "table_read",
1279 EdgeKind::TableWrite { .. } => "table_write",
1280 EdgeKind::TriggeredBy { .. } => "triggered_by",
1281 EdgeKind::MessageQueue { .. } => "message_queue",
1282 EdgeKind::WebSocket { .. } => "websocket",
1283 EdgeKind::GraphQLOperation { .. } => "graphql_operation",
1284 EdgeKind::ProcessExec { .. } => "process_exec",
1285 EdgeKind::FileIpc { .. } => "file_ipc",
1286 EdgeKind::ProtocolCall { .. } => "protocol_call",
1287 EdgeKind::LifetimeConstraint { .. } => "lifetime_constraint",
1289 EdgeKind::TraitMethodBinding { .. } => "trait_method_binding",
1290 EdgeKind::MacroExpansion { .. } => "macro_expansion",
1291 }
1292 }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297 use super::*;
1298
1299 #[test]
1302 fn test_direction_as_str() {
1303 assert_eq!(Direction::LeftToRight.as_str(), "LR");
1304 assert_eq!(Direction::TopToBottom.as_str(), "TB");
1305 }
1306
1307 #[test]
1308 fn test_direction_default() {
1309 assert_eq!(Direction::default(), Direction::LeftToRight);
1310 }
1311
1312 #[test]
1315 fn test_edge_filter_matches_calls() {
1316 assert!(EdgeFilter::Calls.matches(&EdgeKind::Calls {
1317 argument_count: 0,
1318 is_async: false
1319 }));
1320 assert!(EdgeFilter::Calls.matches(&EdgeKind::Calls {
1321 argument_count: 5,
1322 is_async: true
1323 }));
1324 assert!(!EdgeFilter::Calls.matches(&EdgeKind::References));
1325 }
1326
1327 #[test]
1328 fn test_edge_filter_matches_imports() {
1329 assert!(EdgeFilter::Imports.matches(&EdgeKind::Imports {
1330 alias: None,
1331 is_wildcard: false
1332 }));
1333 assert!(EdgeFilter::Imports.matches(&EdgeKind::Imports {
1334 alias: None,
1335 is_wildcard: true
1336 }));
1337 assert!(!EdgeFilter::Imports.matches(&EdgeKind::Exports {
1338 kind: crate::graph::unified::edge::ExportKind::Direct,
1339 alias: None
1340 }));
1341 }
1342
1343 #[test]
1344 fn test_edge_filter_matches_exports() {
1345 assert!(EdgeFilter::Exports.matches(&EdgeKind::Exports {
1346 kind: crate::graph::unified::edge::ExportKind::Direct,
1347 alias: None
1348 }));
1349 assert!(!EdgeFilter::Exports.matches(&EdgeKind::Imports {
1350 alias: None,
1351 is_wildcard: false
1352 }));
1353 }
1354
1355 #[test]
1356 fn test_edge_filter_matches_references() {
1357 assert!(EdgeFilter::References.matches(&EdgeKind::References));
1358 assert!(!EdgeFilter::References.matches(&EdgeKind::Calls {
1359 argument_count: 0,
1360 is_async: false
1361 }));
1362 }
1363
1364 #[test]
1365 fn test_edge_filter_matches_inheritance() {
1366 assert!(EdgeFilter::Inherits.matches(&EdgeKind::Inherits));
1367 assert!(EdgeFilter::Implements.matches(&EdgeKind::Implements));
1368 assert!(!EdgeFilter::Inherits.matches(&EdgeKind::Implements));
1369 assert!(!EdgeFilter::Implements.matches(&EdgeKind::Inherits));
1370 }
1371
1372 #[test]
1373 fn test_edge_filter_matches_cross_language() {
1374 assert!(EdgeFilter::FfiCall.matches(&EdgeKind::FfiCall {
1375 convention: crate::graph::unified::edge::FfiConvention::C
1376 }));
1377 assert!(EdgeFilter::HttpRequest.matches(&EdgeKind::HttpRequest {
1378 method: crate::graph::unified::edge::HttpMethod::Get,
1379 url: None
1380 }));
1381 assert!(EdgeFilter::DbQuery.matches(&EdgeKind::DbQuery {
1382 query_type: crate::graph::unified::edge::DbQueryType::Select,
1383 table: None
1384 }));
1385 }
1386
1387 #[test]
1390 fn test_language_color_common_languages() {
1391 assert_eq!(language_color(Language::Rust), "#dea584");
1392 assert_eq!(language_color(Language::JavaScript), "#f7df1e");
1393 assert_eq!(language_color(Language::TypeScript), "#3178c6");
1394 assert_eq!(language_color(Language::Python), "#3572A5");
1395 assert_eq!(language_color(Language::Go), "#00ADD8");
1396 assert_eq!(language_color(Language::Java), "#b07219");
1397 }
1398
1399 #[test]
1400 fn test_language_color_all_languages() {
1401 let languages = [
1403 Language::Rust,
1404 Language::JavaScript,
1405 Language::TypeScript,
1406 Language::Python,
1407 Language::Go,
1408 Language::Java,
1409 Language::Ruby,
1410 Language::Php,
1411 Language::Cpp,
1412 Language::C,
1413 Language::Swift,
1414 Language::Kotlin,
1415 Language::Scala,
1416 Language::Sql,
1417 Language::Plsql,
1418 Language::Shell,
1419 Language::Lua,
1420 Language::Perl,
1421 Language::Dart,
1422 Language::Groovy,
1423 Language::Http,
1424 Language::Css,
1425 Language::Elixir,
1426 Language::R,
1427 Language::Haskell,
1428 Language::Html,
1429 Language::Svelte,
1430 Language::Vue,
1431 Language::Zig,
1432 Language::Terraform,
1433 Language::Puppet,
1434 Language::Apex,
1435 Language::Abap,
1436 Language::ServiceNow,
1437 Language::CSharp,
1438 ];
1439 for lang in languages {
1440 let color = language_color(lang);
1441 assert!(color.starts_with('#'), "Color for {lang:?} should be hex");
1442 }
1443 }
1444
1445 #[test]
1446 fn test_default_language_color() {
1447 assert_eq!(default_language_color(), "#cccccc");
1448 }
1449
1450 #[test]
1453 fn test_node_shape_class_types() {
1454 assert_eq!(node_shape(&NodeKind::Class), "component");
1455 assert_eq!(node_shape(&NodeKind::Struct), "component");
1456 }
1457
1458 #[test]
1459 fn test_node_shape_interface_types() {
1460 assert_eq!(node_shape(&NodeKind::Interface), "ellipse");
1461 assert_eq!(node_shape(&NodeKind::Trait), "ellipse");
1462 }
1463
1464 #[test]
1465 fn test_node_shape_module() {
1466 assert_eq!(node_shape(&NodeKind::Module), "folder");
1467 }
1468
1469 #[test]
1470 fn test_node_shape_variables() {
1471 assert_eq!(node_shape(&NodeKind::Variable), "note");
1472 assert_eq!(node_shape(&NodeKind::Constant), "note");
1473 }
1474
1475 #[test]
1476 fn test_node_shape_enums() {
1477 assert_eq!(node_shape(&NodeKind::Enum), "hexagon");
1478 assert_eq!(node_shape(&NodeKind::EnumVariant), "hexagon");
1479 }
1480
1481 #[test]
1482 fn test_node_shape_special() {
1483 assert_eq!(node_shape(&NodeKind::Type), "diamond");
1484 assert_eq!(node_shape(&NodeKind::Macro), "parallelogram");
1485 }
1486
1487 #[test]
1488 fn test_node_shape_default() {
1489 assert_eq!(node_shape(&NodeKind::Function), "box");
1490 assert_eq!(node_shape(&NodeKind::Method), "box");
1491 }
1492
1493 #[test]
1496 fn test_edge_style_calls() {
1497 let (style, color) = edge_style(&EdgeKind::Calls {
1498 argument_count: 0,
1499 is_async: false,
1500 });
1501 assert_eq!(style, "solid");
1502 assert_eq!(color, "#333333");
1503 }
1504
1505 #[test]
1506 fn test_edge_style_imports_exports() {
1507 let (style, color) = edge_style(&EdgeKind::Imports {
1508 alias: None,
1509 is_wildcard: false,
1510 });
1511 assert_eq!(style, "dashed");
1512 assert_eq!(color, "#0066cc");
1513
1514 let (style, color) = edge_style(&EdgeKind::Exports {
1515 kind: crate::graph::unified::edge::ExportKind::Direct,
1516 alias: None,
1517 });
1518 assert_eq!(style, "dashed");
1519 assert_eq!(color, "#00cc66");
1520 }
1521
1522 #[test]
1523 fn test_edge_style_references() {
1524 let (style, color) = edge_style(&EdgeKind::References);
1525 assert_eq!(style, "dotted");
1526 assert_eq!(color, "#666666");
1527 }
1528
1529 #[test]
1530 fn test_edge_style_inheritance() {
1531 let (style, color) = edge_style(&EdgeKind::Inherits);
1532 assert_eq!(style, "solid");
1533 assert_eq!(color, "#990099");
1534
1535 let (style, color) = edge_style(&EdgeKind::Implements);
1536 assert_eq!(style, "dashed");
1537 assert_eq!(color, "#990099");
1538 }
1539
1540 #[test]
1541 fn test_edge_style_cross_language() {
1542 let (style, color) = edge_style(&EdgeKind::FfiCall {
1543 convention: crate::graph::unified::edge::FfiConvention::C,
1544 });
1545 assert_eq!(style, "bold");
1546 assert_eq!(color, "#ff6600");
1547
1548 let (style, color) = edge_style(&EdgeKind::HttpRequest {
1549 method: crate::graph::unified::edge::HttpMethod::Get,
1550 url: None,
1551 });
1552 assert_eq!(style, "bold");
1553 assert_eq!(color, "#cc0000");
1554
1555 let (style, color) = edge_style(&EdgeKind::DbQuery {
1556 query_type: crate::graph::unified::edge::DbQueryType::Select,
1557 table: None,
1558 });
1559 assert_eq!(style, "bold");
1560 assert_eq!(color, "#009900");
1561 }
1562
1563 #[test]
1566 fn test_escape_dot_basic() {
1567 assert_eq!(escape_dot("hello"), "hello");
1568 assert_eq!(escape_dot("hello world"), "hello world");
1569 }
1570
1571 #[test]
1572 fn test_escape_dot_quotes() {
1573 assert_eq!(escape_dot("say \"hi\""), "say \\\"hi\\\"");
1574 assert_eq!(escape_dot("\"quoted\""), "\\\"quoted\\\"");
1575 }
1576
1577 #[test]
1578 fn test_escape_dot_newlines() {
1579 assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
1580 assert_eq!(escape_dot("a\nb\nc"), "a\\nb\\nc");
1581 }
1582
1583 #[test]
1584 fn test_escape_dot_backslashes() {
1585 assert_eq!(escape_dot("path\\to\\file"), "path\\\\to\\\\file");
1586 }
1587
1588 #[test]
1589 fn test_escape_d2_basic() {
1590 assert_eq!(escape_d2("hello"), "hello");
1591 assert_eq!(escape_d2("hello world"), "hello world");
1592 }
1593
1594 #[test]
1595 fn test_escape_d2_quotes_and_newlines() {
1596 assert_eq!(escape_d2("say \"hi\""), "say \\\"hi\\\"");
1598 assert_eq!(escape_d2("line1\nline2"), "line1 line2");
1599 assert_eq!(escape_d2("path\\to\\file"), "path\\\\to\\\\file");
1600 }
1601
1602 #[test]
1605 fn test_dot_config_default() {
1606 let config = DotConfig::default();
1607 assert_eq!(config.direction, Direction::LeftToRight);
1608 assert!(!config.highlight_cross_language);
1609 assert!(config.show_details); assert!(config.show_edge_labels); }
1612
1613 #[test]
1614 fn test_dot_config_builder() {
1615 let config = DotConfig::default()
1616 .with_direction(Direction::TopToBottom)
1617 .with_cross_language_highlight(true)
1618 .with_details(true);
1619 assert_eq!(config.direction, Direction::TopToBottom);
1620 assert!(config.highlight_cross_language);
1621 assert!(config.show_details);
1622 }
1623
1624 #[test]
1625 fn test_d2_config_default() {
1626 let config = D2Config::default();
1627 assert_eq!(config.direction, Direction::LeftToRight);
1628 assert!(!config.highlight_cross_language);
1629 assert!(config.show_details); assert!(config.show_edge_labels); }
1632
1633 #[test]
1634 fn test_d2_config_builder() {
1635 let config = D2Config::default()
1636 .with_cross_language_highlight(true)
1637 .with_details(true)
1638 .with_edge_labels(true);
1639 assert!(config.highlight_cross_language);
1640 assert!(config.show_details);
1641 assert!(config.show_edge_labels);
1642 }
1643
1644 #[test]
1645 fn test_mermaid_config_default() {
1646 let config = MermaidConfig::default();
1647 assert_eq!(config.direction, Direction::LeftToRight);
1648 assert!(!config.highlight_cross_language);
1649 assert!(config.show_edge_labels); }
1651
1652 #[test]
1653 fn test_mermaid_config_builder() {
1654 let config = MermaidConfig::default()
1655 .with_cross_language_highlight(true)
1656 .with_edge_labels(false);
1657 assert!(config.highlight_cross_language);
1658 assert!(!config.show_edge_labels);
1659 }
1660
1661 #[test]
1662 fn test_json_config_builder() {
1663 let config = JsonConfig::default()
1664 .with_details(true)
1665 .with_edge_metadata(true);
1666 assert!(config.include_details);
1667 assert!(config.include_edge_metadata);
1668 }
1669
1670 use crate::graph::unified::concurrent::CodeGraph;
1675 use crate::graph::unified::edge::BidirectionalEdgeStore;
1676 use crate::graph::unified::storage::NodeEntry;
1677 use crate::graph::unified::storage::arena::NodeArena;
1678 use crate::graph::unified::storage::indices::AuxiliaryIndices;
1679 use crate::graph::unified::storage::interner::StringInterner;
1680 use crate::graph::unified::storage::registry::FileRegistry;
1681 use std::path::Path;
1682
1683 fn create_test_graph_for_export() -> CodeGraph {
1685 let mut nodes = NodeArena::new();
1686 let mut strings = StringInterner::new();
1687 let mut files = FileRegistry::new();
1688 let edges = BidirectionalEdgeStore::new();
1689 let indices = AuxiliaryIndices::new();
1690
1691 let file_id = files
1693 .register_with_language(Path::new("src/main.rs"), Some(Language::Rust))
1694 .unwrap();
1695
1696 let name_main = strings.intern("main").unwrap();
1698 let name_helper = strings.intern("helper").unwrap();
1699 let qname_main = strings.intern("app::main").unwrap();
1700 let qname_helper = strings.intern("app::helper").unwrap();
1701 let sig = strings.intern("fn main()").unwrap();
1702
1703 let main_entry = NodeEntry {
1705 kind: NodeKind::Function,
1706 name: name_main,
1707 file: file_id,
1708 start_byte: 0,
1709 end_byte: 100,
1710 start_line: 1,
1711 start_column: 0,
1712 end_line: 10,
1713 end_column: 1,
1714 signature: Some(sig),
1715 doc: None,
1716 qualified_name: Some(qname_main),
1717 visibility: None,
1718 is_async: false,
1719 is_static: false,
1720 is_unsafe: false,
1721 body_hash: None,
1722 };
1723 let main_id = nodes.alloc(main_entry).unwrap();
1724
1725 let helper_entry = NodeEntry {
1726 kind: NodeKind::Function,
1727 name: name_helper,
1728 file: file_id,
1729 start_byte: 100,
1730 end_byte: 200,
1731 start_line: 11,
1732 start_column: 0,
1733 end_line: 20,
1734 end_column: 1,
1735 signature: None,
1736 doc: None,
1737 qualified_name: Some(qname_helper),
1738 visibility: None,
1739 is_async: true,
1740 is_static: false,
1741 is_unsafe: false,
1742 body_hash: None,
1743 };
1744 let helper_id = nodes.alloc(helper_entry).unwrap();
1745
1746 edges.add_edge(
1748 main_id,
1749 helper_id,
1750 EdgeKind::Calls {
1751 argument_count: 2,
1752 is_async: false,
1753 },
1754 file_id,
1755 );
1756
1757 CodeGraph::from_components(nodes, edges, strings, files, indices)
1758 }
1759
1760 #[test]
1763 fn test_unified_dot_exporter_basic() {
1764 let graph = create_test_graph_for_export();
1765 let snapshot = graph.snapshot();
1766 let exporter = UnifiedDotExporter::new(&snapshot);
1767 let output = exporter.export();
1768
1769 assert!(output.starts_with("digraph CodeGraph {"));
1771 assert!(output.ends_with("}\n"));
1772 assert!(output.contains("rankdir=LR"));
1773 }
1774
1775 #[test]
1776 fn test_unified_dot_exporter_with_config() {
1777 let graph = create_test_graph_for_export();
1778 let snapshot = graph.snapshot();
1779 let config = DotConfig::default()
1780 .with_direction(Direction::TopToBottom)
1781 .with_cross_language_highlight(true)
1782 .with_details(true);
1783 let exporter = UnifiedDotExporter::with_config(&snapshot, config);
1784 let output = exporter.export();
1785
1786 assert!(output.contains("rankdir=TB"));
1787 }
1788
1789 #[test]
1790 fn test_unified_dot_exporter_contains_nodes() {
1791 let graph = create_test_graph_for_export();
1792 let snapshot = graph.snapshot();
1793 let exporter = UnifiedDotExporter::new(&snapshot);
1794 let output = exporter.export();
1795
1796 assert!(output.contains("n0"), "Should contain first node");
1798 assert!(output.contains("n1"), "Should contain second node");
1799 assert!(
1801 output.contains("main") || output.contains("app::main"),
1802 "Should contain main function name"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_unified_dot_exporter_contains_edges() {
1808 let graph = create_test_graph_for_export();
1809 let snapshot = graph.snapshot();
1810 let exporter = UnifiedDotExporter::new(&snapshot);
1811 let output = exporter.export();
1812
1813 assert!(output.contains("->"), "Should contain edge arrow");
1815 }
1816
1817 #[test]
1818 fn test_unified_dot_exporter_empty_graph() {
1819 let graph = CodeGraph::new();
1820 let snapshot = graph.snapshot();
1821 let exporter = UnifiedDotExporter::new(&snapshot);
1822 let output = exporter.export();
1823
1824 assert!(output.starts_with("digraph CodeGraph {"));
1825 assert!(output.ends_with("}\n"));
1826 assert!(!output.contains("n0"));
1828 }
1829
1830 #[test]
1833 fn test_unified_d2_exporter_basic() {
1834 let graph = create_test_graph_for_export();
1835 let snapshot = graph.snapshot();
1836 let exporter = UnifiedD2Exporter::new(&snapshot);
1837 let output = exporter.export();
1838
1839 assert!(output.contains("direction: LR"));
1841 }
1842
1843 #[test]
1844 fn test_unified_d2_exporter_with_config() {
1845 let graph = create_test_graph_for_export();
1846 let snapshot = graph.snapshot();
1847 let config = D2Config::default().with_cross_language_highlight(true);
1848 let exporter = UnifiedD2Exporter::with_config(&snapshot, config);
1849 let output = exporter.export();
1850
1851 assert!(output.contains("direction:"));
1852 }
1853
1854 #[test]
1855 fn test_unified_d2_exporter_contains_nodes() {
1856 let graph = create_test_graph_for_export();
1857 let snapshot = graph.snapshot();
1858 let exporter = UnifiedD2Exporter::new(&snapshot);
1859 let output = exporter.export();
1860
1861 assert!(
1863 output.contains("n0:"),
1864 "Should contain first node definition"
1865 );
1866 assert!(
1867 output.contains("n1:"),
1868 "Should contain second node definition"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_unified_d2_exporter_contains_edges() {
1874 let graph = create_test_graph_for_export();
1875 let snapshot = graph.snapshot();
1876 let exporter = UnifiedD2Exporter::new(&snapshot);
1877 let output = exporter.export();
1878
1879 assert!(
1881 output.contains("->") || output.contains("<->"),
1882 "Should contain edge"
1883 );
1884 }
1885
1886 #[test]
1889 fn test_unified_mermaid_exporter_basic() {
1890 let graph = create_test_graph_for_export();
1891 let snapshot = graph.snapshot();
1892 let exporter = UnifiedMermaidExporter::new(&snapshot);
1893 let output = exporter.export();
1894
1895 assert!(output.starts_with("graph LR"));
1897 }
1898
1899 #[test]
1900 fn test_unified_mermaid_exporter_with_config() {
1901 let graph = create_test_graph_for_export();
1902 let snapshot = graph.snapshot();
1903 let config = MermaidConfig::default()
1904 .with_cross_language_highlight(true)
1905 .with_edge_labels(false);
1906 let exporter = UnifiedMermaidExporter::with_config(&snapshot, config);
1907 let output = exporter.export();
1908
1909 assert!(output.starts_with("graph LR"));
1910 }
1911
1912 #[test]
1913 fn test_unified_mermaid_exporter_contains_nodes() {
1914 let graph = create_test_graph_for_export();
1915 let snapshot = graph.snapshot();
1916 let exporter = UnifiedMermaidExporter::new(&snapshot);
1917 let output = exporter.export();
1918
1919 assert!(output.contains("n0"), "Should contain first node");
1921 assert!(output.contains("n1"), "Should contain second node");
1922 }
1923
1924 #[test]
1925 fn test_unified_mermaid_exporter_contains_edges() {
1926 let graph = create_test_graph_for_export();
1927 let snapshot = graph.snapshot();
1928 let exporter = UnifiedMermaidExporter::new(&snapshot);
1929 let output = exporter.export();
1930
1931 assert!(
1933 output.contains("-->") || output.contains("==>") || output.contains("-.->"),
1934 "Should contain edge"
1935 );
1936 }
1937
1938 #[test]
1941 fn test_unified_json_exporter_basic() {
1942 let graph = create_test_graph_for_export();
1943 let snapshot = graph.snapshot();
1944 let exporter = UnifiedJsonExporter::new(&snapshot);
1945 let output = exporter.export();
1946
1947 assert!(output.is_object());
1949 assert!(output.get("nodes").is_some());
1950 assert!(output.get("edges").is_some());
1951 assert!(output.get("metadata").is_some());
1952 }
1953
1954 #[test]
1955 fn test_unified_json_exporter_node_count() {
1956 let graph = create_test_graph_for_export();
1957 let snapshot = graph.snapshot();
1958 let exporter = UnifiedJsonExporter::new(&snapshot);
1959 let output = exporter.export();
1960
1961 let nodes = output.get("nodes").unwrap().as_array().unwrap();
1962 assert_eq!(nodes.len(), 2, "Should have 2 nodes");
1963 }
1964
1965 #[test]
1966 fn test_unified_json_exporter_edge_count() {
1967 let graph = create_test_graph_for_export();
1968 let snapshot = graph.snapshot();
1969 let exporter = UnifiedJsonExporter::new(&snapshot);
1970 let output = exporter.export();
1971
1972 let edges = output.get("edges").unwrap().as_array().unwrap();
1973 assert_eq!(edges.len(), 1, "Should have 1 edge");
1974 }
1975
1976 #[test]
1977 fn test_unified_json_exporter_metadata() {
1978 let graph = create_test_graph_for_export();
1979 let snapshot = graph.snapshot();
1980 let exporter = UnifiedJsonExporter::new(&snapshot);
1981 let output = exporter.export();
1982
1983 let metadata = output.get("metadata").unwrap();
1984 assert_eq!(
1985 metadata.get("node_count").unwrap().as_u64().unwrap(),
1986 2,
1987 "Metadata should report 2 nodes"
1988 );
1989 assert_eq!(
1990 metadata.get("edge_count").unwrap().as_u64().unwrap(),
1991 1,
1992 "Metadata should report 1 edge"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_unified_json_exporter_with_details() {
1998 let graph = create_test_graph_for_export();
1999 let snapshot = graph.snapshot();
2000 let config = JsonConfig::default().with_details(true);
2001 let exporter = UnifiedJsonExporter::with_config(&snapshot, config);
2002 let output = exporter.export();
2003
2004 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2005 let main_node = &nodes[0];
2007 assert!(
2008 main_node.get("signature").is_some(),
2009 "Node should have signature when details enabled"
2010 );
2011 }
2012
2013 #[test]
2014 fn test_unified_json_exporter_with_edge_metadata() {
2015 let graph = create_test_graph_for_export();
2016 let snapshot = graph.snapshot();
2017 let config = JsonConfig::default().with_edge_metadata(true);
2018 let exporter = UnifiedJsonExporter::with_config(&snapshot, config);
2019 let output = exporter.export();
2020
2021 let edges = output.get("edges").unwrap().as_array().unwrap();
2022 let edge = &edges[0];
2023 assert!(
2025 edge.get("argument_count").is_some(),
2026 "Edge should have argument_count metadata"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_unified_json_exporter_empty_graph() {
2032 let graph = CodeGraph::new();
2033 let snapshot = graph.snapshot();
2034 let exporter = UnifiedJsonExporter::new(&snapshot);
2035 let output = exporter.export();
2036
2037 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2038 let edges = output.get("edges").unwrap().as_array().unwrap();
2039 assert!(nodes.is_empty(), "Empty graph should have no nodes");
2040 assert!(edges.is_empty(), "Empty graph should have no edges");
2041 }
2042
2043 #[test]
2046 fn test_edge_label_calls() {
2047 let strings = StringInterner::new();
2048 let label = edge_label(
2049 &EdgeKind::Calls {
2050 argument_count: 3,
2051 is_async: false,
2052 },
2053 &strings,
2054 );
2055 assert_eq!(label, "call(3)");
2056 }
2057
2058 #[test]
2059 fn test_edge_label_async_calls() {
2060 let strings = StringInterner::new();
2061 let label = edge_label(
2062 &EdgeKind::Calls {
2063 argument_count: 2,
2064 is_async: true,
2065 },
2066 &strings,
2067 );
2068 assert_eq!(label, "async call(2)");
2069 }
2070
2071 #[test]
2072 fn test_edge_label_imports_simple() {
2073 let strings = StringInterner::new();
2074 let label = edge_label(
2075 &EdgeKind::Imports {
2076 alias: None,
2077 is_wildcard: false,
2078 },
2079 &strings,
2080 );
2081 assert_eq!(label, "import");
2082 }
2083
2084 #[test]
2085 fn test_edge_label_imports_wildcard() {
2086 let strings = StringInterner::new();
2087 let label = edge_label(
2088 &EdgeKind::Imports {
2089 alias: None,
2090 is_wildcard: true,
2091 },
2092 &strings,
2093 );
2094 assert_eq!(label, "import *");
2095 }
2096
2097 #[test]
2098 fn test_edge_label_references() {
2099 let strings = StringInterner::new();
2100 let label = edge_label(&EdgeKind::References, &strings);
2101 assert_eq!(label, "ref");
2102 }
2103
2104 #[test]
2105 fn test_edge_label_inherits() {
2106 let strings = StringInterner::new();
2107 let label = edge_label(&EdgeKind::Inherits, &strings);
2108 assert_eq!(label, "extends");
2109 }
2110
2111 #[test]
2112 fn test_edge_label_implements() {
2113 let strings = StringInterner::new();
2114 let label = edge_label(&EdgeKind::Implements, &strings);
2115 assert_eq!(label, "implements");
2116 }
2117
2118 #[test]
2121 fn test_dot_config_filter_language() {
2122 let config = DotConfig::default().filter_language(Language::Rust);
2123 assert!(config.filter_languages.contains(&Language::Rust));
2124 }
2125
2126 #[test]
2127 fn test_dot_config_filter_edge() {
2128 let config = DotConfig::default().filter_edge(EdgeFilter::Calls);
2129 assert!(config.filter_edges.contains(&EdgeFilter::Calls));
2130 }
2131
2132 #[test]
2133 fn test_dot_config_with_max_depth() {
2134 let config = DotConfig::default().with_max_depth(5);
2135 assert_eq!(config.max_depth, Some(5));
2136 }
2137}