Skip to main content

sqry_core/visualization/
unified.rs

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