Skip to main content

sqry_core/output/diagram/
mod.rs

1//! Diagram output formatters (Mermaid, GraphViz, D2).
2//!
3//! The types in this module convert relation query results into
4//! diagram-friendly representations for offline text generation.
5
6mod builder;
7mod d2;
8mod graphviz;
9mod mermaid;
10
11pub use builder::{
12    Edge, GraphBuilder, Node, escape_label_graphviz, escape_label_mermaid, sanitize_node_id,
13};
14pub use d2::D2Formatter;
15pub use graphviz::GraphVizFormatter;
16pub use mermaid::MermaidFormatter;
17
18use crate::Result;
19use crate::graph::unified::GraphSnapshot;
20use crate::graph::unified::node::NodeId;
21use anyhow::anyhow;
22use std::fmt;
23use std::str::FromStr;
24
25/// Supported text-based diagram formats.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DiagramFormat {
28    /// Mermaid `graph` syntax.
29    Mermaid,
30    /// `GraphViz` DOT graphs.
31    GraphViz,
32    /// D2 diagrams.
33    D2,
34}
35
36impl DiagramFormat {
37    /// Parse a diagram format from user input (e.g., CLI flag).
38    ///
39    /// # Errors
40    ///
41    /// Returns an error when the provided string does not match any supported
42    /// format (Mermaid, GraphViz/DOT, or D2).
43    pub fn parse_format(value: &str) -> Result<Self> {
44        value.parse()
45    }
46
47    /// File extension typically used for this diagram format.
48    #[must_use]
49    pub fn file_extension(&self) -> &'static str {
50        match self {
51            DiagramFormat::Mermaid => "mmd",
52            DiagramFormat::GraphViz => "dot",
53            DiagramFormat::D2 => "d2",
54        }
55    }
56}
57
58impl fmt::Display for DiagramFormat {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            DiagramFormat::Mermaid => write!(f, "mermaid"),
62            DiagramFormat::GraphViz => write!(f, "graphviz"),
63            DiagramFormat::D2 => write!(f, "d2"),
64        }
65    }
66}
67
68impl FromStr for DiagramFormat {
69    type Err = anyhow::Error;
70
71    fn from_str(s: &str) -> Result<Self> {
72        match s.trim().to_lowercase().as_str() {
73            "mermaid" | "mmd" => Ok(DiagramFormat::Mermaid),
74            "graphviz" | "dot" => Ok(DiagramFormat::GraphViz),
75            "d2" => Ok(DiagramFormat::D2),
76            other => Err(anyhow!("unknown diagram format: {other}")),
77        }
78    }
79}
80
81/// Graph intent that determines which relations are rendered.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum GraphType {
84    /// Function callers/callees graph.
85    CallGraph,
86    /// File/module dependency graph (imports/exports).
87    DependencyGraph,
88    /// Type hierarchy graph (inheritance/implementation).
89    TypeHierarchy,
90}
91
92/// Layout direction for diagram rendering.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum Direction {
95    /// Top-to-bottom layout (Mermaid `TB`, DOT `TB`).
96    #[default]
97    TopDown,
98    /// Bottom-to-top layout (Mermaid `BT`).
99    BottomUp,
100    /// Left-to-right layout (Mermaid/DOT `LR`).
101    LeftRight,
102    /// Right-to-left layout (Mermaid `RL`).
103    RightLeft,
104}
105
106/// Options that control diagram generation.
107#[derive(Debug, Clone)]
108pub struct DiagramOptions {
109    /// Desired output format.
110    pub format: DiagramFormat,
111    /// Relation type to visualize.
112    pub graph_type: GraphType,
113    /// Optional maximum traversal depth (None = unlimited).
114    pub max_depth: Option<usize>,
115    /// Maximum number of nodes allowed in generated graph.
116    pub max_nodes: usize,
117    /// Whether to include file paths/line numbers in node labels.
118    pub include_file_paths: bool,
119    /// Edge layout direction preference.
120    pub direction: Direction,
121}
122
123impl Default for DiagramOptions {
124    fn default() -> Self {
125        Self {
126            format: DiagramFormat::Mermaid,
127            graph_type: GraphType::CallGraph,
128            max_depth: Some(3),
129            max_nodes: 100,
130            include_file_paths: true,
131            direction: Direction::TopDown,
132        }
133    }
134}
135
136/// Edge input for diagram generation.
137///
138/// Uses `NodeId` references directly from the graph.
139#[derive(Debug, Clone)]
140pub struct DiagramEdge {
141    /// Source node ID from the graph.
142    pub source: NodeId,
143    /// Target node ID from the graph.
144    pub target: NodeId,
145    /// Optional edge label (e.g., "async", "as alias", "*").
146    pub label: Option<String>,
147}
148
149impl DiagramEdge {
150    /// Create a new edge without a label.
151    #[must_use]
152    pub fn new(source: NodeId, target: NodeId) -> Self {
153        Self {
154            source,
155            target,
156            label: None,
157        }
158    }
159
160    /// Create a new edge with a label.
161    #[must_use]
162    pub fn with_label(source: NodeId, target: NodeId, label: impl Into<String>) -> Self {
163        Self {
164            source,
165            target,
166            label: Some(label.into()),
167        }
168    }
169}
170
171/// Result of formatting a diagram.
172#[derive(Debug, Clone)]
173pub struct Diagram {
174    /// Format used to render the content.
175    pub format: DiagramFormat,
176    /// Raw text content (Mermaid/DOT/D2).
177    pub content: String,
178    /// Number of nodes included in the diagram.
179    pub node_count: usize,
180    /// Number of edges included in the diagram.
181    pub edge_count: usize,
182    /// True if nodes/edges were truncated to satisfy [`DiagramOptions::max_nodes`].
183    pub is_truncated: bool,
184}
185
186impl Diagram {
187    /// Convenience accessor for whether the diagram contains any nodes.
188    #[must_use]
189    pub fn is_empty(&self) -> bool {
190        self.node_count == 0
191    }
192}
193
194/// Trait implemented by all diagram formatters.
195pub trait DiagramFormatter {
196    /// Format call graph relations into the formatter's syntax.
197    ///
198    /// # Arguments
199    ///
200    /// * `snapshot` - Graph snapshot for node lookups
201    /// * `nodes` - Node IDs to include in the diagram
202    /// * `edges` - Edges between nodes
203    /// * `extra_nodes` - Additional nodes not in the graph (e.g., placeholders)
204    /// * `options` - Diagram generation options
205    ///
206    /// # Errors
207    ///
208    /// Returns an error when graph construction fails.
209    fn format_call_graph(
210        &self,
211        snapshot: &GraphSnapshot,
212        nodes: &[NodeId],
213        edges: &[DiagramEdge],
214        extra_nodes: &[Node],
215        options: &DiagramOptions,
216    ) -> Result<Diagram>;
217
218    /// Format dependency graphs (imports/exports).
219    ///
220    /// # Arguments
221    ///
222    /// * `snapshot` - Graph snapshot for node lookups
223    /// * `nodes` - Node IDs to include in the diagram
224    /// * `edges` - Edges between nodes
225    /// * `extra_nodes` - Additional nodes not in the graph (e.g., placeholders)
226    /// * `options` - Diagram generation options
227    ///
228    /// # Errors
229    ///
230    /// Returns an error when graph construction fails.
231    fn format_dependency_graph(
232        &self,
233        snapshot: &GraphSnapshot,
234        nodes: &[NodeId],
235        edges: &[DiagramEdge],
236        extra_nodes: &[Node],
237        options: &DiagramOptions,
238    ) -> Result<Diagram>;
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn diagram_format_from_str() {
247        assert_eq!(
248            DiagramFormat::parse_format("mermaid").unwrap(),
249            DiagramFormat::Mermaid
250        );
251        assert_eq!(
252            DiagramFormat::parse_format("MMD").unwrap(),
253            DiagramFormat::Mermaid
254        );
255        assert_eq!(
256            DiagramFormat::parse_format("graphviz").unwrap(),
257            DiagramFormat::GraphViz
258        );
259        assert_eq!(
260            DiagramFormat::parse_format("dot").unwrap(),
261            DiagramFormat::GraphViz
262        );
263        assert_eq!(
264            DiagramFormat::parse_format("d2").unwrap(),
265            DiagramFormat::D2
266        );
267        assert!(DiagramFormat::parse_format("invalid").is_err());
268    }
269
270    #[test]
271    fn diagram_format_file_extensions() {
272        assert_eq!(DiagramFormat::Mermaid.file_extension(), "mmd");
273        assert_eq!(DiagramFormat::GraphViz.file_extension(), "dot");
274        assert_eq!(DiagramFormat::D2.file_extension(), "d2");
275    }
276
277    #[test]
278    fn diagram_options_defaults() {
279        let opts = DiagramOptions::default();
280        assert_eq!(opts.format, DiagramFormat::Mermaid);
281        assert_eq!(opts.graph_type, GraphType::CallGraph);
282        assert_eq!(opts.max_depth, Some(3));
283        assert_eq!(opts.max_nodes, 100);
284        assert!(opts.include_file_paths);
285        assert_eq!(opts.direction, Direction::TopDown);
286    }
287}