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