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();
416
417 for node_id in &visible_nodes {
419 if let Some(entry) = self.graph.get_node(*node_id) {
420 self.export_node(&mut dot, *node_id, entry);
421 }
422 }
423
424 dot.push('\n');
425
426 for (from, to, kind) in self.graph.iter_edges() {
428 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
429 continue;
430 }
431
432 if !self.edge_allowed(&kind) {
433 continue;
434 }
435
436 self.export_edge(&mut dot, from, to, &kind);
437 }
438
439 dot.push_str("}\n");
440 dot
441 }
442
443 fn filter_nodes(&self) -> HashSet<NodeId> {
445 if let Some(ref filter_ids) = self.config.filter_node_ids {
447 return filter_ids.clone();
448 }
449
450 if self.config.root_nodes.is_empty() && self.config.max_depth.is_none() {
452 return self
453 .graph
454 .iter_nodes()
455 .filter(|(id, entry)| self.should_include_node(*id, entry))
456 .map(|(id, _)| id)
457 .collect();
458 }
459
460 let adjacency = self.build_adjacency();
462 let depth_limit = self.config.max_depth.unwrap_or(usize::MAX);
463 let mut visible = HashSet::new();
464
465 let starting_nodes: Vec<NodeId> = if self.config.root_nodes.is_empty() {
466 self.graph
470 .iter_nodes()
471 .filter(|(_, entry)| !entry.is_unified_loser())
472 .map(|(id, _)| id)
473 .collect()
474 } else {
475 self.config.root_nodes.iter().copied().collect()
476 };
477
478 for node_id in starting_nodes {
479 if visible.contains(&node_id) {
480 continue;
481 }
482 self.collect_nodes(&mut visible, &adjacency, node_id, depth_limit);
483 }
484
485 visible
486 }
487
488 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
490 if entry.is_unified_loser() {
493 return false;
494 }
495 if !self.config.filter_languages.is_empty() {
497 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
498 if !self.config.filter_languages.contains(&lang) {
499 return false;
500 }
501 } else {
502 return false;
503 }
504 }
505
506 if !self.config.filter_files.is_empty() {
508 if let Some(path) = self.graph.files().resolve(entry.file) {
509 let path_str = path.to_string_lossy();
510 if !self
511 .config
512 .filter_files
513 .iter()
514 .any(|f| path_str.contains(f))
515 {
516 return false;
517 }
518 } else {
519 return false;
520 }
521 }
522
523 true
524 }
525
526 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
528 if self.config.filter_edges.is_empty() {
529 return true;
530 }
531 self.config.filter_edges.iter().any(|f| f.matches(kind))
532 }
533
534 fn build_adjacency(&self) -> HashMap<NodeId, Vec<NodeId>> {
536 let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
537 for (from, to, _) in self.graph.iter_edges() {
538 adjacency.entry(from).or_default().push(to);
539 adjacency.entry(to).or_default().push(from);
540 }
541 adjacency
542 }
543
544 fn collect_nodes(
546 &self,
547 visible: &mut HashSet<NodeId>,
548 adjacency: &HashMap<NodeId, Vec<NodeId>>,
549 start: NodeId,
550 depth_limit: usize,
551 ) {
552 let mut queue = VecDeque::new();
553 queue.push_back((start, 0usize));
554
555 while let Some((node_id, depth)) = queue.pop_front() {
556 if depth > depth_limit {
557 continue;
558 }
559
560 let Some(entry) = self.graph.get_node(node_id) else {
561 continue;
562 };
563
564 if !self.should_include_node(node_id, entry) {
565 continue;
566 }
567
568 if !visible.insert(node_id) {
569 continue;
570 }
571
572 if depth == depth_limit {
573 continue;
574 }
575
576 if let Some(neighbors) = adjacency.get(&node_id) {
577 for neighbor in neighbors {
578 queue.push_back((*neighbor, depth + 1));
579 }
580 }
581 }
582 }
583
584 fn export_node(&self, dot: &mut String, node_id: NodeId, entry: &NodeEntry) {
586 let lang = self.graph.files().language_for_file(entry.file);
587 let color = lang.map_or(default_language_color(), language_color);
588 let shape = node_shape(&entry.kind);
589
590 let name = self
592 .graph
593 .strings()
594 .resolve(entry.name)
595 .unwrap_or_else(|| std::sync::Arc::from("?"));
596 let qualified_name = entry
597 .qualified_name
598 .and_then(|id| self.graph.strings().resolve(id))
599 .unwrap_or_else(|| std::sync::Arc::clone(&name));
600
601 let label = if self.config.show_details {
603 let file = self
604 .graph
605 .files()
606 .resolve(entry.file)
607 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
608 format!("{}\\n{}:{}", qualified_name, file, entry.start_line)
609 } else {
610 qualified_name.to_string()
611 };
612
613 let label = escape_dot(&label);
614 let node_key = format!("n{}", node_id.index());
615
616 writeln!(
617 dot,
618 " \"{node_key}\" [label=\"{label}\", fillcolor=\"{color}\", shape=\"{shape}\"];",
619 )
620 .expect("write to String never fails");
621 }
622
623 fn export_edge(&self, dot: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
625 let (style, base_color) = edge_style(kind);
626 let color = if self.config.highlight_cross_language
627 && let (Some(from_entry), Some(to_entry)) =
628 (self.graph.get_node(from), self.graph.get_node(to))
629 {
630 let from_lang = self.graph.files().language_for_file(from_entry.file);
631 let to_lang = self.graph.files().language_for_file(to_entry.file);
632 if from_lang == to_lang {
633 base_color
634 } else {
635 "red"
636 }
637 } else {
638 base_color
639 };
640
641 let label = if self.config.show_edge_labels {
642 edge_label(kind, self.graph.strings())
643 } else {
644 String::new()
645 };
646
647 let from_key = format!("n{}", from.index());
648 let to_key = format!("n{}", to.index());
649
650 if label.is_empty() {
651 writeln!(
652 dot,
653 " \"{from_key}\" -> \"{to_key}\" [style=\"{style}\", color=\"{color}\"];"
654 )
655 .expect("write to String never fails");
656 } else {
657 writeln!(
658 dot,
659 " \"{from_key}\" -> \"{to_key}\" [style=\"{style}\", color=\"{color}\", label=\"{}\"];",
660 escape_dot(&label)
661 )
662 .expect("write to String never fails");
663 }
664 }
665}
666
667#[derive(Debug, Clone)]
673pub struct D2Config {
674 pub filter_languages: HashSet<Language>,
676 pub filter_edges: HashSet<EdgeFilter>,
678 pub filter_node_ids: Option<HashSet<NodeId>>,
681 pub highlight_cross_language: bool,
683 pub show_details: bool,
685 pub show_edge_labels: bool,
687 pub direction: Direction,
689}
690
691impl Default for D2Config {
692 fn default() -> Self {
693 Self {
694 filter_languages: HashSet::new(),
695 filter_edges: HashSet::new(),
696 filter_node_ids: None,
697 highlight_cross_language: false,
698 show_details: true,
699 show_edge_labels: true,
700 direction: Direction::LeftToRight,
701 }
702 }
703}
704
705impl D2Config {
706 #[must_use]
708 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
709 self.highlight_cross_language = enabled;
710 self
711 }
712
713 #[must_use]
715 pub fn with_details(mut self, enabled: bool) -> Self {
716 self.show_details = enabled;
717 self
718 }
719
720 #[must_use]
722 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
723 self.show_edge_labels = enabled;
724 self
725 }
726
727 #[must_use]
729 pub fn with_filter_node_ids(mut self, ids: Option<HashSet<NodeId>>) -> Self {
730 self.filter_node_ids = ids;
731 self
732 }
733}
734
735pub struct UnifiedD2Exporter<'a> {
737 graph: &'a GraphSnapshot,
738 config: D2Config,
739}
740
741impl<'a> UnifiedD2Exporter<'a> {
742 #[must_use]
744 pub fn new(graph: &'a GraphSnapshot) -> Self {
745 Self {
746 graph,
747 config: D2Config::default(),
748 }
749 }
750
751 #[must_use]
753 pub fn with_config(graph: &'a GraphSnapshot, config: D2Config) -> Self {
754 Self { graph, config }
755 }
756
757 #[must_use]
759 pub fn export(&self) -> String {
760 let mut d2 = String::new();
761
762 writeln!(d2, "direction: {}", self.config.direction.as_str())
764 .expect("write to String never fails");
765 d2.push('\n');
766
767 let visible_nodes: HashSet<NodeId> =
769 if let Some(ref filter_ids) = self.config.filter_node_ids {
770 filter_ids.clone()
771 } else {
772 self.graph
773 .iter_nodes()
774 .filter(|(id, entry)| self.should_include_node(*id, entry))
775 .map(|(id, _)| id)
776 .collect()
777 };
778
779 for node_id in &visible_nodes {
781 if let Some(entry) = self.graph.get_node(*node_id) {
782 self.export_node(&mut d2, *node_id, entry);
783 }
784 }
785
786 d2.push('\n');
787
788 for (from, to, kind) in self.graph.iter_edges() {
790 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
791 continue;
792 }
793
794 if !self.edge_allowed(&kind) {
795 continue;
796 }
797
798 self.export_edge(&mut d2, from, to, &kind);
799 }
800
801 d2
802 }
803
804 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
806 if entry.is_unified_loser() {
809 return false;
810 }
811 if !self.config.filter_languages.is_empty() {
812 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
813 if !self.config.filter_languages.contains(&lang) {
814 return false;
815 }
816 } else {
817 return false;
818 }
819 }
820 true
821 }
822
823 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
825 if self.config.filter_edges.is_empty() {
826 return true;
827 }
828 self.config.filter_edges.iter().any(|f| f.matches(kind))
829 }
830
831 fn export_node(&self, d2: &mut String, node_id: NodeId, entry: &NodeEntry) {
833 let lang = self.graph.files().language_for_file(entry.file);
834 let color = lang.map_or(default_language_color(), language_color);
835 let shape = match entry.kind {
836 NodeKind::Class | NodeKind::Struct => "class",
837 NodeKind::Interface | NodeKind::Trait => "oval",
838 NodeKind::Module => "package",
839 _ => "rectangle",
840 };
841
842 let name = self
843 .graph
844 .strings()
845 .resolve(entry.name)
846 .unwrap_or_else(|| std::sync::Arc::from("?"));
847 let qualified_name = entry
848 .qualified_name
849 .and_then(|id| self.graph.strings().resolve(id))
850 .unwrap_or_else(|| std::sync::Arc::clone(&name));
851
852 let label = if self.config.show_details {
853 let file = self
854 .graph
855 .files()
856 .resolve(entry.file)
857 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
858 format!("{} ({file}:{})", qualified_name.as_ref(), entry.start_line)
859 } else {
860 qualified_name.to_string()
861 };
862
863 let node_key = format!("n{}", node_id.index());
864 let label = escape_d2(&label);
865
866 writeln!(d2, "{node_key}: \"{label}\" {{").expect("write to String never fails");
867 writeln!(d2, " shape: {shape}").expect("write to String never fails");
868 writeln!(d2, " style.fill: \"{color}\"").expect("write to String never fails");
869 writeln!(d2, "}}").expect("write to String never fails");
870 }
871
872 fn export_edge(&self, d2: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
874 let (style, base_color) = edge_style(kind);
875 let color = if self.config.highlight_cross_language
876 && let (Some(from_entry), Some(to_entry)) =
877 (self.graph.get_node(from), self.graph.get_node(to))
878 {
879 let from_lang = self.graph.files().language_for_file(from_entry.file);
880 let to_lang = self.graph.files().language_for_file(to_entry.file);
881 if from_lang == to_lang {
882 base_color
883 } else {
884 "#ff0000"
885 }
886 } else {
887 base_color
888 };
889
890 let from_key = format!("n{}", from.index());
891 let to_key = format!("n{}", to.index());
892
893 let arrow = match kind {
894 EdgeKind::Inherits | EdgeKind::Implements => "<->",
895 _ => "->",
896 };
897
898 if self.config.show_edge_labels {
899 let label = edge_label(kind, self.graph.strings());
900 if label.is_empty() {
901 writeln!(d2, "{from_key} {arrow} {to_key}: {{")
902 .expect("write to String never fails");
903 } else {
904 writeln!(d2, "{from_key} {arrow} {to_key}: \"{label}\" {{")
905 .expect("write to String never fails");
906 }
907 } else {
908 writeln!(d2, "{from_key} {arrow} {to_key}: {{").expect("write to String never fails");
909 }
910
911 let d2_style = match style {
912 "dashed" => "stroke-dash: 3",
913 "dotted" => "stroke-dash: 1",
914 "bold" => "stroke-width: 3",
915 _ => "",
916 };
917
918 if !d2_style.is_empty() {
919 writeln!(d2, " style.{d2_style}").expect("write to String never fails");
920 }
921 writeln!(d2, " style.stroke: \"{color}\"").expect("write to String never fails");
922 writeln!(d2, "}}").expect("write to String never fails");
923 }
924}
925
926#[derive(Debug, Clone)]
932pub struct MermaidConfig {
933 pub filter_languages: HashSet<Language>,
935 pub filter_edges: HashSet<EdgeFilter>,
937 pub highlight_cross_language: bool,
939 pub show_edge_labels: bool,
941 pub direction: Direction,
943 pub filter_node_ids: Option<HashSet<NodeId>>,
946}
947
948impl Default for MermaidConfig {
949 fn default() -> Self {
950 Self {
951 filter_languages: HashSet::new(),
952 filter_edges: HashSet::new(),
953 highlight_cross_language: false,
954 show_edge_labels: true,
955 direction: Direction::LeftToRight,
956 filter_node_ids: None,
957 }
958 }
959}
960
961impl MermaidConfig {
962 #[must_use]
964 pub fn with_cross_language_highlight(mut self, enabled: bool) -> Self {
965 self.highlight_cross_language = enabled;
966 self
967 }
968
969 #[must_use]
971 pub fn with_edge_labels(mut self, enabled: bool) -> Self {
972 self.show_edge_labels = enabled;
973 self
974 }
975
976 #[must_use]
978 pub fn with_filter_node_ids(mut self, ids: Option<HashSet<NodeId>>) -> Self {
979 self.filter_node_ids = ids;
980 self
981 }
982}
983
984pub struct UnifiedMermaidExporter<'a> {
986 graph: &'a GraphSnapshot,
987 config: MermaidConfig,
988}
989
990impl<'a> UnifiedMermaidExporter<'a> {
991 #[must_use]
993 pub fn new(graph: &'a GraphSnapshot) -> Self {
994 Self {
995 graph,
996 config: MermaidConfig::default(),
997 }
998 }
999
1000 #[must_use]
1002 pub fn with_config(graph: &'a GraphSnapshot, config: MermaidConfig) -> Self {
1003 Self { graph, config }
1004 }
1005
1006 #[must_use]
1008 pub fn export(&self) -> String {
1009 let mut mermaid = String::new();
1010
1011 writeln!(mermaid, "graph {}", self.config.direction.as_str())
1013 .expect("write to String never fails");
1014
1015 let visible_nodes: HashSet<NodeId> =
1017 if let Some(ref filter_ids) = self.config.filter_node_ids {
1018 filter_ids.clone()
1019 } else {
1020 self.graph
1021 .iter_nodes()
1022 .filter(|(id, entry)| self.should_include_node(*id, entry))
1023 .map(|(id, _)| id)
1024 .collect()
1025 };
1026
1027 for node_id in &visible_nodes {
1029 if let Some(entry) = self.graph.get_node(*node_id) {
1030 self.export_node(&mut mermaid, *node_id, entry);
1031 }
1032 }
1033
1034 for (from, to, kind) in self.graph.iter_edges() {
1036 if !visible_nodes.contains(&from) || !visible_nodes.contains(&to) {
1037 continue;
1038 }
1039
1040 if !self.edge_allowed(&kind) {
1041 continue;
1042 }
1043
1044 self.export_edge(&mut mermaid, from, to, &kind);
1045 }
1046
1047 mermaid
1048 }
1049
1050 fn should_include_node(&self, _node_id: NodeId, entry: &NodeEntry) -> bool {
1052 if entry.is_unified_loser() {
1055 return false;
1056 }
1057 if !self.config.filter_languages.is_empty() {
1058 if let Some(lang) = self.graph.files().language_for_file(entry.file) {
1059 if !self.config.filter_languages.contains(&lang) {
1060 return false;
1061 }
1062 } else {
1063 return false;
1064 }
1065 }
1066 true
1067 }
1068
1069 fn edge_allowed(&self, kind: &EdgeKind) -> bool {
1071 if self.config.filter_edges.is_empty() {
1072 return true;
1073 }
1074 self.config.filter_edges.iter().any(|f| f.matches(kind))
1075 }
1076
1077 fn export_node(&self, mermaid: &mut String, node_id: NodeId, entry: &NodeEntry) {
1079 let name = self
1080 .graph
1081 .strings()
1082 .resolve(entry.name)
1083 .unwrap_or_else(|| std::sync::Arc::from("?"));
1084 let qualified_name = entry
1085 .qualified_name
1086 .and_then(|id| self.graph.strings().resolve(id))
1087 .unwrap_or_else(|| std::sync::Arc::clone(&name));
1088
1089 let node_key = format!("n{}", node_id.index());
1090 let label = Self::escape_mermaid(&qualified_name);
1091
1092 let (open, close) = match entry.kind {
1094 NodeKind::Class | NodeKind::Struct => ("[[", "]]"),
1095 NodeKind::Interface | NodeKind::Trait => ("([", "])"),
1096 NodeKind::Module => ("{{", "}}"),
1097 _ => ("[", "]"),
1098 };
1099
1100 writeln!(mermaid, " {node_key}{open}\"{label}\"{close}")
1101 .expect("write to String never fails");
1102 }
1103
1104 fn export_edge(&self, mermaid: &mut String, from: NodeId, to: NodeId, kind: &EdgeKind) {
1106 let from_key = format!("n{}", from.index());
1107 let to_key = format!("n{}", to.index());
1108
1109 let arrow = match kind {
1110 EdgeKind::Imports { .. } | EdgeKind::Exports { .. } => "-.->",
1111 EdgeKind::Calls { is_async: true, .. } => "==>",
1112 _ => "-->",
1113 };
1114
1115 if self.config.show_edge_labels {
1116 let label = edge_label(kind, self.graph.strings());
1117 if label.is_empty() {
1118 writeln!(mermaid, " {from_key} {arrow} {to_key}")
1119 .expect("write to String never fails");
1120 } else {
1121 let label = Self::escape_mermaid(&label);
1122 writeln!(mermaid, " {from_key} {arrow}|\"{label}\"| {to_key}")
1123 .expect("write to String never fails");
1124 }
1125 } else {
1126 writeln!(mermaid, " {from_key} {arrow} {to_key}")
1127 .expect("write to String never fails");
1128 }
1129 }
1130
1131 fn escape_mermaid(s: &str) -> String {
1133 s.replace('"', "#quot;")
1134 .replace('<', "<")
1135 .replace('>', ">")
1136 }
1137}
1138
1139#[derive(Debug, Clone, Default)]
1145pub struct JsonConfig {
1146 pub include_details: bool,
1148 pub include_edge_metadata: bool,
1150}
1151
1152impl JsonConfig {
1153 #[must_use]
1155 pub fn with_details(mut self, enabled: bool) -> Self {
1156 self.include_details = enabled;
1157 self
1158 }
1159
1160 #[must_use]
1162 pub fn with_edge_metadata(mut self, enabled: bool) -> Self {
1163 self.include_edge_metadata = enabled;
1164 self
1165 }
1166}
1167
1168pub struct UnifiedJsonExporter<'a> {
1170 graph: &'a GraphSnapshot,
1171 config: JsonConfig,
1172}
1173
1174impl<'a> UnifiedJsonExporter<'a> {
1175 #[must_use]
1177 pub fn new(graph: &'a GraphSnapshot) -> Self {
1178 Self {
1179 graph,
1180 config: JsonConfig::default(),
1181 }
1182 }
1183
1184 #[must_use]
1186 pub fn with_config(graph: &'a GraphSnapshot, config: JsonConfig) -> Self {
1187 Self { graph, config }
1188 }
1189
1190 #[must_use]
1192 pub fn export(&self) -> serde_json::Value {
1193 let nodes = self.export_nodes();
1194 let edges = self.export_edges();
1195
1196 serde_json::json!({
1197 "nodes": nodes,
1198 "edges": edges,
1199 "metadata": {
1200 "node_count": nodes.len(),
1201 "edge_count": edges.len(),
1202 }
1203 })
1204 }
1205
1206 fn export_nodes(&self) -> Vec<serde_json::Value> {
1207 self.graph
1210 .iter_nodes()
1211 .filter(|(_, entry)| !entry.is_unified_loser())
1212 .map(|(node_id, entry)| self.export_node(node_id, entry))
1213 .collect()
1214 }
1215
1216 fn export_node(&self, node_id: NodeId, entry: &NodeEntry) -> serde_json::Value {
1217 let name = self
1218 .graph
1219 .strings()
1220 .resolve(entry.name)
1221 .map_or_else(|| "?".to_string(), |s| s.to_string());
1222 let qualified_name = entry
1223 .qualified_name
1224 .and_then(|id| self.graph.strings().resolve(id))
1225 .map_or_else(|| name.clone(), |s| s.to_string());
1226 let file = self
1227 .graph
1228 .files()
1229 .resolve(entry.file)
1230 .map_or_else(|| "?".to_string(), |p| p.to_string_lossy().into_owned());
1231 let lang = self
1232 .graph
1233 .files()
1234 .language_for_file(entry.file)
1235 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
1236
1237 let mut node = serde_json::json!({
1238 "id": format!("n{}", node_id.index()),
1239 "name": name,
1240 "qualified_name": qualified_name,
1241 "kind": format!("{:?}", entry.kind),
1242 "file": file,
1243 "language": lang,
1244 "line": entry.start_line,
1245 });
1246
1247 if self.config.include_details {
1248 self.append_node_details(entry, &mut node);
1249 }
1250
1251 node
1252 }
1253
1254 fn append_node_details(&self, entry: &NodeEntry, node: &mut serde_json::Value) {
1255 if let Some(sig_id) = entry.signature
1256 && let Some(sig) = self.graph.strings().resolve(sig_id)
1257 {
1258 node["signature"] = serde_json::Value::String(sig.to_string());
1259 }
1260 if let Some(doc_id) = entry.doc
1261 && let Some(doc) = self.graph.strings().resolve(doc_id)
1262 {
1263 node["doc"] = serde_json::Value::String(doc.to_string());
1264 }
1265 if let Some(vis_id) = entry.visibility
1266 && let Some(vis) = self.graph.strings().resolve(vis_id)
1267 {
1268 node["visibility"] = serde_json::Value::String(vis.to_string());
1269 }
1270 node["is_async"] = serde_json::Value::Bool(entry.is_async);
1271 node["is_static"] = serde_json::Value::Bool(entry.is_static);
1272 }
1273
1274 fn export_edges(&self) -> Vec<serde_json::Value> {
1275 self.graph
1276 .iter_edges()
1277 .map(|(from, to, kind)| self.export_edge(from, to, &kind))
1278 .collect()
1279 }
1280
1281 fn export_edge(&self, from: NodeId, to: NodeId, kind: &EdgeKind) -> serde_json::Value {
1282 let from_key = format!("n{}", from.index());
1283 let to_key = format!("n{}", to.index());
1284
1285 let mut edge = serde_json::json!({
1286 "from": from_key,
1287 "to": to_key,
1288 "kind": Self::edge_kind_name(kind),
1289 });
1290
1291 if self.config.include_edge_metadata {
1292 self.append_edge_metadata(kind, &mut edge);
1293 }
1294
1295 edge
1296 }
1297
1298 fn append_edge_metadata(&self, kind: &EdgeKind, edge: &mut serde_json::Value) {
1299 match kind {
1300 EdgeKind::Calls {
1301 argument_count,
1302 is_async,
1303 ..
1304 } => {
1305 edge["argument_count"] = serde_json::Value::Number((*argument_count).into());
1306 edge["is_async"] = serde_json::Value::Bool(*is_async);
1307 }
1308 EdgeKind::Imports { alias, is_wildcard } => {
1309 edge["is_wildcard"] = serde_json::Value::Bool(*is_wildcard);
1310 if let Some(alias_id) = alias
1311 && let Some(alias_str) = self.graph.strings().resolve(*alias_id)
1312 {
1313 edge["alias"] = serde_json::Value::String(alias_str.to_string());
1314 }
1315 }
1316 EdgeKind::Exports {
1317 kind: export_kind,
1318 alias,
1319 } => {
1320 edge["export_kind"] = serde_json::Value::String(format!("{export_kind:?}"));
1321 if let Some(alias_id) = alias
1322 && let Some(alias_str) = self.graph.strings().resolve(*alias_id)
1323 {
1324 edge["alias"] = serde_json::Value::String(alias_str.to_string());
1325 }
1326 }
1327 EdgeKind::HttpRequest { method, url } => {
1328 edge["method"] = serde_json::Value::String(method.as_str().to_string());
1329 if let Some(url_id) = url
1330 && let Some(url_str) = self.graph.strings().resolve(*url_id)
1331 {
1332 edge["url"] = serde_json::Value::String(url_str.to_string());
1333 }
1334 }
1335 _ => {}
1336 }
1337 }
1338
1339 fn edge_kind_name(kind: &EdgeKind) -> &'static str {
1341 match kind {
1342 EdgeKind::Defines => "defines",
1343 EdgeKind::Contains => "contains",
1344 EdgeKind::Calls { .. } => "calls",
1345 EdgeKind::References => "references",
1346 EdgeKind::Imports { .. } => "imports",
1347 EdgeKind::Exports { .. } => "exports",
1348 EdgeKind::TypeOf { .. } => "type_of",
1349 EdgeKind::Inherits => "inherits",
1350 EdgeKind::Implements => "implements",
1351 EdgeKind::FfiCall { .. } => "ffi_call",
1352 EdgeKind::HttpRequest { .. } => "http_request",
1353 EdgeKind::GrpcCall { .. } => "grpc_call",
1354 EdgeKind::WebAssemblyCall => "wasm_call",
1355 EdgeKind::DbQuery { .. } => "db_query",
1356 EdgeKind::TableRead { .. } => "table_read",
1357 EdgeKind::TableWrite { .. } => "table_write",
1358 EdgeKind::TriggeredBy { .. } => "triggered_by",
1359 EdgeKind::MessageQueue { .. } => "message_queue",
1360 EdgeKind::WebSocket { .. } => "websocket",
1361 EdgeKind::GraphQLOperation { .. } => "graphql_operation",
1362 EdgeKind::ProcessExec { .. } => "process_exec",
1363 EdgeKind::FileIpc { .. } => "file_ipc",
1364 EdgeKind::ProtocolCall { .. } => "protocol_call",
1365 EdgeKind::LifetimeConstraint { .. } => "lifetime_constraint",
1367 EdgeKind::TraitMethodBinding { .. } => "trait_method_binding",
1368 EdgeKind::MacroExpansion { .. } => "macro_expansion",
1369 EdgeKind::GenericBound => "generic_bound",
1371 EdgeKind::AnnotatedWith => "annotated_with",
1372 EdgeKind::AnnotationParam => "annotation_param",
1373 EdgeKind::LambdaCaptures => "lambda_captures",
1374 EdgeKind::ModuleExports => "module_exports",
1375 EdgeKind::ModuleRequires => "module_requires",
1376 EdgeKind::ModuleOpens => "module_opens",
1377 EdgeKind::ModuleProvides => "module_provides",
1378 EdgeKind::TypeArgument => "type_argument",
1379 EdgeKind::ExtensionReceiver => "extension_receiver",
1380 EdgeKind::CompanionOf => "companion_of",
1381 EdgeKind::SealedPermit => "sealed_permit",
1382 EdgeKind::Wraps { .. } => "wraps",
1384 EdgeKind::ChannelPeer { .. } => "channel_peer",
1386 EdgeKind::Instantiates { .. } => "instantiates",
1387 }
1388 }
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394
1395 #[test]
1398 fn test_direction_as_str() {
1399 assert_eq!(Direction::LeftToRight.as_str(), "LR");
1400 assert_eq!(Direction::TopToBottom.as_str(), "TB");
1401 }
1402
1403 #[test]
1404 fn test_direction_default() {
1405 assert_eq!(Direction::default(), Direction::LeftToRight);
1406 }
1407
1408 #[test]
1411 fn test_edge_filter_matches_calls() {
1412 assert!(EdgeFilter::Calls.matches(&EdgeKind::Calls {
1413 argument_count: 0,
1414 is_async: false,
1415 resolved_via: ResolvedVia::Direct,
1416 }));
1417 assert!(EdgeFilter::Calls.matches(&EdgeKind::Calls {
1418 argument_count: 5,
1419 is_async: true,
1420 resolved_via: ResolvedVia::Direct,
1421 }));
1422 assert!(!EdgeFilter::Calls.matches(&EdgeKind::References));
1423 }
1424
1425 #[test]
1426 fn test_edge_filter_matches_imports() {
1427 assert!(EdgeFilter::Imports.matches(&EdgeKind::Imports {
1428 alias: None,
1429 is_wildcard: false
1430 }));
1431 assert!(EdgeFilter::Imports.matches(&EdgeKind::Imports {
1432 alias: None,
1433 is_wildcard: true
1434 }));
1435 assert!(!EdgeFilter::Imports.matches(&EdgeKind::Exports {
1436 kind: crate::graph::unified::edge::ExportKind::Direct,
1437 alias: None
1438 }));
1439 }
1440
1441 #[test]
1442 fn test_edge_filter_matches_exports() {
1443 assert!(EdgeFilter::Exports.matches(&EdgeKind::Exports {
1444 kind: crate::graph::unified::edge::ExportKind::Direct,
1445 alias: None
1446 }));
1447 assert!(!EdgeFilter::Exports.matches(&EdgeKind::Imports {
1448 alias: None,
1449 is_wildcard: false
1450 }));
1451 }
1452
1453 #[test]
1454 fn test_edge_filter_matches_references() {
1455 assert!(EdgeFilter::References.matches(&EdgeKind::References));
1456 assert!(!EdgeFilter::References.matches(&EdgeKind::Calls {
1457 argument_count: 0,
1458 is_async: false,
1459 resolved_via: ResolvedVia::Direct,
1460 }));
1461 }
1462
1463 #[test]
1464 fn test_edge_filter_matches_inheritance() {
1465 assert!(EdgeFilter::Inherits.matches(&EdgeKind::Inherits));
1466 assert!(EdgeFilter::Implements.matches(&EdgeKind::Implements));
1467 assert!(!EdgeFilter::Inherits.matches(&EdgeKind::Implements));
1468 assert!(!EdgeFilter::Implements.matches(&EdgeKind::Inherits));
1469 }
1470
1471 #[test]
1472 fn test_edge_filter_matches_cross_language() {
1473 assert!(EdgeFilter::FfiCall.matches(&EdgeKind::FfiCall {
1474 convention: crate::graph::unified::edge::FfiConvention::C
1475 }));
1476 assert!(EdgeFilter::HttpRequest.matches(&EdgeKind::HttpRequest {
1477 method: crate::graph::unified::edge::HttpMethod::Get,
1478 url: None
1479 }));
1480 assert!(EdgeFilter::DbQuery.matches(&EdgeKind::DbQuery {
1481 query_type: crate::graph::unified::edge::DbQueryType::Select,
1482 table: None
1483 }));
1484 }
1485
1486 #[test]
1489 fn test_language_color_common_languages() {
1490 assert_eq!(language_color(Language::Rust), "#dea584");
1491 assert_eq!(language_color(Language::JavaScript), "#f7df1e");
1492 assert_eq!(language_color(Language::TypeScript), "#3178c6");
1493 assert_eq!(language_color(Language::Python), "#3572A5");
1494 assert_eq!(language_color(Language::Go), "#00ADD8");
1495 assert_eq!(language_color(Language::Java), "#b07219");
1496 }
1497
1498 #[test]
1499 fn test_language_color_all_languages() {
1500 let languages = [
1502 Language::Rust,
1503 Language::JavaScript,
1504 Language::TypeScript,
1505 Language::Python,
1506 Language::Go,
1507 Language::Java,
1508 Language::Ruby,
1509 Language::Php,
1510 Language::Cpp,
1511 Language::C,
1512 Language::Swift,
1513 Language::Kotlin,
1514 Language::Scala,
1515 Language::Sql,
1516 Language::Plsql,
1517 Language::Shell,
1518 Language::Lua,
1519 Language::Perl,
1520 Language::Dart,
1521 Language::Groovy,
1522 Language::Http,
1523 Language::Css,
1524 Language::Elixir,
1525 Language::R,
1526 Language::Haskell,
1527 Language::Html,
1528 Language::Svelte,
1529 Language::Vue,
1530 Language::Zig,
1531 Language::Terraform,
1532 Language::Puppet,
1533 Language::Apex,
1534 Language::Abap,
1535 Language::ServiceNow,
1536 Language::CSharp,
1537 ];
1538 for lang in languages {
1539 let color = language_color(lang);
1540 assert!(color.starts_with('#'), "Color for {lang:?} should be hex");
1541 }
1542 }
1543
1544 #[test]
1545 fn test_default_language_color() {
1546 assert_eq!(default_language_color(), "#cccccc");
1547 }
1548
1549 #[test]
1552 fn test_node_shape_class_types() {
1553 assert_eq!(node_shape(&NodeKind::Class), "component");
1554 assert_eq!(node_shape(&NodeKind::Struct), "component");
1555 }
1556
1557 #[test]
1558 fn test_node_shape_interface_types() {
1559 assert_eq!(node_shape(&NodeKind::Interface), "ellipse");
1560 assert_eq!(node_shape(&NodeKind::Trait), "ellipse");
1561 }
1562
1563 #[test]
1564 fn test_node_shape_module() {
1565 assert_eq!(node_shape(&NodeKind::Module), "folder");
1566 }
1567
1568 #[test]
1569 fn test_node_shape_variables() {
1570 assert_eq!(node_shape(&NodeKind::Variable), "note");
1571 assert_eq!(node_shape(&NodeKind::Constant), "note");
1572 }
1573
1574 #[test]
1575 fn test_node_shape_enums() {
1576 assert_eq!(node_shape(&NodeKind::Enum), "hexagon");
1577 assert_eq!(node_shape(&NodeKind::EnumVariant), "hexagon");
1578 }
1579
1580 #[test]
1581 fn test_node_shape_special() {
1582 assert_eq!(node_shape(&NodeKind::Type), "diamond");
1583 assert_eq!(node_shape(&NodeKind::Macro), "parallelogram");
1584 }
1585
1586 #[test]
1587 fn test_node_shape_default() {
1588 assert_eq!(node_shape(&NodeKind::Function), "box");
1589 assert_eq!(node_shape(&NodeKind::Method), "box");
1590 }
1591
1592 #[test]
1595 fn test_edge_style_calls() {
1596 let (style, color) = edge_style(&EdgeKind::Calls {
1597 argument_count: 0,
1598 is_async: false,
1599 resolved_via: ResolvedVia::Direct,
1600 });
1601 assert_eq!(style, "solid");
1602 assert_eq!(color, "#333333");
1603 }
1604
1605 #[test]
1606 fn test_edge_style_imports_exports() {
1607 let (style, color) = edge_style(&EdgeKind::Imports {
1608 alias: None,
1609 is_wildcard: false,
1610 });
1611 assert_eq!(style, "dashed");
1612 assert_eq!(color, "#0066cc");
1613
1614 let (style, color) = edge_style(&EdgeKind::Exports {
1615 kind: crate::graph::unified::edge::ExportKind::Direct,
1616 alias: None,
1617 });
1618 assert_eq!(style, "dashed");
1619 assert_eq!(color, "#00cc66");
1620 }
1621
1622 #[test]
1623 fn test_edge_style_references() {
1624 let (style, color) = edge_style(&EdgeKind::References);
1625 assert_eq!(style, "dotted");
1626 assert_eq!(color, "#666666");
1627 }
1628
1629 #[test]
1630 fn test_edge_style_inheritance() {
1631 let (style, color) = edge_style(&EdgeKind::Inherits);
1632 assert_eq!(style, "solid");
1633 assert_eq!(color, "#990099");
1634
1635 let (style, color) = edge_style(&EdgeKind::Implements);
1636 assert_eq!(style, "dashed");
1637 assert_eq!(color, "#990099");
1638 }
1639
1640 #[test]
1641 fn test_edge_style_cross_language() {
1642 let (style, color) = edge_style(&EdgeKind::FfiCall {
1643 convention: crate::graph::unified::edge::FfiConvention::C,
1644 });
1645 assert_eq!(style, "bold");
1646 assert_eq!(color, "#ff6600");
1647
1648 let (style, color) = edge_style(&EdgeKind::HttpRequest {
1649 method: crate::graph::unified::edge::HttpMethod::Get,
1650 url: None,
1651 });
1652 assert_eq!(style, "bold");
1653 assert_eq!(color, "#cc0000");
1654
1655 let (style, color) = edge_style(&EdgeKind::DbQuery {
1656 query_type: crate::graph::unified::edge::DbQueryType::Select,
1657 table: None,
1658 });
1659 assert_eq!(style, "bold");
1660 assert_eq!(color, "#009900");
1661 }
1662
1663 #[test]
1666 fn test_escape_dot_basic() {
1667 assert_eq!(escape_dot("hello"), "hello");
1668 assert_eq!(escape_dot("hello world"), "hello world");
1669 }
1670
1671 #[test]
1672 fn test_escape_dot_quotes() {
1673 assert_eq!(escape_dot("say \"hi\""), "say \\\"hi\\\"");
1674 assert_eq!(escape_dot("\"quoted\""), "\\\"quoted\\\"");
1675 }
1676
1677 #[test]
1678 fn test_escape_dot_newlines() {
1679 assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
1680 assert_eq!(escape_dot("a\nb\nc"), "a\\nb\\nc");
1681 }
1682
1683 #[test]
1684 fn test_escape_dot_backslashes() {
1685 assert_eq!(escape_dot("path\\to\\file"), "path\\\\to\\\\file");
1686 }
1687
1688 #[test]
1689 fn test_escape_d2_basic() {
1690 assert_eq!(escape_d2("hello"), "hello");
1691 assert_eq!(escape_d2("hello world"), "hello world");
1692 }
1693
1694 #[test]
1695 fn test_escape_d2_quotes_and_newlines() {
1696 assert_eq!(escape_d2("say \"hi\""), "say \\\"hi\\\"");
1698 assert_eq!(escape_d2("line1\nline2"), "line1 line2");
1699 assert_eq!(escape_d2("path\\to\\file"), "path\\\\to\\\\file");
1700 }
1701
1702 #[test]
1705 fn test_dot_config_default() {
1706 let config = DotConfig::default();
1707 assert_eq!(config.direction, Direction::LeftToRight);
1708 assert!(!config.highlight_cross_language);
1709 assert!(config.show_details); assert!(config.show_edge_labels); assert!(config.filter_node_ids.is_none());
1712 assert!(config.filter_languages.is_empty());
1713 assert!(config.filter_edges.is_empty());
1714 assert!(config.filter_files.is_empty());
1715 }
1716
1717 #[test]
1718 fn test_dot_config_builder() {
1719 let config = DotConfig::default()
1720 .with_direction(Direction::TopToBottom)
1721 .with_cross_language_highlight(true)
1722 .with_details(true);
1723 assert_eq!(config.direction, Direction::TopToBottom);
1724 assert!(config.highlight_cross_language);
1725 assert!(config.show_details);
1726
1727 let mut ids = HashSet::new();
1729 ids.insert(NodeId::new(0, 0));
1730 let config_with_filter = DotConfig::default().with_filter_node_ids(Some(ids.clone()));
1731 assert!(config_with_filter.filter_node_ids.is_some());
1732 assert_eq!(config_with_filter.filter_node_ids.as_ref().unwrap(), &ids);
1733
1734 let config_cleared = config_with_filter.with_filter_node_ids(None);
1735 assert!(config_cleared.filter_node_ids.is_none());
1736 }
1737
1738 #[test]
1739 fn test_d2_config_default() {
1740 let config = D2Config::default();
1741 assert_eq!(config.direction, Direction::LeftToRight);
1742 assert!(!config.highlight_cross_language);
1743 assert!(config.show_details); assert!(config.show_edge_labels); assert!(config.filter_node_ids.is_none());
1746 assert!(config.filter_languages.is_empty());
1747 assert!(config.filter_edges.is_empty());
1748 }
1749
1750 #[test]
1751 fn test_d2_config_builder() {
1752 let config = D2Config::default()
1753 .with_cross_language_highlight(true)
1754 .with_details(true)
1755 .with_edge_labels(true);
1756 assert!(config.highlight_cross_language);
1757 assert!(config.show_details);
1758 assert!(config.show_edge_labels);
1759
1760 let mut ids = HashSet::new();
1762 ids.insert(NodeId::new(0, 0));
1763 let config_with_filter = D2Config::default().with_filter_node_ids(Some(ids.clone()));
1764 assert!(config_with_filter.filter_node_ids.is_some());
1765 assert_eq!(config_with_filter.filter_node_ids.as_ref().unwrap(), &ids);
1766
1767 let config_cleared = config_with_filter.with_filter_node_ids(None);
1768 assert!(config_cleared.filter_node_ids.is_none());
1769 }
1770
1771 #[test]
1772 fn test_mermaid_config_default() {
1773 let config = MermaidConfig::default();
1774 assert_eq!(config.direction, Direction::LeftToRight);
1775 assert!(!config.highlight_cross_language);
1776 assert!(config.show_edge_labels); assert!(config.filter_node_ids.is_none());
1778 assert!(config.filter_languages.is_empty());
1779 assert!(config.filter_edges.is_empty());
1780 }
1781
1782 #[test]
1783 fn test_mermaid_config_builder() {
1784 let config = MermaidConfig::default()
1785 .with_cross_language_highlight(true)
1786 .with_edge_labels(false);
1787 assert!(config.highlight_cross_language);
1788 assert!(!config.show_edge_labels);
1789
1790 let mut ids = HashSet::new();
1792 ids.insert(NodeId::new(0, 0));
1793 let config_with_filter = MermaidConfig::default().with_filter_node_ids(Some(ids.clone()));
1794 assert!(config_with_filter.filter_node_ids.is_some());
1795 assert_eq!(config_with_filter.filter_node_ids.as_ref().unwrap(), &ids);
1796
1797 let config_cleared = config_with_filter.with_filter_node_ids(None);
1798 assert!(config_cleared.filter_node_ids.is_none());
1799 }
1800
1801 #[test]
1802 fn test_json_config_builder() {
1803 let config = JsonConfig::default()
1804 .with_details(true)
1805 .with_edge_metadata(true);
1806 assert!(config.include_details);
1807 assert!(config.include_edge_metadata);
1808 }
1809
1810 use crate::graph::unified::concurrent::CodeGraph;
1815 use crate::graph::unified::edge::BidirectionalEdgeStore;
1816 use crate::graph::unified::storage::NodeEntry;
1817 use crate::graph::unified::storage::arena::NodeArena;
1818 use crate::graph::unified::storage::indices::AuxiliaryIndices;
1819 use crate::graph::unified::storage::interner::StringInterner;
1820 use crate::graph::unified::storage::registry::FileRegistry;
1821 use std::path::Path;
1822
1823 fn create_test_graph_for_export() -> CodeGraph {
1825 let mut nodes = NodeArena::new();
1826 let mut strings = StringInterner::new();
1827 let mut files = FileRegistry::new();
1828 let edges = BidirectionalEdgeStore::new();
1829 let indices = AuxiliaryIndices::new();
1830
1831 let file_id = files
1833 .register_with_language(Path::new("src/main.rs"), Some(Language::Rust))
1834 .unwrap();
1835
1836 let name_main = strings.intern("main").unwrap();
1838 let name_helper = strings.intern("helper").unwrap();
1839 let qname_main = strings.intern("app::main").unwrap();
1840 let qname_helper = strings.intern("app::helper").unwrap();
1841 let sig = strings.intern("fn main()").unwrap();
1842
1843 let main_entry = NodeEntry {
1845 kind: NodeKind::Function,
1846 name: name_main,
1847 file: file_id,
1848 start_byte: 0,
1849 end_byte: 100,
1850 start_line: 1,
1851 start_column: 0,
1852 end_line: 10,
1853 end_column: 1,
1854 signature: Some(sig),
1855 doc: None,
1856 qualified_name: Some(qname_main),
1857 visibility: None,
1858 is_async: false,
1859 is_static: false,
1860 is_unsafe: false,
1861 body_hash: None,
1862 };
1863 let main_id = nodes.alloc(main_entry).unwrap();
1864
1865 let helper_entry = NodeEntry {
1866 kind: NodeKind::Function,
1867 name: name_helper,
1868 file: file_id,
1869 start_byte: 100,
1870 end_byte: 200,
1871 start_line: 11,
1872 start_column: 0,
1873 end_line: 20,
1874 end_column: 1,
1875 signature: None,
1876 doc: None,
1877 qualified_name: Some(qname_helper),
1878 visibility: None,
1879 is_async: true,
1880 is_static: false,
1881 is_unsafe: false,
1882 body_hash: None,
1883 };
1884 let helper_id = nodes.alloc(helper_entry).unwrap();
1885
1886 edges.add_edge(
1888 main_id,
1889 helper_id,
1890 EdgeKind::Calls {
1891 argument_count: 2,
1892 is_async: false,
1893 resolved_via: ResolvedVia::Direct,
1894 },
1895 file_id,
1896 );
1897
1898 CodeGraph::from_components(
1899 nodes,
1900 edges,
1901 strings,
1902 files,
1903 indices,
1904 crate::graph::unified::NodeMetadataStore::new(),
1905 )
1906 }
1907
1908 #[test]
1911 fn test_unified_dot_exporter_basic() {
1912 let graph = create_test_graph_for_export();
1913 let snapshot = graph.snapshot();
1914 let exporter = UnifiedDotExporter::new(&snapshot);
1915 let output = exporter.export();
1916
1917 assert!(output.starts_with("digraph CodeGraph {"));
1919 assert!(output.ends_with("}\n"));
1920 assert!(output.contains("rankdir=LR"));
1921 }
1922
1923 #[test]
1924 fn test_unified_dot_exporter_with_config() {
1925 let graph = create_test_graph_for_export();
1926 let snapshot = graph.snapshot();
1927 let config = DotConfig::default()
1928 .with_direction(Direction::TopToBottom)
1929 .with_cross_language_highlight(true)
1930 .with_details(true);
1931 let exporter = UnifiedDotExporter::with_config(&snapshot, config);
1932 let output = exporter.export();
1933
1934 assert!(output.contains("rankdir=TB"));
1935 }
1936
1937 #[test]
1938 fn test_unified_dot_exporter_contains_nodes() {
1939 let graph = create_test_graph_for_export();
1940 let snapshot = graph.snapshot();
1941 let exporter = UnifiedDotExporter::new(&snapshot);
1942 let output = exporter.export();
1943
1944 assert!(output.contains("n0"), "Should contain first node");
1946 assert!(output.contains("n1"), "Should contain second node");
1947 assert!(
1949 output.contains("main") || output.contains("app::main"),
1950 "Should contain main function name"
1951 );
1952 }
1953
1954 #[test]
1955 fn test_unified_dot_exporter_contains_edges() {
1956 let graph = create_test_graph_for_export();
1957 let snapshot = graph.snapshot();
1958 let exporter = UnifiedDotExporter::new(&snapshot);
1959 let output = exporter.export();
1960
1961 assert!(output.contains("->"), "Should contain edge arrow");
1963 }
1964
1965 #[test]
1966 fn test_unified_dot_exporter_empty_graph() {
1967 let graph = CodeGraph::new();
1968 let snapshot = graph.snapshot();
1969 let exporter = UnifiedDotExporter::new(&snapshot);
1970 let output = exporter.export();
1971
1972 assert!(output.starts_with("digraph CodeGraph {"));
1973 assert!(output.ends_with("}\n"));
1974 assert!(!output.contains("n0"));
1976 }
1977
1978 #[test]
1981 fn test_unified_d2_exporter_basic() {
1982 let graph = create_test_graph_for_export();
1983 let snapshot = graph.snapshot();
1984 let exporter = UnifiedD2Exporter::new(&snapshot);
1985 let output = exporter.export();
1986
1987 assert!(output.contains("direction: LR"));
1989 }
1990
1991 #[test]
1992 fn test_unified_d2_exporter_with_config() {
1993 let graph = create_test_graph_for_export();
1994 let snapshot = graph.snapshot();
1995 let config = D2Config::default().with_cross_language_highlight(true);
1996 let exporter = UnifiedD2Exporter::with_config(&snapshot, config);
1997 let output = exporter.export();
1998
1999 assert!(output.contains("direction:"));
2000 }
2001
2002 #[test]
2003 fn test_unified_d2_exporter_contains_nodes() {
2004 let graph = create_test_graph_for_export();
2005 let snapshot = graph.snapshot();
2006 let exporter = UnifiedD2Exporter::new(&snapshot);
2007 let output = exporter.export();
2008
2009 assert!(
2011 output.contains("n0:"),
2012 "Should contain first node definition"
2013 );
2014 assert!(
2015 output.contains("n1:"),
2016 "Should contain second node definition"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_unified_d2_exporter_contains_edges() {
2022 let graph = create_test_graph_for_export();
2023 let snapshot = graph.snapshot();
2024 let exporter = UnifiedD2Exporter::new(&snapshot);
2025 let output = exporter.export();
2026
2027 assert!(
2029 output.contains("->") || output.contains("<->"),
2030 "Should contain edge"
2031 );
2032 }
2033
2034 #[test]
2037 fn test_unified_mermaid_exporter_basic() {
2038 let graph = create_test_graph_for_export();
2039 let snapshot = graph.snapshot();
2040 let exporter = UnifiedMermaidExporter::new(&snapshot);
2041 let output = exporter.export();
2042
2043 assert!(output.starts_with("graph LR"));
2045 }
2046
2047 #[test]
2048 fn test_unified_mermaid_exporter_with_config() {
2049 let graph = create_test_graph_for_export();
2050 let snapshot = graph.snapshot();
2051 let config = MermaidConfig::default()
2052 .with_cross_language_highlight(true)
2053 .with_edge_labels(false);
2054 let exporter = UnifiedMermaidExporter::with_config(&snapshot, config);
2055 let output = exporter.export();
2056
2057 assert!(output.starts_with("graph LR"));
2058 }
2059
2060 #[test]
2061 fn test_unified_mermaid_exporter_contains_nodes() {
2062 let graph = create_test_graph_for_export();
2063 let snapshot = graph.snapshot();
2064 let exporter = UnifiedMermaidExporter::new(&snapshot);
2065 let output = exporter.export();
2066
2067 assert!(output.contains("n0"), "Should contain first node");
2069 assert!(output.contains("n1"), "Should contain second node");
2070 }
2071
2072 #[test]
2073 fn test_unified_mermaid_exporter_contains_edges() {
2074 let graph = create_test_graph_for_export();
2075 let snapshot = graph.snapshot();
2076 let exporter = UnifiedMermaidExporter::new(&snapshot);
2077 let output = exporter.export();
2078
2079 assert!(
2081 output.contains("-->") || output.contains("==>") || output.contains("-.->"),
2082 "Should contain edge"
2083 );
2084 }
2085
2086 #[test]
2089 fn test_unified_json_exporter_basic() {
2090 let graph = create_test_graph_for_export();
2091 let snapshot = graph.snapshot();
2092 let exporter = UnifiedJsonExporter::new(&snapshot);
2093 let output = exporter.export();
2094
2095 assert!(output.is_object());
2097 assert!(output.get("nodes").is_some());
2098 assert!(output.get("edges").is_some());
2099 assert!(output.get("metadata").is_some());
2100 }
2101
2102 #[test]
2103 fn test_unified_json_exporter_node_count() {
2104 let graph = create_test_graph_for_export();
2105 let snapshot = graph.snapshot();
2106 let exporter = UnifiedJsonExporter::new(&snapshot);
2107 let output = exporter.export();
2108
2109 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2110 assert_eq!(nodes.len(), 2, "Should have 2 nodes");
2111 }
2112
2113 #[test]
2114 fn test_unified_json_exporter_edge_count() {
2115 let graph = create_test_graph_for_export();
2116 let snapshot = graph.snapshot();
2117 let exporter = UnifiedJsonExporter::new(&snapshot);
2118 let output = exporter.export();
2119
2120 let edges = output.get("edges").unwrap().as_array().unwrap();
2121 assert_eq!(edges.len(), 1, "Should have 1 edge");
2122 }
2123
2124 #[test]
2125 fn test_unified_json_exporter_metadata() {
2126 let graph = create_test_graph_for_export();
2127 let snapshot = graph.snapshot();
2128 let exporter = UnifiedJsonExporter::new(&snapshot);
2129 let output = exporter.export();
2130
2131 let metadata = output.get("metadata").unwrap();
2132 assert_eq!(
2133 metadata.get("node_count").unwrap().as_u64().unwrap(),
2134 2,
2135 "Metadata should report 2 nodes"
2136 );
2137 assert_eq!(
2138 metadata.get("edge_count").unwrap().as_u64().unwrap(),
2139 1,
2140 "Metadata should report 1 edge"
2141 );
2142 }
2143
2144 #[test]
2145 fn test_unified_json_exporter_with_details() {
2146 let graph = create_test_graph_for_export();
2147 let snapshot = graph.snapshot();
2148 let config = JsonConfig::default().with_details(true);
2149 let exporter = UnifiedJsonExporter::with_config(&snapshot, config);
2150 let output = exporter.export();
2151
2152 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2153 let main_node = &nodes[0];
2155 assert!(
2156 main_node.get("signature").is_some(),
2157 "Node should have signature when details enabled"
2158 );
2159 }
2160
2161 #[test]
2162 fn test_unified_json_exporter_with_edge_metadata() {
2163 let graph = create_test_graph_for_export();
2164 let snapshot = graph.snapshot();
2165 let config = JsonConfig::default().with_edge_metadata(true);
2166 let exporter = UnifiedJsonExporter::with_config(&snapshot, config);
2167 let output = exporter.export();
2168
2169 let edges = output.get("edges").unwrap().as_array().unwrap();
2170 let edge = &edges[0];
2171 assert!(
2173 edge.get("argument_count").is_some(),
2174 "Edge should have argument_count metadata"
2175 );
2176 }
2177
2178 #[test]
2179 fn test_unified_json_exporter_empty_graph() {
2180 let graph = CodeGraph::new();
2181 let snapshot = graph.snapshot();
2182 let exporter = UnifiedJsonExporter::new(&snapshot);
2183 let output = exporter.export();
2184
2185 let nodes = output.get("nodes").unwrap().as_array().unwrap();
2186 let edges = output.get("edges").unwrap().as_array().unwrap();
2187 assert!(nodes.is_empty(), "Empty graph should have no nodes");
2188 assert!(edges.is_empty(), "Empty graph should have no edges");
2189 }
2190
2191 #[test]
2194 fn test_edge_label_calls() {
2195 let strings = StringInterner::new();
2196 let label = edge_label(
2197 &EdgeKind::Calls {
2198 argument_count: 3,
2199 is_async: false,
2200 resolved_via: ResolvedVia::Direct,
2201 },
2202 &strings,
2203 );
2204 assert_eq!(label, "call(3)");
2205 }
2206
2207 #[test]
2208 fn test_edge_label_async_calls() {
2209 let strings = StringInterner::new();
2210 let label = edge_label(
2211 &EdgeKind::Calls {
2212 argument_count: 2,
2213 is_async: true,
2214 resolved_via: ResolvedVia::Direct,
2215 },
2216 &strings,
2217 );
2218 assert_eq!(label, "async call(2)");
2219 }
2220
2221 #[test]
2222 fn test_edge_label_imports_simple() {
2223 let strings = StringInterner::new();
2224 let label = edge_label(
2225 &EdgeKind::Imports {
2226 alias: None,
2227 is_wildcard: false,
2228 },
2229 &strings,
2230 );
2231 assert_eq!(label, "import");
2232 }
2233
2234 #[test]
2235 fn test_edge_label_imports_wildcard() {
2236 let strings = StringInterner::new();
2237 let label = edge_label(
2238 &EdgeKind::Imports {
2239 alias: None,
2240 is_wildcard: true,
2241 },
2242 &strings,
2243 );
2244 assert_eq!(label, "import *");
2245 }
2246
2247 #[test]
2248 fn test_edge_label_references() {
2249 let strings = StringInterner::new();
2250 let label = edge_label(&EdgeKind::References, &strings);
2251 assert_eq!(label, "ref");
2252 }
2253
2254 #[test]
2255 fn test_edge_label_inherits() {
2256 let strings = StringInterner::new();
2257 let label = edge_label(&EdgeKind::Inherits, &strings);
2258 assert_eq!(label, "extends");
2259 }
2260
2261 #[test]
2262 fn test_edge_label_implements() {
2263 let strings = StringInterner::new();
2264 let label = edge_label(&EdgeKind::Implements, &strings);
2265 assert_eq!(label, "implements");
2266 }
2267
2268 #[test]
2271 fn test_mermaid_filter_node_ids_restricts_output() {
2272 let graph = create_test_graph_for_export();
2273 let snapshot = graph.snapshot();
2274
2275 let all_nodes: Vec<NodeId> = snapshot.iter_nodes().map(|(id, _)| id).collect();
2277 assert!(
2278 all_nodes.len() >= 2,
2279 "test graph must have at least 2 nodes"
2280 );
2281
2282 let kept_id = all_nodes[0];
2284 let expected_key = format!("n{}", kept_id.index());
2285 let excluded_key = format!("n{}", all_nodes[1].index());
2287
2288 let mut filter = HashSet::new();
2289 filter.insert(kept_id);
2290
2291 let config = MermaidConfig::default().with_filter_node_ids(Some(filter));
2292 let exporter = UnifiedMermaidExporter::with_config(&snapshot, config);
2293 let output = exporter.export();
2294
2295 let node_defs: Vec<&str> = output
2297 .lines()
2298 .filter(|l| l.trim_start().starts_with('n') && l.contains('['))
2299 .collect();
2300
2301 assert_eq!(
2302 node_defs.len(),
2303 1,
2304 "filtered export should have exactly 1 node, got: {node_defs:?}"
2305 );
2306
2307 assert!(
2309 node_defs[0].trim_start().starts_with(&expected_key),
2310 "expected node key '{expected_key}' but got: {}",
2311 node_defs[0]
2312 );
2313
2314 let excluded_present = output
2316 .lines()
2317 .any(|l| l.trim_start().starts_with(&excluded_key));
2318 assert!(
2319 !excluded_present,
2320 "excluded node key '{excluded_key}' must not appear in filtered output"
2321 );
2322
2323 let edge_lines: Vec<&str> = output
2325 .lines()
2326 .filter(|l| l.contains("-->") || l.contains("---"))
2327 .collect();
2328 assert!(
2329 edge_lines.is_empty(),
2330 "no edges should appear when only one node is visible, got: {edge_lines:?}"
2331 );
2332 }
2333
2334 #[test]
2337 fn test_d2_filter_node_ids_restricts_output() {
2338 let graph = create_test_graph_for_export();
2339 let snapshot = graph.snapshot();
2340
2341 let all_nodes: Vec<NodeId> = snapshot.iter_nodes().map(|(id, _)| id).collect();
2343 assert!(
2344 all_nodes.len() >= 2,
2345 "test graph must have at least 2 nodes"
2346 );
2347
2348 let kept_id = all_nodes[0];
2350 let expected_key = format!("n{}:", kept_id.index());
2351 let excluded_key = format!("n{}:", all_nodes[1].index());
2353
2354 let mut filter = HashSet::new();
2355 filter.insert(kept_id);
2356
2357 let config = D2Config::default().with_filter_node_ids(Some(filter));
2358 let exporter = UnifiedD2Exporter::with_config(&snapshot, config);
2359 let output = exporter.export();
2360
2361 let node_defs: Vec<&str> = output
2363 .lines()
2364 .filter(|l| {
2365 let trimmed = l.trim_start();
2366 (trimmed.starts_with('n') && trimmed.contains(": {"))
2367 || (trimmed.starts_with('n') && trimmed.contains(": \""))
2368 })
2369 .collect();
2370
2371 assert_eq!(
2372 node_defs.len(),
2373 1,
2374 "filtered export should have exactly 1 node, got: {node_defs:?}"
2375 );
2376
2377 assert!(
2379 node_defs[0].trim_start().starts_with(&expected_key),
2380 "expected node key '{expected_key}' but got: {}",
2381 node_defs[0]
2382 );
2383
2384 let excluded_present = output
2386 .lines()
2387 .any(|l| l.trim_start().starts_with(&excluded_key));
2388 assert!(
2389 !excluded_present,
2390 "excluded node key '{excluded_key}' must not appear in filtered output"
2391 );
2392
2393 let edge_lines: Vec<&str> = output
2395 .lines()
2396 .filter(|l| l.contains("->") || l.contains("<->"))
2397 .collect();
2398 assert!(
2399 edge_lines.is_empty(),
2400 "no edges should appear when only one node is visible, got: {edge_lines:?}"
2401 );
2402 }
2403
2404 #[test]
2407 fn test_dot_filter_node_ids_restricts_output() {
2408 let graph = create_test_graph_for_export();
2409 let snapshot = graph.snapshot();
2410
2411 let all_nodes: Vec<NodeId> = snapshot.iter_nodes().map(|(id, _)| id).collect();
2413 assert!(
2414 all_nodes.len() >= 2,
2415 "test graph must have at least 2 nodes"
2416 );
2417
2418 let kept_id = all_nodes[0];
2420 let expected_key = format!("\"n{}\"", kept_id.index());
2421 let excluded_key = format!("\"n{}\"", all_nodes[1].index());
2423
2424 let mut filter = HashSet::new();
2425 filter.insert(kept_id);
2426
2427 let config = DotConfig::default().with_filter_node_ids(Some(filter));
2428 let exporter = UnifiedDotExporter::with_config(&snapshot, config);
2429 let output = exporter.export();
2430
2431 let node_defs: Vec<&str> = output
2433 .lines()
2434 .filter(|l| {
2435 let trimmed = l.trim_start();
2436 trimmed.starts_with('"') && trimmed.contains("[label=")
2437 })
2438 .collect();
2439
2440 assert_eq!(
2441 node_defs.len(),
2442 1,
2443 "filtered export should have exactly 1 node, got: {node_defs:?}"
2444 );
2445
2446 assert!(
2448 node_defs[0].trim_start().starts_with(&expected_key),
2449 "expected node key '{expected_key}' but got: {}",
2450 node_defs[0]
2451 );
2452
2453 let excluded_present = output
2455 .lines()
2456 .any(|l| l.trim_start().starts_with(&excluded_key) && l.contains("[label="));
2457 assert!(
2458 !excluded_present,
2459 "excluded node key '{excluded_key}' must not appear in filtered output"
2460 );
2461
2462 let edge_lines: Vec<&str> = output.lines().filter(|l| l.contains("->")).collect();
2464 assert!(
2465 edge_lines.is_empty(),
2466 "no edges should appear when only one node is visible, got: {edge_lines:?}"
2467 );
2468 }
2469
2470 #[test]
2473 fn test_dot_config_filter_language() {
2474 let config = DotConfig::default().filter_language(Language::Rust);
2475 assert!(config.filter_languages.contains(&Language::Rust));
2476 }
2477
2478 #[test]
2479 fn test_dot_config_filter_edge() {
2480 let config = DotConfig::default().filter_edge(EdgeFilter::Calls);
2481 assert!(config.filter_edges.contains(&EdgeFilter::Calls));
2482 }
2483
2484 #[test]
2485 fn test_dot_config_with_max_depth() {
2486 let config = DotConfig::default().with_max_depth(5);
2487 assert_eq!(config.max_depth, Some(5));
2488 }
2489}