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