Skip to main content

sqry_core/visualization/
unified.rs

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