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