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;
40#[cfg(test)]
41use crate::graph::unified::edge::ResolvedVia;
42use crate::graph::unified::node::NodeId;
43use crate::graph::unified::node::kind::NodeKind;
44use crate::graph::unified::storage::arena::NodeEntry;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum Direction {
53 #[default]
55 LeftToRight,
56 TopToBottom,
58}
59
60impl Direction {
61 #[must_use]
63 pub const fn as_str(self) -> &'static str {
64 match self {
65 Self::LeftToRight => "LR",
66 Self::TopToBottom => "TB",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum EdgeFilter {
74 Calls,
76 Imports,
78 Exports,
80 References,
82 Inherits,
84 Implements,
86 FfiCall,
88 HttpRequest,
90 DbQuery,
92}
93
94impl EdgeFilter {
95 #[must_use]
97 pub fn matches(&self, kind: &EdgeKind) -> bool {
98 matches!(
99 (self, kind),
100 (EdgeFilter::Calls, EdgeKind::Calls { .. })
101 | (EdgeFilter::Imports, EdgeKind::Imports { .. })
102 | (EdgeFilter::Exports, EdgeKind::Exports { .. })
103 | (EdgeFilter::References, EdgeKind::References)
104 | (EdgeFilter::Inherits, EdgeKind::Inherits)
105 | (EdgeFilter::Implements, EdgeKind::Implements)
106 | (EdgeFilter::FfiCall, EdgeKind::FfiCall { .. })
107 | (EdgeFilter::HttpRequest, EdgeKind::HttpRequest { .. })
108 | (EdgeFilter::DbQuery, EdgeKind::DbQuery { .. })
109 )
110 }
111}
112
113#[must_use]
119pub fn language_color(lang: Language) -> &'static str {
120 match lang {
121 Language::Rust => "#dea584",
122 Language::JavaScript => "#f7df1e",
123 Language::TypeScript => "#3178c6",
124 Language::Python => "#3572A5",
125 Language::Go => "#00ADD8",
126 Language::Java => "#b07219",
127 Language::Ruby => "#701516",
128 Language::Php => "#4F5D95",
129 Language::Cpp => "#f34b7d",
130 Language::C => "#555555",
131 Language::Swift => "#F05138",
132 Language::Kotlin => "#A97BFF",
133 Language::Scala => "#c22d40",
134 Language::Sql | Language::Plsql => "#e38c00",
135 Language::Shell => "#89e051",
136 Language::Lua => "#000080",
137 Language::Perl => "#0298c3",
138 Language::Dart => "#00B4AB",
139 Language::Groovy => "#4298b8",
140 Language::Http => "#005C9C",
141 Language::Css => "#563d7c",
142 Language::Elixir => "#6e4a7e",
143 Language::R => "#198CE7",
144 Language::Haskell => "#5e5086",
145 Language::Html => "#e34c26",
146 Language::Svelte => "#ff3e00",
147 Language::Vue => "#41b883",
148 Language::Zig => "#ec915c",
149 Language::Terraform => "#5c4ee5",
150 Language::Puppet => "#302B6D",
151 Language::Pulumi => "#6d2df5",
152 Language::Apex => "#1797c0",
153 Language::Abap => "#E8274B",
154 Language::ServiceNow => "#62d84e",
155 Language::CSharp => "#178600",
156 Language::Json => "#292929",
157 }
158}
159
160#[must_use]
162pub const fn default_language_color() -> &'static str {
163 "#cccccc"
164}
165
166#[must_use]
168pub fn node_shape(kind: &NodeKind) -> &'static str {
169 match kind {
170 NodeKind::Class | NodeKind::Struct => "component",
171 NodeKind::Interface | NodeKind::Trait => "ellipse",
172 NodeKind::Module => "folder",
173 NodeKind::Variable | NodeKind::Constant => "note",
174 NodeKind::Enum | NodeKind::EnumVariant => "hexagon",
175 NodeKind::Type => "diamond",
176 NodeKind::Macro => "parallelogram",
177 _ => "box",
178 }
179}
180
181#[must_use]
183pub fn edge_style(kind: &EdgeKind) -> (&'static str, &'static str) {
184 match kind {
185 EdgeKind::Calls { .. } => ("solid", "#333333"),
186 EdgeKind::Imports { .. } => ("dashed", "#0066cc"),
187 EdgeKind::Exports { .. } => ("dashed", "#00cc66"),
188 EdgeKind::References => ("dotted", "#666666"),
189 EdgeKind::Inherits => ("solid", "#990099"),
190 EdgeKind::Implements => ("dashed", "#990099"),
191 EdgeKind::FfiCall { .. } => ("bold", "#ff6600"),
192 EdgeKind::HttpRequest { .. } => ("bold", "#cc0000"),
193 EdgeKind::DbQuery { .. } => ("bold", "#009900"),
194 _ => ("solid", "#666666"),
195 }
196}
197
198#[must_use]
200pub fn edge_label(
201 kind: &EdgeKind,
202 strings: &crate::graph::unified::storage::interner::StringInterner,
203) -> String {
204 match kind {
205 EdgeKind::Calls {
206 argument_count,
207 is_async,
208 ..
209 } => {
210 if *is_async {
211 format!("async call({argument_count})")
212 } else {
213 format!("call({argument_count})")
214 }
215 }
216 EdgeKind::Imports { alias, is_wildcard } => {
217 if *is_wildcard {
218 "import *".to_string()
219 } else if let Some(alias_id) = alias {
220 let alias_str = strings
221 .resolve(*alias_id)
222 .map_or_else(|| "?".to_string(), |s| s.to_string());
223 format!("import as {alias_str}")
224 } else {
225 "import".to_string()
226 }
227 }
228 EdgeKind::Exports {
229 kind: export_kind,
230 alias,
231 } => {
232 let kind_str = match export_kind {
233 crate::graph::unified::edge::ExportKind::Direct => "export",
234 crate::graph::unified::edge::ExportKind::Reexport => "re-export",
235 crate::graph::unified::edge::ExportKind::Default => "default export",
236 crate::graph::unified::edge::ExportKind::Namespace => "export *",
237 };
238 if let Some(alias_id) = alias {
239 let alias_str = strings
240 .resolve(*alias_id)
241 .map_or_else(|| "?".to_string(), |s| s.to_string());
242 format!("{kind_str} as {alias_str}")
243 } else {
244 kind_str.to_string()
245 }
246 }
247 EdgeKind::References => "ref".to_string(),
248 EdgeKind::Inherits => "extends".to_string(),
249 EdgeKind::Implements => "implements".to_string(),
250 EdgeKind::FfiCall { convention } => format!("ffi:{convention:?}"),
251 EdgeKind::HttpRequest { method, .. } => method.as_str().to_string(),
252 EdgeKind::DbQuery { query_type, .. } => format!("{query_type:?}"),
253 _ => String::new(),
254 }
255}
256
257#[must_use]
259pub fn escape_dot(s: &str) -> String {
260 s.replace('\\', "\\\\")
261 .replace('"', "\\\"")
262 .replace('\n', "\\n")
263}
264
265#[must_use]
267pub fn escape_d2(s: &str) -> String {
268 s.replace('\\', "\\\\")
269 .replace('"', "\\\"")
270 .replace('\n', " ")
271}
272
273#[derive(Debug, Clone)]
279pub struct DotConfig {
280 pub filter_languages: HashSet<Language>,
282 pub filter_edges: HashSet<EdgeFilter>,
284 pub filter_files: HashSet<String>,
286 pub filter_node_ids: Option<HashSet<NodeId>>,
290 pub highlight_cross_language: bool,
292 pub max_depth: Option<usize>,
294 pub root_nodes: HashSet<NodeId>,
296 pub direction: Direction,
298 pub show_details: bool,
300 pub show_edge_labels: bool,
302}
303
304impl Default for DotConfig {
305 fn default() -> Self {
306 Self {
307 filter_languages: HashSet::new(),
308 filter_edges: HashSet::new(),
309 filter_files: HashSet::new(),
310 filter_node_ids: None,
311 highlight_cross_language: false,
312 max_depth: None,
313 root_nodes: HashSet::new(),
314 direction: Direction::LeftToRight,
315 show_details: true,
316 show_edge_labels: true,
317 }
318 }
319}
320
321impl DotConfig {
322 #[must_use]
324 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
325 self.highlight_cross_language = enabled;
326 self
327 }
328
329 #[must_use]
331 pub fn with_details(mut self, enabled: bool) -> Self {
332 self.show_details = enabled;
333 self
334 }
335
336 #[must_use]
338 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
339 self.show_edge_labels = enabled;
340 self
341 }
342
343 #[must_use]
345 pub fn with_direction(mut self, direction: Direction) -> Self {
346 self.direction = direction;
347 self
348 }
349
350 #[must_use]
352 pub fn filter_language(mut self, lang: Language) -> Self {
353 self.filter_languages.insert(lang);
354 self
355 }
356
357 #[must_use]
359 pub fn filter_edge(mut self, edge: EdgeFilter) -> Self {
360 self.filter_edges.insert(edge);
361 self
362 }
363
364 #[must_use]
366 pub fn with_max_depth(mut self, depth: usize) -> Self {
367 self.max_depth = Some(depth);
368 self
369 }
370
371 #[must_use]
374 pub fn with_filter_node_ids(mut self, ids: Option<HashSet<NodeId>>) -> Self {
375 self.filter_node_ids = ids;
376 self
377 }
378}
379
380pub struct UnifiedDotExporter<'a> {
382 graph: &'a GraphSnapshot,
383 config: DotConfig,
384}
385
386impl<'a> UnifiedDotExporter<'a> {
387 #[must_use]
389 pub fn new(graph: &'a GraphSnapshot) -> Self {
390 Self {
391 graph,
392 config: DotConfig::default(),
393 }
394 }
395
396 #[must_use]
398 pub fn with_config(graph: &'a GraphSnapshot, config: DotConfig) -> Self {
399 Self { graph, config }
400 }
401
402 #[must_use]
404 pub fn export(&self) -> String {
405 let mut dot = String::from("digraph CodeGraph {\n");
406
407 let rankdir = self.config.direction.as_str();
409 writeln!(dot, " rankdir={rankdir};").expect("write to String never fails");
410 dot.push_str(" node [shape=box, style=filled];\n");
411 dot.push_str(" overlap=false;\n");
412 dot.push_str(" splines=true;\n\n");
413
414 let visible_nodes = self.filter_nodes();
423 let mut sorted_node_ids: Vec<NodeId> = visible_nodes.iter().copied().collect();
424 sorted_node_ids.sort();
425
426 for node_id in &sorted_node_ids {
428 if let Some(entry) = self.graph.get_node(*node_id) {
429 self.export_node(&mut dot, *node_id, entry);
430 }
431 }
432
433 dot.push('\n');
434
435 for (from, to, kind) in self.graph.iter_edges() {
437 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
438 continue;
439 }
440
441 if !self.edge_allowed(&kind) {
442 continue;
443 }
444
445 self.export_edge(&mut dot, from, to, &kind);
446 }
447
448 dot.push_str("}\n");
449 dot
450 }
451
452 fn filter_nodes(&self) -> HashSet<NodeId> {
454 if let Some(ref filter_ids) = self.config.filter_node_ids {
456 return filter_ids.clone();
457 }
458
459 if self.config.root_nodes.is_empty() && self.config.max_depth.is_none() {
461 return self
462 .graph
463 .iter_nodes()
464 .filter(|(id, entry)| self.should_include_node(*id, entry))
465 .map(|(id, _)| id)
466 .collect();
467 }
468
469 let adjacency = self.build_adjacency();
471 let depth_limit = self.config.max_depth.unwrap_or(usize::MAX);
472 let mut visible = HashSet::new();
473
474 let starting_nodes: Vec<NodeId> = if self.config.root_nodes.is_empty() {
475 self.graph
479 .iter_nodes()
480 .filter(|(_, entry)| !entry.is_unified_loser())
481 .map(|(id, _)| id)
482 .collect()
483 } else {
484 self.config.root_nodes.iter().copied().collect()
485 };
486
487 for node_id in starting_nodes {
488 if visible.contains(&node_id) {
489 continue;
490 }
491 self.collect_nodes(&mut visible, &adjacency, node_id, depth_limit);
492 }
493
494 visible
495 }
496
497 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
499 if entry.is_unified_loser() {
502 return false;
503 }
504 if !self.config.filter_languages.is_empty() {
506 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
507 if !self.config.filter_languages.contains(&lang) {
508 return false;
509 }
510 } else {
511 return false;
512 }
513 }
514
515 if !self.config.filter_files.is_empty() {
517 if let Some(path) = self.graph.files().resolve(entry.file) {
518 let path_str = path.to_string_lossy();
519 if !self
520 .config
521 .filter_files
522 .iter()
523 .any(|f| path_str.contains(f))
524 {
525 return false;
526 }
527 } else {
528 return false;
529 }
530 }
531
532 true
533 }
534
535 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
537 if self.config.filter_edges.is_empty() {
538 return true;
539 }
540 self.config.filter_edges.iter().any(|f| f.matches(kind))
541 }
542
543 fn build_adjacency(&self) -> HashMap<NodeId, Vec<NodeId>> {
545 let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
546 for (from, to, _) in self.graph.iter_edges() {
547 adjacency.entry(from).or_default().push(to);
548 adjacency.entry(to).or_default().push(from);
549 }
550 adjacency
551 }
552
553 fn collect_nodes(
555 &self,
556 visible: &mut HashSet<NodeId>,
557 adjacency: &HashMap<NodeId, Vec<NodeId>>,
558 start: NodeId,
559 depth_limit: usize,
560 ) {
561 let mut queue = VecDeque::new();
562 queue.push_back((start, 0usize));
563
564 while let Some((node_id, depth)) = queue.pop_front() {
565 if depth > depth_limit {
566 continue;
567 }
568
569 let Some(entry) = self.graph.get_node(node_id) else {
570 continue;
571 };
572
573 if !self.should_include_node(node_id, entry) {
574 continue;
575 }
576
577 if !visible.insert(node_id) {
578 continue;
579 }
580
581 if depth == depth_limit {
582 continue;
583 }
584
585 if let Some(neighbors) = adjacency.get(&node_id) {
586 for neighbor in neighbors {
587 queue.push_back((*neighbor, depth + 1));
588 }
589 }
590 }
591 }
592
593 fn export_node(&self, dot: &mut String, node_id: NodeId, entry: &NodeEntry) {
595 let lang = self.graph.files().language_for_file(entry.file);
596 let color = lang.map_or(default_language_color(), language_color);
597 let shape = node_shape(&entry.kind);
598
599 let name = self
601 .graph
602 .strings()
603 .resolve(entry.name)
604 .unwrap_or_else(|| std::sync::Arc::from("?"));
605 let qualified_name = entry
606 .qualified_name
607 .and_then(|id| self.graph.strings().resolve(id))
608 .unwrap_or_else(|| std::sync::Arc::clone(&name));
609
610 let label = if self.config.show_details {
612 let file = self
613 .graph
614 .files()
615 .resolve(entry.file)
616 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
617 format!("{}\\n{}:{}", qualified_name, file, entry.start_line)
618 } else {
619 qualified_name.to_string()
620 };
621
622 let label = escape_dot(&label);
623 let node_key = format!("n{}", node_id.index());
624
625 writeln!(
626 dot,
627 " \"{node_key}\" [label=\"{label}\", fillcolor=\"{color}\", shape=\"{shape}\"];",
628 )
629 .expect("write to String never fails");
630 }
631
632 fn export_edge(&self, dot: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
634 let (style, base_color) = edge_style(kind);
635 let color = if self.config.highlight_cross_language
636 && let (Some(from_entry), Some(to_entry)) =
637 (self.graph.get_node(from), self.graph.get_node(to))
638 {
639 let from_lang = self.graph.files().language_for_file(from_entry.file);
640 let to_lang = self.graph.files().language_for_file(to_entry.file);
641 if from_lang == to_lang {
642 base_color
643 } else {
644 "red"
645 }
646 } else {
647 base_color
648 };
649
650 let label = if self.config.show_edge_labels {
651 edge_label(kind, self.graph.strings())
652 } else {
653 String::new()
654 };
655
656 let from_key = format!("n{}", from.index());
657 let to_key = format!("n{}", to.index());
658
659 if label.is_empty() {
660 writeln!(
661 dot,
662 " \"{from_key}\" -> \"{to_key}\" [style=\"{style}\", color=\"{color}\"];"
663 )
664 .expect("write to String never fails");
665 } else {
666 writeln!(
667 dot,
668 " \"{from_key}\" -> \"{to_key}\" [style=\"{style}\", color=\"{color}\", label=\"{}\"];",
669 escape_dot(&label)
670 )
671 .expect("write to String never fails");
672 }
673 }
674}
675
676#[derive(Debug, Clone)]
682pub struct D2Config {
683 pub filter_languages: HashSet<Language>,
685 pub filter_edges: HashSet<EdgeFilter>,
687 pub filter_node_ids: Option<HashSet<NodeId>>,
690 pub highlight_cross_language: bool,
692 pub show_details: bool,
694 pub show_edge_labels: bool,
696 pub direction: Direction,
698}
699
700impl Default for D2Config {
701 fn default() -> Self {
702 Self {
703 filter_languages: HashSet::new(),
704 filter_edges: HashSet::new(),
705 filter_node_ids: None,
706 highlight_cross_language: false,
707 show_details: true,
708 show_edge_labels: true,
709 direction: Direction::LeftToRight,
710 }
711 }
712}
713
714impl D2Config {
715 #[must_use]
717 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
718 self.highlight_cross_language = enabled;
719 self
720 }
721
722 #[must_use]
724 pub fn with_details(mut self, enabled: bool) -> Self {
725 self.show_details = enabled;
726 self
727 }
728
729 #[must_use]
731 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
732 self.show_edge_labels = enabled;
733 self
734 }
735
736 #[must_use]
738 pub fn with_filter_node_ids(mut self, ids: Option<HashSet<NodeId>>) -> Self {
739 self.filter_node_ids = ids;
740 self
741 }
742}
743
744pub struct UnifiedD2Exporter<'a> {
746 graph: &'a GraphSnapshot,
747 config: D2Config,
748}
749
750impl<'a> UnifiedD2Exporter<'a> {
751 #[must_use]
753 pub fn new(graph: &'a GraphSnapshot) -> Self {
754 Self {
755 graph,
756 config: D2Config::default(),
757 }
758 }
759
760 #[must_use]
762 pub fn with_config(graph: &'a GraphSnapshot, config: D2Config) -> Self {
763 Self { graph, config }
764 }
765
766 #[must_use]
768 pub fn export(&self) -> String {
769 let mut d2 = String::new();
770
771 writeln!(d2, "direction: {}", self.config.direction.as_str())
773 .expect("write to String never fails");
774 d2.push('\n');
775
776 let visible_nodes: HashSet<NodeId> =
783 if let Some(ref filter_ids) = self.config.filter_node_ids {
784 filter_ids.clone()
785 } else {
786 self.graph
787 .iter_nodes()
788 .filter(|(id, entry)| self.should_include_node(*id, entry))
789 .map(|(id, _)| id)
790 .collect()
791 };
792 let mut sorted_node_ids: Vec<NodeId> = visible_nodes.iter().copied().collect();
793 sorted_node_ids.sort();
794
795 for node_id in &sorted_node_ids {
797 if let Some(entry) = self.graph.get_node(*node_id) {
798 self.export_node(&mut d2, *node_id, entry);
799 }
800 }
801
802 d2.push('\n');
803
804 for (from, to, kind) in self.graph.iter_edges() {
806 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
807 continue;
808 }
809
810 if !self.edge_allowed(&kind) {
811 continue;
812 }
813
814 self.export_edge(&mut d2, from, to, &kind);
815 }
816
817 d2
818 }
819
820 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
822 if entry.is_unified_loser() {
825 return false;
826 }
827 if !self.config.filter_languages.is_empty() {
828 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
829 if !self.config.filter_languages.contains(&lang) {
830 return false;
831 }
832 } else {
833 return false;
834 }
835 }
836 true
837 }
838
839 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
841 if self.config.filter_edges.is_empty() {
842 return true;
843 }
844 self.config.filter_edges.iter().any(|f| f.matches(kind))
845 }
846
847 fn export_node(&self, d2: &mut String, node_id: NodeId, entry: &NodeEntry) {
849 let lang = self.graph.files().language_for_file(entry.file);
850 let color = lang.map_or(default_language_color(), language_color);
851 let shape = match entry.kind {
852 NodeKind::Class | NodeKind::Struct => "class",
853 NodeKind::Interface | NodeKind::Trait => "oval",
854 NodeKind::Module => "package",
855 _ => "rectangle",
856 };
857
858 let name = self
859 .graph
860 .strings()
861 .resolve(entry.name)
862 .unwrap_or_else(|| std::sync::Arc::from("?"));
863 let qualified_name = entry
864 .qualified_name
865 .and_then(|id| self.graph.strings().resolve(id))
866 .unwrap_or_else(|| std::sync::Arc::clone(&name));
867
868 let label = if self.config.show_details {
869 let file = self
870 .graph
871 .files()
872 .resolve(entry.file)
873 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
874 format!("{} ({file}:{})", qualified_name.as_ref(), entry.start_line)
875 } else {
876 qualified_name.to_string()
877 };
878
879 let node_key = format!("n{}", node_id.index());
880 let label = escape_d2(&label);
881
882 writeln!(d2, "{node_key}: \"{label}\" {{").expect("write to String never fails");
883 writeln!(d2, " shape: {shape}").expect("write to String never fails");
884 writeln!(d2, " style.fill: \"{color}\"").expect("write to String never fails");
885 writeln!(d2, "}}").expect("write to String never fails");
886 }
887
888 fn export_edge(&self, d2: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
890 let (style, base_color) = edge_style(kind);
891 let color = if self.config.highlight_cross_language
892 && let (Some(from_entry), Some(to_entry)) =
893 (self.graph.get_node(from), self.graph.get_node(to))
894 {
895 let from_lang = self.graph.files().language_for_file(from_entry.file);
896 let to_lang = self.graph.files().language_for_file(to_entry.file);
897 if from_lang == to_lang {
898 base_color
899 } else {
900 "#ff0000"
901 }
902 } else {
903 base_color
904 };
905
906 let from_key = format!("n{}", from.index());
907 let to_key = format!("n{}", to.index());
908
909 let arrow = match kind {
910 EdgeKind::Inherits | EdgeKind::Implements => "<->",
911 _ => "->",
912 };
913
914 if self.config.show_edge_labels {
915 let label = edge_label(kind, self.graph.strings());
916 if label.is_empty() {
917 writeln!(d2, "{from_key} {arrow} {to_key}: {{")
918 .expect("write to String never fails");
919 } else {
920 writeln!(d2, "{from_key} {arrow} {to_key}: \"{label}\" {{")
921 .expect("write to String never fails");
922 }
923 } else {
924 writeln!(d2, "{from_key} {arrow} {to_key}: {{").expect("write to String never fails");
925 }
926
927 let d2_style = match style {
928 "dashed" => "stroke-dash: 3",
929 "dotted" => "stroke-dash: 1",
930 "bold" => "stroke-width: 3",
931 _ => "",
932 };
933
934 if !d2_style.is_empty() {
935 writeln!(d2, " style.{d2_style}").expect("write to String never fails");
936 }
937 writeln!(d2, " style.stroke: \"{color}\"").expect("write to String never fails");
938 writeln!(d2, "}}").expect("write to String never fails");
939 }
940}
941
942#[derive(Debug, Clone)]
948pub struct MermaidConfig {
949 pub filter_languages: HashSet<Language>,
951 pub filter_edges: HashSet<EdgeFilter>,
953 pub highlight_cross_language: bool,
955 pub show_edge_labels: bool,
957 pub direction: Direction,
959 pub filter_node_ids: Option<HashSet<NodeId>>,
962}
963
964impl Default for MermaidConfig {
965 fn default() -> Self {
966 Self {
967 filter_languages: HashSet::new(),
968 filter_edges: HashSet::new(),
969 highlight_cross_language: false,
970 show_edge_labels: true,
971 direction: Direction::LeftToRight,
972 filter_node_ids: None,
973 }
974 }
975}
976
977impl MermaidConfig {
978 #[must_use]
980 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
981 self.highlight_cross_language = enabled;
982 self
983 }
984
985 #[must_use]
987 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
988 self.show_edge_labels = enabled;
989 self
990 }
991
992 #[must_use]
994 pub fn with_filter_node_ids(mut self, ids: Option<HashSet<NodeId>>) -> Self {
995 self.filter_node_ids = ids;
996 self
997 }
998}
999
1000pub struct UnifiedMermaidExporter<'a> {
1002 graph: &'a GraphSnapshot,
1003 config: MermaidConfig,
1004}
1005
1006impl<'a> UnifiedMermaidExporter<'a> {
1007 #[must_use]
1009 pub fn new(graph: &'a GraphSnapshot) -> Self {
1010 Self {
1011 graph,
1012 config: MermaidConfig::default(),
1013 }
1014 }
1015
1016 #[must_use]
1018 pub fn with_config(graph: &'a GraphSnapshot, config: MermaidConfig) -> Self {
1019 Self { graph, config }
1020 }
1021
1022 #[must_use]
1024 pub fn export(&self) -> String {
1025 let mut mermaid = String::new();
1026
1027 writeln!(mermaid, "graph {}", self.config.direction.as_str())
1029 .expect("write to String never fails");
1030
1031 let visible_nodes: HashSet<NodeId> =
1038 if let Some(ref filter_ids) = self.config.filter_node_ids {
1039 filter_ids.clone()
1040 } else {
1041 self.graph
1042 .iter_nodes()
1043 .filter(|(id, entry)| self.should_include_node(*id, entry))
1044 .map(|(id, _)| id)
1045 .collect()
1046 };
1047 let mut sorted_node_ids: Vec<NodeId> = visible_nodes.iter().copied().collect();
1048 sorted_node_ids.sort();
1049
1050 for node_id in &sorted_node_ids {
1052 if let Some(entry) = self.graph.get_node(*node_id) {
1053 self.export_node(&mut mermaid, *node_id, entry);
1054 }
1055 }
1056
1057 for (from, to, kind) in self.graph.iter_edges() {
1059 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
1060 continue;
1061 }
1062
1063 if !self.edge_allowed(&kind) {
1064 continue;
1065 }
1066
1067 self.export_edge(&mut mermaid, from, to, &kind);
1068 }
1069
1070 mermaid
1071 }
1072
1073 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
1075 if entry.is_unified_loser() {
1078 return false;
1079 }
1080 if !self.config.filter_languages.is_empty() {
1081 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
1082 if !self.config.filter_languages.contains(&lang) {
1083 return false;
1084 }
1085 } else {
1086 return false;
1087 }
1088 }
1089 true
1090 }
1091
1092 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
1094 if self.config.filter_edges.is_empty() {
1095 return true;
1096 }
1097 self.config.filter_edges.iter().any(|f| f.matches(kind))
1098 }
1099
1100 fn export_node(&self, mermaid: &mut String, node_id: NodeId, entry: &NodeEntry) {
1102 let name = self
1103 .graph
1104 .strings()
1105 .resolve(entry.name)
1106 .unwrap_or_else(|| std::sync::Arc::from("?"));
1107 let qualified_name = entry
1108 .qualified_name
1109 .and_then(|id| self.graph.strings().resolve(id))
1110 .unwrap_or_else(|| std::sync::Arc::clone(&name));
1111
1112 let node_key = format!("n{}", node_id.index());
1113 let label = Self::escape_mermaid(&qualified_name);
1114
1115 let (open, close) = match entry.kind {
1117 NodeKind::Class | NodeKind::Struct => ("[[", "]]"),
1118 NodeKind::Interface | NodeKind::Trait => ("([", "])"),
1119 NodeKind::Module => ("{{", "}}"),
1120 _ => ("[", "]"),
1121 };
1122
1123 writeln!(mermaid, " {node_key}{open}\"{label}\"{close}")
1124 .expect("write to String never fails");
1125 }
1126
1127 fn export_edge(&self, mermaid: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
1129 let from_key = format!("n{}", from.index());
1130 let to_key = format!("n{}", to.index());
1131
1132 let arrow = match kind {
1133 EdgeKind::Imports { .. } | EdgeKind::Exports { .. } => "-.->",
1134 EdgeKind::Calls { is_async: true, .. } => "==>",
1135 _ => "-->",
1136 };
1137
1138 if self.config.show_edge_labels {
1139 let label = edge_label(kind, self.graph.strings());
1140 if label.is_empty() {
1141 writeln!(mermaid, " {from_key} {arrow} {to_key}")
1142 .expect("write to String never fails");
1143 } else {
1144 let label = Self::escape_mermaid(&label);
1145 writeln!(mermaid, " {from_key} {arrow}|\"{label}\"| {to_key}")
1146 .expect("write to String never fails");
1147 }
1148 } else {
1149 writeln!(mermaid, " {from_key} {arrow} {to_key}")
1150 .expect("write to String never fails");
1151 }
1152 }
1153
1154 fn escape_mermaid(s: &str) -> String {
1156 s.replace('"', "#quot;")
1157 .replace('<', "<")
1158 .replace('>', ">")
1159 }
1160}
1161
1162#[derive(Debug, Clone, Default)]
1168pub struct JsonConfig {
1169 pub include_details: bool,
1171 pub include_edge_metadata: bool,
1173}
1174
1175impl JsonConfig {
1176 #[must_use]
1178 pub fn with_details(mut self, enabled: bool) -> Self {
1179 self.include_details = enabled;
1180 self
1181 }
1182
1183 #[must_use]
1185 pub fn with_edge_metadata(mut self, enabled: bool) -> Self {
1186 self.include_edge_metadata = enabled;
1187 self
1188 }
1189}
1190
1191pub struct UnifiedJsonExporter<'a> {
1193 graph: &'a GraphSnapshot,
1194 config: JsonConfig,
1195}
1196
1197impl<'a> UnifiedJsonExporter<'a> {
1198 #[must_use]
1200 pub fn new(graph: &'a GraphSnapshot) -> Self {
1201 Self {
1202 graph,
1203 config: JsonConfig::default(),
1204 }
1205 }
1206
1207 #[must_use]
1209 pub fn with_config(graph: &'a GraphSnapshot, config: JsonConfig) -> Self {
1210 Self { graph, config }
1211 }
1212
1213 #[must_use]
1215 pub fn export(&self) -> serde_json::Value {
1216 let nodes = self.export_nodes();
1217 let edges = self.export_edges();
1218
1219 serde_json::json!({
1220 "nodes": nodes,
1221 "edges": edges,
1222 "metadata": {
1223 "node_count": nodes.len(),
1224 "edge_count": edges.len(),
1225 }
1226 })
1227 }
1228
1229 fn export_nodes(&self) -> Vec<serde_json::Value> {
1230 self.graph
1233 .iter_nodes()
1234 .filter(|(_, entry)| !entry.is_unified_loser())
1235 .map(|(node_id, entry)| self.export_node(node_id, entry))
1236 .collect()
1237 }
1238
1239 fn export_node(&self, node_id: NodeId, entry: &NodeEntry) -> serde_json::Value {
1240 let name = self
1241 .graph
1242 .strings()
1243 .resolve(entry.name)
1244 .map_or_else(|| "?".to_string(), |s| s.to_string());
1245 let qualified_name = entry
1246 .qualified_name
1247 .and_then(|id| self.graph.strings().resolve(id))
1248 .map_or_else(|| name.clone(), |s| s.to_string());
1249 let file = self
1250 .graph
1251 .files()
1252 .resolve(entry.file)
1253 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
1254 let lang = self
1255 .graph
1256 .files()
1257 .language_for_file(entry.file)
1258 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
1259
1260 let mut node = serde_json::json!({
1261 "id": format!("n{}", node_id.index()),
1262 "name": name,
1263 "qualified_name": qualified_name,
1264 "kind": format!("{:?}", entry.kind),
1265 "file": file,
1266 "language": lang,
1267 "line": entry.start_line,
1268 });
1269
1270 if self.config.include_details {
1271 self.append_node_details(entry, &mut node);
1272 }
1273
1274 node
1275 }
1276
1277 fn append_node_details(&self, entry: &NodeEntry, node: &mut serde_json::Value) {
1278 if let Some(sig_id) = entry.signature
1279 && let Some(sig) = self.graph.strings().resolve(sig_id)
1280 {
1281 node["signature"] = serde_json::Value::String(sig.to_string());
1282 }
1283 if let Some(doc_id) = entry.doc
1284 && let Some(doc) = self.graph.strings().resolve(doc_id)
1285 {
1286 node["doc"] = serde_json::Value::String(doc.to_string());
1287 }
1288 if let Some(vis_id) = entry.visibility
1289 && let Some(vis) = self.graph.strings().resolve(vis_id)
1290 {
1291 node["visibility"] = serde_json::Value::String(vis.to_string());
1292 }
1293 node["is_async"] = serde_json::Value::Bool(entry.is_async);
1294 node["is_static"] = serde_json::Value::Bool(entry.is_static);
1295 }
1296
1297 fn export_edges(&self) -> Vec<serde_json::Value> {
1298 self.graph
1299 .iter_edges()
1300 .map(|(from, to, kind)| self.export_edge(from, to, &kind))
1301 .collect()
1302 }
1303
1304 fn export_edge(&self, from: NodeId, to: NodeId, kind: &EdgeKind) -> serde_json::Value {
1305 let from_key = format!("n{}", from.index());
1306 let to_key = format!("n{}", to.index());
1307
1308 let mut edge = serde_json::json!({
1309 "from": from_key,
1310 "to": to_key,
1311 "kind": Self::edge_kind_name(kind),
1312 });
1313
1314 if self.config.include_edge_metadata {
1315 self.append_edge_metadata(kind, &mut edge);
1316 }
1317
1318 edge
1319 }
1320
1321 fn append_edge_metadata(&self, kind: &EdgeKind, edge: &mut serde_json::Value) {
1322 match kind {
1323 EdgeKind::Calls {
1324 argument_count,
1325 is_async,
1326 ..
1327 } => {
1328 edge["argument_count"] = serde_json::Value::Number((*argument_count).into());
1329 edge["is_async"] = serde_json::Value::Bool(*is_async);
1330 }
1331 EdgeKind::Imports { alias, is_wildcard } => {
1332 edge["is_wildcard"] = serde_json::Value::Bool(*is_wildcard);
1333 if let Some(alias_id) = alias
1334 && let Some(alias_str) = self.graph.strings().resolve(*alias_id)
1335 {
1336 edge["alias"] = serde_json::Value::String(alias_str.to_string());
1337 }
1338 }
1339 EdgeKind::Exports {
1340 kind: export_kind,
1341 alias,
1342 } => {
1343 edge["export_kind"] = serde_json::Value::String(format!("{export_kind:?}"));
1344 if let Some(alias_id) = alias
1345 && let Some(alias_str) = self.graph.strings().resolve(*alias_id)
1346 {
1347 edge["alias"] = serde_json::Value::String(alias_str.to_string());
1348 }
1349 }
1350 EdgeKind::HttpRequest { method, url } => {
1351 edge["method"] = serde_json::Value::String(method.as_str().to_string());
1352 if let Some(url_id) = url
1353 && let Some(url_str) = self.graph.strings().resolve(*url_id)
1354 {
1355 edge["url"] = serde_json::Value::String(url_str.to_string());
1356 }
1357 }
1358 _ => {}
1359 }
1360 }
1361
1362 fn edge_kind_name(kind: &EdgeKind) -> &'static str {
1364 match kind {
1365 EdgeKind::Defines => "defines",
1366 EdgeKind::Contains => "contains",
1367 EdgeKind::Calls { .. } => "calls",
1368 EdgeKind::References => "references",
1369 EdgeKind::Imports { .. } => "imports",
1370 EdgeKind::Exports { .. } => "exports",
1371 EdgeKind::TypeOf { .. } => "type_of",
1372 EdgeKind::Inherits => "inherits",
1373 EdgeKind::Implements => "implements",
1374 EdgeKind::FfiCall { .. } => "ffi_call",
1375 EdgeKind::HttpRequest { .. } => "http_request",
1376 EdgeKind::GrpcCall { .. } => "grpc_call",
1377 EdgeKind::WebAssemblyCall => "wasm_call",
1378 EdgeKind::DbQuery { .. } => "db_query",
1379 EdgeKind::TableRead { .. } => "table_read",
1380 EdgeKind::TableWrite { .. } => "table_write",
1381 EdgeKind::TriggeredBy { .. } => "triggered_by",
1382 EdgeKind::MessageQueue { .. } => "message_queue",
1383 EdgeKind::WebSocket { .. } => "websocket",
1384 EdgeKind::GraphQLOperation { .. } => "graphql_operation",
1385 EdgeKind::ProcessExec { .. } => "process_exec",
1386 EdgeKind::FileIpc { .. } => "file_ipc",
1387 EdgeKind::ProtocolCall { .. } => "protocol_call",
1388 EdgeKind::LifetimeConstraint { .. } => "lifetime_constraint",
1390 EdgeKind::TraitMethodBinding { .. } => "trait_method_binding",
1391 EdgeKind::MacroExpansion { .. } => "macro_expansion",
1392 EdgeKind::GenericBound => "generic_bound",
1394 EdgeKind::AnnotatedWith => "annotated_with",
1395 EdgeKind::AnnotationParam => "annotation_param",
1396 EdgeKind::LambdaCaptures => "lambda_captures",
1397 EdgeKind::ModuleExports => "module_exports",
1398 EdgeKind::ModuleRequires => "module_requires",
1399 EdgeKind::ModuleOpens => "module_opens",
1400 EdgeKind::ModuleProvides => "module_provides",
1401 EdgeKind::TypeArgument => "type_argument",
1402 EdgeKind::ExtensionReceiver => "extension_receiver",
1403 EdgeKind::CompanionOf => "companion_of",
1404 EdgeKind::SealedPermit => "sealed_permit",
1405 EdgeKind::Wraps { .. } => "wraps",
1407 EdgeKind::ChannelPeer { .. } => "channel_peer",
1409 EdgeKind::Instantiates { .. } => "instantiates",
1410 }
1411 }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416 use super::*;
1417
1418 #[test]
1421 fn test_direction_as_str() {
1422 assert_eq!(Direction::LeftToRight.as_str(), "LR");
1423 assert_eq!(Direction::TopToBottom.as_str(), "TB");
1424 }
1425
1426 #[test]
1427 fn test_direction_default() {
1428 assert_eq!(Direction::default(), Direction::LeftToRight);
1429 }
1430
1431 #[test]
1434 fn test_edge_filter_matches_calls() {
1435 assert!(EdgeFilter::Calls.matches(&EdgeKind::Calls {
1436 argument_count: 0,
1437 is_async: false,
1438 resolved_via: ResolvedVia::Direct,
1439 }));
1440 assert!(EdgeFilter::Calls.matches(&EdgeKind::Calls {
1441 argument_count: 5,
1442 is_async: true,
1443 resolved_via: ResolvedVia::Direct,
1444 }));
1445 assert!(!EdgeFilter::Calls.matches(&EdgeKind::References));
1446 }
1447
1448 #[test]
1449 fn test_edge_filter_matches_imports() {
1450 assert!(EdgeFilter::Imports.matches(&EdgeKind::Imports {
1451 alias: None,
1452 is_wildcard: false
1453 }));
1454 assert!(EdgeFilter::Imports.matches(&EdgeKind::Imports {
1455 alias: None,
1456 is_wildcard: true
1457 }));
1458 assert!(!EdgeFilter::Imports.matches(&EdgeKind::Exports {
1459 kind: crate::graph::unified::edge::ExportKind::Direct,
1460 alias: None
1461 }));
1462 }
1463
1464 #[test]
1465 fn test_edge_filter_matches_exports() {
1466 assert!(EdgeFilter::Exports.matches(&EdgeKind::Exports {
1467 kind: crate::graph::unified::edge::ExportKind::Direct,
1468 alias: None
1469 }));
1470 assert!(!EdgeFilter::Exports.matches(&EdgeKind::Imports {
1471 alias: None,
1472 is_wildcard: false
1473 }));
1474 }
1475
1476 #[test]
1477 fn test_edge_filter_matches_references() {
1478 assert!(EdgeFilter::References.matches(&EdgeKind::References));
1479 assert!(!EdgeFilter::References.matches(&EdgeKind::Calls {
1480 argument_count: 0,
1481 is_async: false,
1482 resolved_via: ResolvedVia::Direct,
1483 }));
1484 }
1485
1486 #[test]
1487 fn test_edge_filter_matches_inheritance() {
1488 assert!(EdgeFilter::Inherits.matches(&EdgeKind::Inherits));
1489 assert!(EdgeFilter::Implements.matches(&EdgeKind::Implements));
1490 assert!(!EdgeFilter::Inherits.matches(&EdgeKind::Implements));
1491 assert!(!EdgeFilter::Implements.matches(&EdgeKind::Inherits));
1492 }
1493
1494 #[test]
1495 fn test_edge_filter_matches_cross_language() {
1496 assert!(EdgeFilter::FfiCall.matches(&EdgeKind::FfiCall {
1497 convention: crate::graph::unified::edge::FfiConvention::C
1498 }));
1499 assert!(EdgeFilter::HttpRequest.matches(&EdgeKind::HttpRequest {
1500 method: crate::graph::unified::edge::HttpMethod::Get,
1501 url: None
1502 }));
1503 assert!(EdgeFilter::DbQuery.matches(&EdgeKind::DbQuery {
1504 query_type: crate::graph::unified::edge::DbQueryType::Select,
1505 table: None
1506 }));
1507 }
1508
1509 #[test]
1512 fn test_language_color_common_languages() {
1513 assert_eq!(language_color(Language::Rust), "#dea584");
1514 assert_eq!(language_color(Language::JavaScript), "#f7df1e");
1515 assert_eq!(language_color(Language::TypeScript), "#3178c6");
1516 assert_eq!(language_color(Language::Python), "#3572A5");
1517 assert_eq!(language_color(Language::Go), "#00ADD8");
1518 assert_eq!(language_color(Language::Java), "#b07219");
1519 }
1520
1521 #[test]
1522 fn test_language_color_all_languages() {
1523 let languages = [
1525 Language::Rust,
1526 Language::JavaScript,
1527 Language::TypeScript,
1528 Language::Python,
1529 Language::Go,
1530 Language::Java,
1531 Language::Ruby,
1532 Language::Php,
1533 Language::Cpp,
1534 Language::C,
1535 Language::Swift,
1536 Language::Kotlin,
1537 Language::Scala,
1538 Language::Sql,
1539 Language::Plsql,
1540 Language::Shell,
1541 Language::Lua,
1542 Language::Perl,
1543 Language::Dart,
1544 Language::Groovy,
1545 Language::Http,
1546 Language::Css,
1547 Language::Elixir,
1548 Language::R,
1549 Language::Haskell,
1550 Language::Html,
1551 Language::Svelte,
1552 Language::Vue,
1553 Language::Zig,
1554 Language::Terraform,
1555 Language::Puppet,
1556 Language::Apex,
1557 Language::Abap,
1558 Language::ServiceNow,
1559 Language::CSharp,
1560 ];
1561 for lang in languages {
1562 let color = language_color(lang);
1563 assert!(color.starts_with('#'), "Color for {lang:?} should be hex");
1564 }
1565 }
1566
1567 #[test]
1568 fn test_default_language_color() {
1569 assert_eq!(default_language_color(), "#cccccc");
1570 }
1571
1572 #[test]
1575 fn test_node_shape_class_types() {
1576 assert_eq!(node_shape(&NodeKind::Class), "component");
1577 assert_eq!(node_shape(&NodeKind::Struct), "component");
1578 }
1579
1580 #[test]
1581 fn test_node_shape_interface_types() {
1582 assert_eq!(node_shape(&NodeKind::Interface), "ellipse");
1583 assert_eq!(node_shape(&NodeKind::Trait), "ellipse");
1584 }
1585
1586 #[test]
1587 fn test_node_shape_module() {
1588 assert_eq!(node_shape(&NodeKind::Module), "folder");
1589 }
1590
1591 #[test]
1592 fn test_node_shape_variables() {
1593 assert_eq!(node_shape(&NodeKind::Variable), "note");
1594 assert_eq!(node_shape(&NodeKind::Constant), "note");
1595 }
1596
1597 #[test]
1598 fn test_node_shape_enums() {
1599 assert_eq!(node_shape(&NodeKind::Enum), "hexagon");
1600 assert_eq!(node_shape(&NodeKind::EnumVariant), "hexagon");
1601 }
1602
1603 #[test]
1604 fn test_node_shape_special() {
1605 assert_eq!(node_shape(&NodeKind::Type), "diamond");
1606 assert_eq!(node_shape(&NodeKind::Macro), "parallelogram");
1607 }
1608
1609 #[test]
1610 fn test_node_shape_default() {
1611 assert_eq!(node_shape(&NodeKind::Function), "box");
1612 assert_eq!(node_shape(&NodeKind::Method), "box");
1613 }
1614
1615 #[test]
1618 fn test_edge_style_calls() {
1619 let (style, color) = edge_style(&EdgeKind::Calls {
1620 argument_count: 0,
1621 is_async: false,
1622 resolved_via: ResolvedVia::Direct,
1623 });
1624 assert_eq!(style, "solid");
1625 assert_eq!(color, "#333333");
1626 }
1627
1628 #[test]
1629 fn test_edge_style_imports_exports() {
1630 let (style, color) = edge_style(&EdgeKind::Imports {
1631 alias: None,
1632 is_wildcard: false,
1633 });
1634 assert_eq!(style, "dashed");
1635 assert_eq!(color, "#0066cc");
1636
1637 let (style, color) = edge_style(&EdgeKind::Exports {
1638 kind: crate::graph::unified::edge::ExportKind::Direct,
1639 alias: None,
1640 });
1641 assert_eq!(style, "dashed");
1642 assert_eq!(color, "#00cc66");
1643 }
1644
1645 #[test]
1646 fn test_edge_style_references() {
1647 let (style, color) = edge_style(&EdgeKind::References);
1648 assert_eq!(style, "dotted");
1649 assert_eq!(color, "#666666");
1650 }
1651
1652 #[test]
1653 fn test_edge_style_inheritance() {
1654 let (style, color) = edge_style(&EdgeKind::Inherits);
1655 assert_eq!(style, "solid");
1656 assert_eq!(color, "#990099");
1657
1658 let (style, color) = edge_style(&EdgeKind::Implements);
1659 assert_eq!(style, "dashed");
1660 assert_eq!(color, "#990099");
1661 }
1662
1663 #[test]
1664 fn test_edge_style_cross_language() {
1665 let (style, color) = edge_style(&EdgeKind::FfiCall {
1666 convention: crate::graph::unified::edge::FfiConvention::C,
1667 });
1668 assert_eq!(style, "bold");
1669 assert_eq!(color, "#ff6600");
1670
1671 let (style, color) = edge_style(&EdgeKind::HttpRequest {
1672 method: crate::graph::unified::edge::HttpMethod::Get,
1673 url: None,
1674 });
1675 assert_eq!(style, "bold");
1676 assert_eq!(color, "#cc0000");
1677
1678 let (style, color) = edge_style(&EdgeKind::DbQuery {
1679 query_type: crate::graph::unified::edge::DbQueryType::Select,
1680 table: None,
1681 });
1682 assert_eq!(style, "bold");
1683 assert_eq!(color, "#009900");
1684 }
1685
1686 #[test]
1689 fn test_escape_dot_basic() {
1690 assert_eq!(escape_dot("hello"), "hello");
1691 assert_eq!(escape_dot("hello world"), "hello world");
1692 }
1693
1694 #[test]
1695 fn test_escape_dot_quotes() {
1696 assert_eq!(escape_dot("say \"hi\""), "say \\\"hi\\\"");
1697 assert_eq!(escape_dot("\"quoted\""), "\\\"quoted\\\"");
1698 }
1699
1700 #[test]
1701 fn test_escape_dot_newlines() {
1702 assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
1703 assert_eq!(escape_dot("a\nb\nc"), "a\\nb\\nc");
1704 }
1705
1706 #[test]
1707 fn test_escape_dot_backslashes() {
1708 assert_eq!(escape_dot("path\\to\\file"), "path\\\\to\\\\file");
1709 }
1710
1711 #[test]
1712 fn test_escape_d2_basic() {
1713 assert_eq!(escape_d2("hello"), "hello");
1714 assert_eq!(escape_d2("hello world"), "hello world");
1715 }
1716
1717 #[test]
1718 fn test_escape_d2_quotes_and_newlines() {
1719 assert_eq!(escape_d2("say \"hi\""), "say \\\"hi\\\"");
1721 assert_eq!(escape_d2("line1\nline2"), "line1 line2");
1722 assert_eq!(escape_d2("path\\to\\file"), "path\\\\to\\\\file");
1723 }
1724
1725 #[test]
1728 fn test_dot_config_default() {
1729 let config = DotConfig::default();
1730 assert_eq!(config.direction, Direction::LeftToRight);
1731 assert!(!config.highlight_cross_language);
1732 assert!(config.show_details); assert!(config.show_edge_labels); assert!(config.filter_node_ids.is_none());
1735 assert!(config.filter_languages.is_empty());
1736 assert!(config.filter_edges.is_empty());
1737 assert!(config.filter_files.is_empty());
1738 }
1739
1740 #[test]
1741 fn test_dot_config_builder() {
1742 let config = DotConfig::default()
1743 .with_direction(Direction::TopToBottom)
1744 .with_cross_language_highlight(true)
1745 .with_details(true);
1746 assert_eq!(config.direction, Direction::TopToBottom);
1747 assert!(config.highlight_cross_language);
1748 assert!(config.show_details);
1749
1750 let mut ids = HashSet::new();
1752 ids.insert(NodeId::new(0, 0));
1753 let config_with_filter = DotConfig::default().with_filter_node_ids(Some(ids.clone()));
1754 assert!(config_with_filter.filter_node_ids.is_some());
1755 assert_eq!(config_with_filter.filter_node_ids.as_ref().unwrap(), &ids);
1756
1757 let config_cleared = config_with_filter.with_filter_node_ids(None);
1758 assert!(config_cleared.filter_node_ids.is_none());
1759 }
1760
1761 #[test]
1762 fn test_d2_config_default() {
1763 let config = D2Config::default();
1764 assert_eq!(config.direction, Direction::LeftToRight);
1765 assert!(!config.highlight_cross_language);
1766 assert!(config.show_details); assert!(config.show_edge_labels); assert!(config.filter_node_ids.is_none());
1769 assert!(config.filter_languages.is_empty());
1770 assert!(config.filter_edges.is_empty());
1771 }
1772
1773 #[test]
1774 fn test_d2_config_builder() {
1775 let config = D2Config::default()
1776 .with_cross_language_highlight(true)
1777 .with_details(true)
1778 .with_edge_labels(true);
1779 assert!(config.highlight_cross_language);
1780 assert!(config.show_details);
1781 assert!(config.show_edge_labels);
1782
1783 let mut ids = HashSet::new();
1785 ids.insert(NodeId::new(0, 0));
1786 let config_with_filter = D2Config::default().with_filter_node_ids(Some(ids.clone()));
1787 assert!(config_with_filter.filter_node_ids.is_some());
1788 assert_eq!(config_with_filter.filter_node_ids.as_ref().unwrap(), &ids);
1789
1790 let config_cleared = config_with_filter.with_filter_node_ids(None);
1791 assert!(config_cleared.filter_node_ids.is_none());
1792 }
1793
1794 #[test]
1795 fn test_mermaid_config_default() {
1796 let config = MermaidConfig::default();
1797 assert_eq!(config.direction, Direction::LeftToRight);
1798 assert!(!config.highlight_cross_language);
1799 assert!(config.show_edge_labels); assert!(config.filter_node_ids.is_none());
1801 assert!(config.filter_languages.is_empty());
1802 assert!(config.filter_edges.is_empty());
1803 }
1804
1805 #[test]
1806 fn test_mermaid_config_builder() {
1807 let config = MermaidConfig::default()
1808 .with_cross_language_highlight(true)
1809 .with_edge_labels(false);
1810 assert!(config.highlight_cross_language);
1811 assert!(!config.show_edge_labels);
1812
1813 let mut ids = HashSet::new();
1815 ids.insert(NodeId::new(0, 0));
1816 let config_with_filter = MermaidConfig::default().with_filter_node_ids(Some(ids.clone()));
1817 assert!(config_with_filter.filter_node_ids.is_some());
1818 assert_eq!(config_with_filter.filter_node_ids.as_ref().unwrap(), &ids);
1819
1820 let config_cleared = config_with_filter.with_filter_node_ids(None);
1821 assert!(config_cleared.filter_node_ids.is_none());
1822 }
1823
1824 #[test]
1825 fn test_json_config_builder() {
1826 let config = JsonConfig::default()
1827 .with_details(true)
1828 .with_edge_metadata(true);
1829 assert!(config.include_details);
1830 assert!(config.include_edge_metadata);
1831 }
1832
1833 use crate::graph::unified::concurrent::CodeGraph;
1838 use crate::graph::unified::edge::BidirectionalEdgeStore;
1839 use crate::graph::unified::storage::NodeEntry;
1840 use crate::graph::unified::storage::arena::NodeArena;
1841 use crate::graph::unified::storage::indices::AuxiliaryIndices;
1842 use crate::graph::unified::storage::interner::StringInterner;
1843 use crate::graph::unified::storage::registry::FileRegistry;
1844 use std::path::Path;
1845
1846 fn create_test_graph_for_export() -> CodeGraph {
1848 let mut nodes = NodeArena::new();
1849 let mut strings = StringInterner::new();
1850 let mut files = FileRegistry::new();
1851 let edges = BidirectionalEdgeStore::new();
1852 let indices = AuxiliaryIndices::new();
1853
1854 let file_id = files
1856 .register_with_language(Path::new("src/main.rs"), Some(Language::Rust))
1857 .unwrap();
1858
1859 let name_main = strings.intern("main").unwrap();
1861 let name_helper = strings.intern("helper").unwrap();
1862 let qname_main = strings.intern("app::main").unwrap();
1863 let qname_helper = strings.intern("app::helper").unwrap();
1864 let sig = strings.intern("fn main()").unwrap();
1865
1866 let main_entry = NodeEntry {
1868 kind: NodeKind::Function,
1869 name: name_main,
1870 file: file_id,
1871 start_byte: 0,
1872 end_byte: 100,
1873 start_line: 1,
1874 start_column: 0,
1875 end_line: 10,
1876 end_column: 1,
1877 signature: Some(sig),
1878 doc: None,
1879 qualified_name: Some(qname_main),
1880 visibility: None,
1881 is_async: false,
1882 is_static: false,
1883 is_unsafe: false,
1884 is_definition: false,
1885 body_hash: None,
1886 };
1887 let main_id = nodes.alloc(main_entry).unwrap();
1888
1889 let helper_entry = NodeEntry {
1890 kind: NodeKind::Function,
1891 name: name_helper,
1892 file: file_id,
1893 start_byte: 100,
1894 end_byte: 200,
1895 start_line: 11,
1896 start_column: 0,
1897 end_line: 20,
1898 end_column: 1,
1899 signature: None,
1900 doc: None,
1901 qualified_name: Some(qname_helper),
1902 visibility: None,
1903 is_async: true,
1904 is_static: false,
1905 is_unsafe: false,
1906 is_definition: false,
1907 body_hash: None,
1908 };
1909 let helper_id = nodes.alloc(helper_entry).unwrap();
1910
1911 edges.add_edge(
1913 main_id,
1914 helper_id,
1915 EdgeKind::Calls {
1916 argument_count: 2,
1917 is_async: false,
1918 resolved_via: ResolvedVia::Direct,
1919 },
1920 file_id,
1921 );
1922
1923 CodeGraph::from_components(
1924 nodes,
1925 edges,
1926 strings,
1927 files,
1928 indices,
1929 crate::graph::unified::NodeMetadataStore::new(),
1930 )
1931 }
1932
1933 #[test]
1938 fn raw_exporters_are_byte_stable() {
1939 let graph = create_test_graph_for_export();
1940 let snapshot = graph.snapshot();
1941
1942 let dot_a = UnifiedDotExporter::new(&snapshot).export();
1943 let dot_b = UnifiedDotExporter::new(&snapshot).export();
1944 let d2_a = UnifiedD2Exporter::new(&snapshot).export();
1945 let d2_b = UnifiedD2Exporter::new(&snapshot).export();
1946 let mm_a = UnifiedMermaidExporter::new(&snapshot).export();
1947 let mm_b = UnifiedMermaidExporter::new(&snapshot).export();
1948 let js_a = serde_json::to_string(&UnifiedJsonExporter::new(&snapshot).export()).unwrap();
1949 let js_b = serde_json::to_string(&UnifiedJsonExporter::new(&snapshot).export()).unwrap();
1950
1951 assert_eq!(dot_a, dot_b, "DOT output must be byte-stable");
1952 assert_eq!(d2_a, d2_b, "D2 output must be byte-stable");
1953 assert_eq!(mm_a, mm_b, "Mermaid output must be byte-stable");
1954 assert_eq!(js_a, js_b, "JSON output must be byte-stable");
1955
1956 assert!(dot_a.starts_with("digraph CodeGraph {"));
1960 assert!(!dot_a.contains("schema_version"));
1961 }
1962
1963 #[test]
1966 fn test_unified_dot_exporter_basic() {
1967 let graph = create_test_graph_for_export();
1968 let snapshot = graph.snapshot();
1969 let exporter = UnifiedDotExporter::new(&snapshot);
1970 let output = exporter.export();
1971
1972 assert!(output.starts_with("digraph CodeGraph {"));
1974 assert!(output.ends_with("}\n"));
1975 assert!(output.contains("rankdir=LR"));
1976 }
1977
1978 #[test]
1979 fn test_unified_dot_exporter_with_config() {
1980 let graph = create_test_graph_for_export();
1981 let snapshot = graph.snapshot();
1982 let config = DotConfig::default()
1983 .with_direction(Direction::TopToBottom)
1984 .with_cross_language_highlight(true)
1985 .with_details(true);
1986 let exporter = UnifiedDotExporter::with_config(&snapshot, config);
1987 let output = exporter.export();
1988
1989 assert!(output.contains("rankdir=TB"));
1990 }
1991
1992 #[test]
1993 fn test_unified_dot_exporter_contains_nodes() {
1994 let graph = create_test_graph_for_export();
1995 let snapshot = graph.snapshot();
1996 let exporter = UnifiedDotExporter::new(&snapshot);
1997 let output = exporter.export();
1998
1999 assert!(output.contains("n0"), "Should contain first node");
2001 assert!(output.contains("n1"), "Should contain second node");
2002 assert!(
2004 output.contains("main") || output.contains("app::main"),
2005 "Should contain main function name"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_unified_dot_exporter_contains_edges() {
2011 let graph = create_test_graph_for_export();
2012 let snapshot = graph.snapshot();
2013 let exporter = UnifiedDotExporter::new(&snapshot);
2014 let output = exporter.export();
2015
2016 assert!(output.contains("->"), "Should contain edge arrow");
2018 }
2019
2020 #[test]
2021 fn test_unified_dot_exporter_empty_graph() {
2022 let graph = CodeGraph::new();
2023 let snapshot = graph.snapshot();
2024 let exporter = UnifiedDotExporter::new(&snapshot);
2025 let output = exporter.export();
2026
2027 assert!(output.starts_with("digraph CodeGraph {"));
2028 assert!(output.ends_with("}\n"));
2029 assert!(!output.contains("n0"));
2031 }
2032
2033 #[test]
2036 fn test_unified_d2_exporter_basic() {
2037 let graph = create_test_graph_for_export();
2038 let snapshot = graph.snapshot();
2039 let exporter = UnifiedD2Exporter::new(&snapshot);
2040 let output = exporter.export();
2041
2042 assert!(output.contains("direction: LR"));
2044 }
2045
2046 #[test]
2047 fn test_unified_d2_exporter_with_config() {
2048 let graph = create_test_graph_for_export();
2049 let snapshot = graph.snapshot();
2050 let config = D2Config::default().with_cross_language_highlight(true);
2051 let exporter = UnifiedD2Exporter::with_config(&snapshot, config);
2052 let output = exporter.export();
2053
2054 assert!(output.contains("direction:"));
2055 }
2056
2057 #[test]
2058 fn test_unified_d2_exporter_contains_nodes() {
2059 let graph = create_test_graph_for_export();
2060 let snapshot = graph.snapshot();
2061 let exporter = UnifiedD2Exporter::new(&snapshot);
2062 let output = exporter.export();
2063
2064 assert!(
2066 output.contains("n0:"),
2067 "Should contain first node definition"
2068 );
2069 assert!(
2070 output.contains("n1:"),
2071 "Should contain second node definition"
2072 );
2073 }
2074
2075 #[test]
2076 fn test_unified_d2_exporter_contains_edges() {
2077 let graph = create_test_graph_for_export();
2078 let snapshot = graph.snapshot();
2079 let exporter = UnifiedD2Exporter::new(&snapshot);
2080 let output = exporter.export();
2081
2082 assert!(
2084 output.contains("->") || output.contains("<->"),
2085 "Should contain edge"
2086 );
2087 }
2088
2089 #[test]
2092 fn test_unified_mermaid_exporter_basic() {
2093 let graph = create_test_graph_for_export();
2094 let snapshot = graph.snapshot();
2095 let exporter = UnifiedMermaidExporter::new(&snapshot);
2096 let output = exporter.export();
2097
2098 assert!(output.starts_with("graph LR"));
2100 }
2101
2102 #[test]
2103 fn test_unified_mermaid_exporter_with_config() {
2104 let graph = create_test_graph_for_export();
2105 let snapshot = graph.snapshot();
2106 let config = MermaidConfig::default()
2107 .with_cross_language_highlight(true)
2108 .with_edge_labels(false);
2109 let exporter = UnifiedMermaidExporter::with_config(&snapshot, config);
2110 let output = exporter.export();
2111
2112 assert!(output.starts_with("graph LR"));
2113 }
2114
2115 #[test]
2116 fn test_unified_mermaid_exporter_contains_nodes() {
2117 let graph = create_test_graph_for_export();
2118 let snapshot = graph.snapshot();
2119 let exporter = UnifiedMermaidExporter::new(&snapshot);
2120 let output = exporter.export();
2121
2122 assert!(output.contains("n0"), "Should contain first node");
2124 assert!(output.contains("n1"), "Should contain second node");
2125 }
2126
2127 #[test]
2128 fn test_unified_mermaid_exporter_contains_edges() {
2129 let graph = create_test_graph_for_export();
2130 let snapshot = graph.snapshot();
2131 let exporter = UnifiedMermaidExporter::new(&snapshot);
2132 let output = exporter.export();
2133
2134 assert!(
2136 output.contains("-->") || output.contains("==>") || output.contains("-.->"),
2137 "Should contain edge"
2138 );
2139 }
2140
2141 #[test]
2144 fn test_unified_json_exporter_basic() {
2145 let graph = create_test_graph_for_export();
2146 let snapshot = graph.snapshot();
2147 let exporter = UnifiedJsonExporter::new(&snapshot);
2148 let output = exporter.export();
2149
2150 assert!(output.is_object());
2152 assert!(output.get("nodes").is_some());
2153 assert!(output.get("edges").is_some());
2154 assert!(output.get("metadata").is_some());
2155 }
2156
2157 #[test]
2158 fn test_unified_json_exporter_node_count() {
2159 let graph = create_test_graph_for_export();
2160 let snapshot = graph.snapshot();
2161 let exporter = UnifiedJsonExporter::new(&snapshot);
2162 let output = exporter.export();
2163
2164 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2165 assert_eq!(nodes.len(), 2, "Should have 2 nodes");
2166 }
2167
2168 #[test]
2169 fn test_unified_json_exporter_edge_count() {
2170 let graph = create_test_graph_for_export();
2171 let snapshot = graph.snapshot();
2172 let exporter = UnifiedJsonExporter::new(&snapshot);
2173 let output = exporter.export();
2174
2175 let edges = output.get("edges").unwrap().as_array().unwrap();
2176 assert_eq!(edges.len(), 1, "Should have 1 edge");
2177 }
2178
2179 #[test]
2180 fn test_unified_json_exporter_metadata() {
2181 let graph = create_test_graph_for_export();
2182 let snapshot = graph.snapshot();
2183 let exporter = UnifiedJsonExporter::new(&snapshot);
2184 let output = exporter.export();
2185
2186 let metadata = output.get("metadata").unwrap();
2187 assert_eq!(
2188 metadata.get("node_count").unwrap().as_u64().unwrap(),
2189 2,
2190 "Metadata should report 2 nodes"
2191 );
2192 assert_eq!(
2193 metadata.get("edge_count").unwrap().as_u64().unwrap(),
2194 1,
2195 "Metadata should report 1 edge"
2196 );
2197 }
2198
2199 #[test]
2200 fn test_unified_json_exporter_with_details() {
2201 let graph = create_test_graph_for_export();
2202 let snapshot = graph.snapshot();
2203 let config = JsonConfig::default().with_details(true);
2204 let exporter = UnifiedJsonExporter::with_config(&snapshot, config);
2205 let output = exporter.export();
2206
2207 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2208 let main_node = &nodes[0];
2210 assert!(
2211 main_node.get("signature").is_some(),
2212 "Node should have signature when details enabled"
2213 );
2214 }
2215
2216 #[test]
2217 fn test_unified_json_exporter_with_edge_metadata() {
2218 let graph = create_test_graph_for_export();
2219 let snapshot = graph.snapshot();
2220 let config = JsonConfig::default().with_edge_metadata(true);
2221 let exporter = UnifiedJsonExporter::with_config(&snapshot, config);
2222 let output = exporter.export();
2223
2224 let edges = output.get("edges").unwrap().as_array().unwrap();
2225 let edge = &edges[0];
2226 assert!(
2228 edge.get("argument_count").is_some(),
2229 "Edge should have argument_count metadata"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_unified_json_exporter_empty_graph() {
2235 let graph = CodeGraph::new();
2236 let snapshot = graph.snapshot();
2237 let exporter = UnifiedJsonExporter::new(&snapshot);
2238 let output = exporter.export();
2239
2240 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2241 let edges = output.get("edges").unwrap().as_array().unwrap();
2242 assert!(nodes.is_empty(), "Empty graph should have no nodes");
2243 assert!(edges.is_empty(), "Empty graph should have no edges");
2244 }
2245
2246 #[test]
2249 fn test_edge_label_calls() {
2250 let strings = StringInterner::new();
2251 let label = edge_label(
2252 &EdgeKind::Calls {
2253 argument_count: 3,
2254 is_async: false,
2255 resolved_via: ResolvedVia::Direct,
2256 },
2257 &strings,
2258 );
2259 assert_eq!(label, "call(3)");
2260 }
2261
2262 #[test]
2263 fn test_edge_label_async_calls() {
2264 let strings = StringInterner::new();
2265 let label = edge_label(
2266 &EdgeKind::Calls {
2267 argument_count: 2,
2268 is_async: true,
2269 resolved_via: ResolvedVia::Direct,
2270 },
2271 &strings,
2272 );
2273 assert_eq!(label, "async call(2)");
2274 }
2275
2276 #[test]
2277 fn test_edge_label_imports_simple() {
2278 let strings = StringInterner::new();
2279 let label = edge_label(
2280 &EdgeKind::Imports {
2281 alias: None,
2282 is_wildcard: false,
2283 },
2284 &strings,
2285 );
2286 assert_eq!(label, "import");
2287 }
2288
2289 #[test]
2290 fn test_edge_label_imports_wildcard() {
2291 let strings = StringInterner::new();
2292 let label = edge_label(
2293 &EdgeKind::Imports {
2294 alias: None,
2295 is_wildcard: true,
2296 },
2297 &strings,
2298 );
2299 assert_eq!(label, "import *");
2300 }
2301
2302 #[test]
2303 fn test_edge_label_references() {
2304 let strings = StringInterner::new();
2305 let label = edge_label(&EdgeKind::References, &strings);
2306 assert_eq!(label, "ref");
2307 }
2308
2309 #[test]
2310 fn test_edge_label_inherits() {
2311 let strings = StringInterner::new();
2312 let label = edge_label(&EdgeKind::Inherits, &strings);
2313 assert_eq!(label, "extends");
2314 }
2315
2316 #[test]
2317 fn test_edge_label_implements() {
2318 let strings = StringInterner::new();
2319 let label = edge_label(&EdgeKind::Implements, &strings);
2320 assert_eq!(label, "implements");
2321 }
2322
2323 #[test]
2326 fn test_mermaid_filter_node_ids_restricts_output() {
2327 let graph = create_test_graph_for_export();
2328 let snapshot = graph.snapshot();
2329
2330 let all_nodes: Vec<NodeId> = snapshot.iter_nodes().map(|(id, _)| id).collect();
2332 assert!(
2333 all_nodes.len() >= 2,
2334 "test graph must have at least 2 nodes"
2335 );
2336
2337 let kept_id = all_nodes[0];
2339 let expected_key = format!("n{}", kept_id.index());
2340 let excluded_key = format!("n{}", all_nodes[1].index());
2342
2343 let mut filter = HashSet::new();
2344 filter.insert(kept_id);
2345
2346 let config = MermaidConfig::default().with_filter_node_ids(Some(filter));
2347 let exporter = UnifiedMermaidExporter::with_config(&snapshot, config);
2348 let output = exporter.export();
2349
2350 let node_defs: Vec<&str> = output
2352 .lines()
2353 .filter(|l| l.trim_start().starts_with('n') && l.contains('['))
2354 .collect();
2355
2356 assert_eq!(
2357 node_defs.len(),
2358 1,
2359 "filtered export should have exactly 1 node, got: {node_defs:?}"
2360 );
2361
2362 assert!(
2364 node_defs[0].trim_start().starts_with(&expected_key),
2365 "expected node key '{expected_key}' but got: {}",
2366 node_defs[0]
2367 );
2368
2369 let excluded_present = output
2371 .lines()
2372 .any(|l| l.trim_start().starts_with(&excluded_key));
2373 assert!(
2374 !excluded_present,
2375 "excluded node key '{excluded_key}' must not appear in filtered output"
2376 );
2377
2378 let edge_lines: Vec<&str> = output
2380 .lines()
2381 .filter(|l| l.contains("-->") || l.contains("---"))
2382 .collect();
2383 assert!(
2384 edge_lines.is_empty(),
2385 "no edges should appear when only one node is visible, got: {edge_lines:?}"
2386 );
2387 }
2388
2389 #[test]
2392 fn test_d2_filter_node_ids_restricts_output() {
2393 let graph = create_test_graph_for_export();
2394 let snapshot = graph.snapshot();
2395
2396 let all_nodes: Vec<NodeId> = snapshot.iter_nodes().map(|(id, _)| id).collect();
2398 assert!(
2399 all_nodes.len() >= 2,
2400 "test graph must have at least 2 nodes"
2401 );
2402
2403 let kept_id = all_nodes[0];
2405 let expected_key = format!("n{}:", kept_id.index());
2406 let excluded_key = format!("n{}:", all_nodes[1].index());
2408
2409 let mut filter = HashSet::new();
2410 filter.insert(kept_id);
2411
2412 let config = D2Config::default().with_filter_node_ids(Some(filter));
2413 let exporter = UnifiedD2Exporter::with_config(&snapshot, config);
2414 let output = exporter.export();
2415
2416 let node_defs: Vec<&str> = output
2418 .lines()
2419 .filter(|l| {
2420 let trimmed = l.trim_start();
2421 (trimmed.starts_with('n') && trimmed.contains(": {"))
2422 || (trimmed.starts_with('n') && trimmed.contains(": \""))
2423 })
2424 .collect();
2425
2426 assert_eq!(
2427 node_defs.len(),
2428 1,
2429 "filtered export should have exactly 1 node, got: {node_defs:?}"
2430 );
2431
2432 assert!(
2434 node_defs[0].trim_start().starts_with(&expected_key),
2435 "expected node key '{expected_key}' but got: {}",
2436 node_defs[0]
2437 );
2438
2439 let excluded_present = output
2441 .lines()
2442 .any(|l| l.trim_start().starts_with(&excluded_key));
2443 assert!(
2444 !excluded_present,
2445 "excluded node key '{excluded_key}' must not appear in filtered output"
2446 );
2447
2448 let edge_lines: Vec<&str> = output
2450 .lines()
2451 .filter(|l| l.contains("->") || l.contains("<->"))
2452 .collect();
2453 assert!(
2454 edge_lines.is_empty(),
2455 "no edges should appear when only one node is visible, got: {edge_lines:?}"
2456 );
2457 }
2458
2459 #[test]
2462 fn test_dot_filter_node_ids_restricts_output() {
2463 let graph = create_test_graph_for_export();
2464 let snapshot = graph.snapshot();
2465
2466 let all_nodes: Vec<NodeId> = snapshot.iter_nodes().map(|(id, _)| id).collect();
2468 assert!(
2469 all_nodes.len() >= 2,
2470 "test graph must have at least 2 nodes"
2471 );
2472
2473 let kept_id = all_nodes[0];
2475 let expected_key = format!("\"n{}\"", kept_id.index());
2476 let excluded_key = format!("\"n{}\"", all_nodes[1].index());
2478
2479 let mut filter = HashSet::new();
2480 filter.insert(kept_id);
2481
2482 let config = DotConfig::default().with_filter_node_ids(Some(filter));
2483 let exporter = UnifiedDotExporter::with_config(&snapshot, config);
2484 let output = exporter.export();
2485
2486 let node_defs: Vec<&str> = output
2488 .lines()
2489 .filter(|l| {
2490 let trimmed = l.trim_start();
2491 trimmed.starts_with('"') && trimmed.contains("[label=")
2492 })
2493 .collect();
2494
2495 assert_eq!(
2496 node_defs.len(),
2497 1,
2498 "filtered export should have exactly 1 node, got: {node_defs:?}"
2499 );
2500
2501 assert!(
2503 node_defs[0].trim_start().starts_with(&expected_key),
2504 "expected node key '{expected_key}' but got: {}",
2505 node_defs[0]
2506 );
2507
2508 let excluded_present = output
2510 .lines()
2511 .any(|l| l.trim_start().starts_with(&excluded_key) && l.contains("[label="));
2512 assert!(
2513 !excluded_present,
2514 "excluded node key '{excluded_key}' must not appear in filtered output"
2515 );
2516
2517 let edge_lines: Vec<&str> = output.lines().filter(|l| l.contains("->")).collect();
2519 assert!(
2520 edge_lines.is_empty(),
2521 "no edges should appear when only one node is visible, got: {edge_lines:?}"
2522 );
2523 }
2524
2525 #[test]
2528 fn test_dot_config_filter_language() {
2529 let config = DotConfig::default().filter_language(Language::Rust);
2530 assert!(config.filter_languages.contains(&Language::Rust));
2531 }
2532
2533 #[test]
2534 fn test_dot_config_filter_edge() {
2535 let config = DotConfig::default().filter_edge(EdgeFilter::Calls);
2536 assert!(config.filter_edges.contains(&EdgeFilter::Calls));
2537 }
2538
2539 #[test]
2540 fn test_dot_config_with_max_depth() {
2541 let config = DotConfig::default().with_max_depth(5);
2542 assert_eq!(config.max_depth, Some(5));
2543 }
2544}