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