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