Skip to main content

sqry_core/visualization/
unified.rs

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