Skip to main content

sqry_cli/commands/
visualize.rs

1//! Implements the `sqry visualize` command for generating diagrams.
2
3use crate::args::{Cli, DiagramFormatArg, DirectionArg, VisualizeCommand};
4use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
5use anyhow::{Context, Result, anyhow, bail};
6use sqry_core::graph::unified::GraphSnapshot;
7use sqry_core::graph::unified::edge::{EdgeKind, ExportKind};
8use sqry_core::graph::unified::node::{NodeId, NodeKind};
9use sqry_core::graph::unified::{
10    EdgeFilter, TraversalConfig, TraversalDirection, TraversalLimits, traverse,
11};
12use sqry_core::output::diagram::{
13    D2Formatter, Diagram, DiagramEdge, DiagramFormat, DiagramFormatter, DiagramOptions, Direction,
14    GraphType, GraphVizFormatter, MermaidFormatter, Node,
15};
16use std::collections::HashSet;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20/// Run the visualize command.
21///
22/// # Errors
23/// Returns an error if the graph cannot be loaded, rendered, or written.
24pub fn run_visualize(cli: &Cli, cmd: &VisualizeCommand) -> Result<()> {
25    validate_command(cli, cmd)?;
26
27    let relation = RelationQuery::parse(&cmd.query)?;
28    let search_path = cmd.path.as_deref().unwrap_or(cli.search_path());
29    let root_path = Path::new(search_path);
30
31    // Load unified graph snapshot
32    // We use default config (no hidden, no follow symlinks) matching CLI defaults unless specified
33    // For visualization, we generally want what's in the snapshot.
34    let config = GraphLoadConfig::default();
35    let graph = load_unified_graph_for_cli(root_path, &config, cli)
36        .context("Failed to load unified graph. Run `sqry index` first.")?;
37
38    let snapshot = graph.snapshot();
39
40    if snapshot.nodes().is_empty() {
41        bail!(
42            "Graph is empty. Run `sqry index {}` to populate it.",
43            root_path.display()
44        );
45    }
46
47    let max_depth = cmd.depth.max(1);
48    let capped_nodes = cmd.max_nodes.clamp(1, 500);
49
50    let graph_data = collect_graph_data_unified(&relation, &snapshot, max_depth, capped_nodes);
51
52    let has_placeholder_root = !graph_data.extra_nodes.is_empty();
53
54    if has_placeholder_root {
55        eprintln!(
56            "No nodes matched '{}'. Rendering placeholder context only.",
57            relation.target
58        );
59    }
60
61    if graph_data.edges.is_empty() {
62        eprintln!(
63            "No relations found for query '{}'. Rendering node context only.",
64            cmd.query
65        );
66    }
67
68    let options = DiagramOptions {
69        format: cmd.format.into(),
70        graph_type: relation.kind.graph_type(),
71        max_depth: Some(max_depth),
72        max_nodes: capped_nodes,
73        direction: cmd.direction.into(),
74        ..Default::default()
75    };
76
77    let node_count = graph_data.nodes.len() + graph_data.extra_nodes.len();
78    if node_count >= capped_nodes {
79        eprintln!(
80            "⚠️  Graph contains {node_count} nodes but visualization is limited to {capped_nodes}. \
81Use --max-nodes (up to 500) or refine your relation query to include more detail."
82        );
83    }
84
85    let formatter = create_formatter(cmd.format);
86    let diagram = match relation.kind {
87        RelationKind::Imports | RelationKind::Exports => formatter.format_dependency_graph(
88            &snapshot,
89            &graph_data.nodes,
90            &graph_data.edges,
91            &graph_data.extra_nodes,
92            &options,
93        )?,
94        _ => formatter.format_call_graph(
95            &snapshot,
96            &graph_data.nodes,
97            &graph_data.edges,
98            &graph_data.extra_nodes,
99            &options,
100        )?,
101    };
102
103    if diagram.is_truncated {
104        eprintln!(
105            "⚠️  Graph truncated to {capped_nodes} nodes (adjust --max-nodes to include more, max 500)."
106        );
107    }
108
109    write_text_output(&diagram, cmd.output_file.as_ref())?;
110    Ok(())
111}
112
113fn validate_command(cli: &Cli, cmd: &VisualizeCommand) -> Result<()> {
114    if cli.json {
115        bail!("--json output is not supported for the visualize command.");
116    }
117    if cmd.max_nodes == 0 {
118        bail!("--max-nodes must be at least 1.");
119    }
120    if cmd.depth == 0 {
121        bail!("--depth must be at least 1.");
122    }
123    Ok(())
124}
125
126/// Create a placeholder node for no-match queries.
127fn placeholder_node(name: &str) -> Node {
128    Node {
129        id: name.to_string(),
130        label: name.to_string(),
131        file_path: None,
132        line: None,
133    }
134}
135
136/// Collect graph data for visualization using the traversal kernel.
137///
138/// Uses the kernel for BFS traversal, then converts the result into
139/// diagram-specific `GraphData` with sorted edges and labeled edges.
140fn collect_graph_data_unified(
141    relation: &RelationQuery,
142    snapshot: &GraphSnapshot,
143    max_depth: usize,
144    max_nodes: usize,
145) -> GraphData {
146    // Resolve root nodes
147    let root_nodes = resolve_nodes(snapshot, &relation.target, relation.kind);
148
149    if root_nodes.is_empty() {
150        let placeholder = placeholder_node(&relation.target);
151        return GraphData {
152            nodes: Vec::new(),
153            edges: Vec::new(),
154            extra_nodes: vec![placeholder],
155        };
156    }
157
158    // Map relation kind to traversal direction and edge filter
159    let (direction, edge_filter) = match relation.kind {
160        RelationKind::Callers => (TraversalDirection::Incoming, EdgeFilter::calls_only()),
161        RelationKind::Callees => (TraversalDirection::Outgoing, EdgeFilter::calls_only()),
162        RelationKind::Imports => (TraversalDirection::Incoming, edge_filter_imports_only()),
163        RelationKind::Exports => (TraversalDirection::Incoming, edge_filter_exports_only()),
164    };
165
166    let max_edges = max_nodes.saturating_mul(max_depth.max(1)).max(32);
167
168    let config = TraversalConfig {
169        direction,
170        edge_filter,
171        limits: TraversalLimits {
172            max_depth: u32::try_from(max_depth).unwrap_or(u32::MAX),
173            max_nodes: Some(max_nodes),
174            max_edges: Some(max_edges),
175            max_paths: None,
176        },
177    };
178
179    let result = traverse(snapshot, &root_nodes, &config, None);
180
181    // Convert TraversalResult into GraphData
182    let mut nodes = NodeSet::default();
183    let mut edges: Vec<DiagramEdge> = Vec::new();
184
185    // Add root nodes first to preserve ordering
186    for &root in &root_nodes {
187        nodes.add_node(snapshot, root);
188    }
189
190    // Build sorted diagram edges from kernel result
191    let mut diagram_edges: Vec<(DiagramEdge, (String, String, &'static str))> = result
192        .edges
193        .iter()
194        .map(|mat_edge| {
195            let source_id = result.nodes[mat_edge.source_idx].node_id;
196            let target_id = result.nodes[mat_edge.target_idx].node_id;
197
198            let label = edge_label_for_kind(snapshot, &mat_edge.raw_kind);
199
200            let source_key = node_sort_key(snapshot, source_id);
201            let target_key = node_sort_key(snapshot, target_id);
202            let sort_key = (
203                source_key.0.clone(),
204                target_key.0.clone(),
205                mat_edge.raw_kind.tag(),
206            );
207
208            (
209                DiagramEdge {
210                    source: source_id,
211                    target: target_id,
212                    label,
213                },
214                sort_key,
215            )
216        })
217        .collect();
218
219    diagram_edges.sort_by(|a, b| a.1.cmp(&b.1));
220
221    for (edge, _sort_key) in diagram_edges {
222        nodes.add_node(snapshot, edge.source);
223        nodes.add_node(snapshot, edge.target);
224        edges.push(edge);
225    }
226
227    GraphData {
228        nodes: nodes.into_vec(),
229        edges,
230        extra_nodes: Vec::new(),
231    }
232}
233
234/// Edge filter that only includes import edges.
235fn edge_filter_imports_only() -> EdgeFilter {
236    EdgeFilter {
237        include_calls: false,
238        include_imports: true,
239        include_references: false,
240        include_inheritance: false,
241        include_structural: false,
242        include_type_edges: false,
243        include_database: false,
244        include_service: false,
245    }
246}
247
248/// Edge filter that only includes export edges.
249fn edge_filter_exports_only() -> EdgeFilter {
250    // Exports are classified under `include_imports` in EdgeFilter (both import+export).
251    // We include imports flag which covers both Import and Export classifications.
252    EdgeFilter {
253        include_calls: false,
254        include_imports: true,
255        include_references: false,
256        include_inheritance: false,
257        include_structural: false,
258        include_type_edges: false,
259        include_database: false,
260        include_service: false,
261    }
262}
263
264fn resolve_nodes(snapshot: &GraphSnapshot, name: &str, relation_kind: RelationKind) -> Vec<NodeId> {
265    let required_kind = required_node_kind(relation_kind);
266    let matches = collect_node_matches(snapshot, name, required_kind);
267    let mut candidates = select_node_candidates(relation_kind, &matches);
268
269    if candidates.is_empty() {
270        return Vec::new();
271    }
272
273    candidates.sort_by_key(|node_id| node_sort_key(snapshot, *node_id));
274    if relation_kind == RelationKind::Imports {
275        candidates
276    } else {
277        candidates.truncate(1);
278        candidates
279    }
280}
281
282struct NodeMatches {
283    qualified: Vec<NodeId>,
284    name: Vec<NodeId>,
285    pattern: Vec<NodeId>,
286}
287
288fn required_node_kind(relation_kind: RelationKind) -> Option<NodeKind> {
289    match relation_kind {
290        RelationKind::Imports => Some(NodeKind::Import),
291        _ => None,
292    }
293}
294
295fn collect_node_matches(
296    snapshot: &GraphSnapshot,
297    name: &str,
298    required_kind: Option<NodeKind>,
299) -> NodeMatches {
300    let mut qualified = Vec::new();
301    let mut name_matches = Vec::new();
302    let mut pattern = Vec::new();
303
304    for (node_id, entry) in snapshot.iter_nodes() {
305        if required_kind.is_some_and(|kind| entry.kind != kind) {
306            continue;
307        }
308        let name_str = snapshot.strings().resolve(entry.name);
309        let qualified_str = entry
310            .qualified_name
311            .and_then(|id| snapshot.strings().resolve(id));
312        let name_ref = name_str.as_ref().map(AsRef::as_ref);
313        let qualified_ref = qualified_str.as_ref().map(AsRef::as_ref);
314
315        if matches!(qualified_ref, Some(candidate) if candidate == name) {
316            qualified.push(node_id);
317            continue;
318        }
319
320        if matches!(name_ref, Some(candidate) if candidate == name) {
321            name_matches.push(node_id);
322            continue;
323        }
324
325        if matches!(qualified_ref, Some(candidate) if candidate.contains(name))
326            || matches!(name_ref, Some(candidate) if candidate.contains(name))
327        {
328            pattern.push(node_id);
329        }
330    }
331
332    NodeMatches {
333        qualified,
334        name: name_matches,
335        pattern,
336    }
337}
338
339fn select_node_candidates(relation_kind: RelationKind, matches: &NodeMatches) -> Vec<NodeId> {
340    if relation_kind == RelationKind::Imports {
341        return merge_node_candidates(&matches.qualified, &matches.name, &matches.pattern);
342    }
343
344    if !matches.qualified.is_empty() {
345        return matches.qualified.clone();
346    }
347    if !matches.name.is_empty() {
348        return matches.name.clone();
349    }
350
351    matches.pattern.clone()
352}
353
354fn merge_node_candidates(
355    qualified: &[NodeId],
356    name_matches: &[NodeId],
357    pattern: &[NodeId],
358) -> Vec<NodeId> {
359    let mut merged = Vec::new();
360    let mut seen = HashSet::new();
361
362    for node_id in qualified.iter().chain(name_matches).chain(pattern) {
363        if seen.insert(*node_id) {
364            merged.push(*node_id);
365        }
366    }
367
368    merged
369}
370
371fn node_sort_key(snapshot: &GraphSnapshot, id: NodeId) -> (String, String, u32, u64) {
372    if let Some(entry) = snapshot.get_node(id) {
373        let name = node_display_name(snapshot, entry);
374        let file = snapshot
375            .files()
376            .resolve(entry.file)
377            .map(|p| p.as_ref().to_string_lossy().to_string())
378            .unwrap_or_default();
379        (file, name, id.index(), id.generation())
380    } else {
381        (String::new(), String::new(), id.index(), id.generation())
382    }
383}
384
385fn node_display_name(
386    snapshot: &GraphSnapshot,
387    entry: &sqry_core::graph::unified::storage::arena::NodeEntry,
388) -> String {
389    entry
390        .qualified_name
391        .and_then(|sid| snapshot.strings().resolve(sid))
392        .or_else(|| snapshot.strings().resolve(entry.name))
393        .map(|s| s.to_string())
394        .unwrap_or_default()
395}
396
397/// Extract a diagram label from edge kind (for import/export/call edges).
398fn edge_label_for_kind(snapshot: &GraphSnapshot, kind: &EdgeKind) -> Option<String> {
399    match kind {
400        EdgeKind::Calls { is_async, .. } => {
401            if *is_async {
402                Some("async".to_string())
403            } else {
404                None
405            }
406        }
407        EdgeKind::Imports { alias, is_wildcard } => {
408            let alias_name = alias
409                .and_then(|id| snapshot.strings().resolve(id))
410                .map(|value| value.to_string());
411            import_edge_label(alias_name.as_deref(), *is_wildcard)
412        }
413        EdgeKind::Exports { kind, alias } => {
414            let alias_name = alias
415                .and_then(|id| snapshot.strings().resolve(id))
416                .map(|value| value.to_string());
417            export_edge_label(*kind, alias_name.as_deref())
418        }
419        _ => None,
420    }
421}
422
423fn import_edge_label(alias: Option<&str>, is_wildcard: bool) -> Option<String> {
424    match (alias, is_wildcard) {
425        (None, false) => None,
426        (Some(alias), false) => Some(format!("as {alias}")),
427        (None, true) => Some("*".to_string()),
428        (Some(alias), true) => Some(format!("* as {alias}")),
429    }
430}
431
432fn export_edge_label(kind: ExportKind, alias: Option<&str>) -> Option<String> {
433    let kind_label = match kind {
434        ExportKind::Direct => None,
435        ExportKind::Reexport => Some("reexport"),
436        ExportKind::Default => Some("default"),
437        ExportKind::Namespace => Some("namespace"),
438    };
439
440    match (kind_label, alias) {
441        (None, None) => None,
442        (Some(kind), None) => Some(kind.to_string()),
443        (None, Some(alias)) => Some(format!("as {alias}")),
444        (Some(kind), Some(alias)) => Some(format!("{kind} as {alias}")),
445    }
446}
447
448fn create_formatter(format: DiagramFormatArg) -> Box<dyn DiagramFormatter> {
449    match format {
450        DiagramFormatArg::Mermaid => Box::new(MermaidFormatter::new()),
451        DiagramFormatArg::Graphviz => Box::new(GraphVizFormatter::new()),
452        DiagramFormatArg::D2 => Box::new(D2Formatter::new()),
453    }
454}
455
456fn write_text_output(diagram: &Diagram, path: Option<&PathBuf>) -> Result<()> {
457    if let Some(path) = path {
458        fs::write(path, &diagram.content)
459            .with_context(|| format!("Failed to write diagram to {}", path.display()))?;
460        println!("Diagram saved to {}", path.display());
461    } else {
462        println!("{}", diagram.content);
463    }
464    Ok(())
465}
466
467fn render_default_direction(dir: DirectionArg) -> Direction {
468    match dir {
469        DirectionArg::TopDown => Direction::TopDown,
470        DirectionArg::BottomUp => Direction::BottomUp,
471        DirectionArg::LeftRight => Direction::LeftRight,
472        DirectionArg::RightLeft => Direction::RightLeft,
473    }
474}
475
476#[derive(Debug)]
477struct GraphData {
478    nodes: Vec<NodeId>,
479    edges: Vec<DiagramEdge>,
480    /// Placeholder nodes for no-match queries (not in graph).
481    extra_nodes: Vec<Node>,
482}
483
484/// Tracks visited nodes by key (`qualified_name` or name), maintaining insertion order.
485#[derive(Default)]
486struct NodeSet {
487    seen: HashSet<String>,
488    ordered: Vec<NodeId>,
489}
490
491impl NodeSet {
492    fn add_node(&mut self, snapshot: &GraphSnapshot, node_id: NodeId) {
493        let key = node_key(snapshot, node_id);
494        if self.seen.insert(key) {
495            self.ordered.push(node_id);
496        }
497    }
498
499    fn into_vec(self) -> Vec<NodeId> {
500        self.ordered
501    }
502}
503
504/// Get a unique key for a node (`qualified_name` if present, else name).
505fn node_key(snapshot: &GraphSnapshot, node_id: NodeId) -> String {
506    if let Some(entry) = snapshot.get_node(node_id) {
507        entry
508            .qualified_name
509            .and_then(|sid| snapshot.strings().resolve(sid))
510            .or_else(|| snapshot.strings().resolve(entry.name))
511            .map(|s| s.to_string())
512            .unwrap_or_default()
513    } else {
514        String::new()
515    }
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519enum RelationKind {
520    Callers,
521    Callees,
522    Imports,
523    Exports,
524}
525
526impl RelationKind {
527    fn from_str(value: &str) -> Option<Self> {
528        match value.to_lowercase().as_str() {
529            "callers" => Some(Self::Callers),
530            "callees" => Some(Self::Callees),
531            "imports" => Some(Self::Imports),
532            "exports" => Some(Self::Exports),
533            _ => None,
534        }
535    }
536
537    fn graph_type(self) -> GraphType {
538        match self {
539            RelationKind::Callers | RelationKind::Callees => GraphType::CallGraph,
540            RelationKind::Imports | RelationKind::Exports => GraphType::DependencyGraph,
541        }
542    }
543}
544
545#[derive(Debug)]
546struct RelationQuery {
547    kind: RelationKind,
548    target: String,
549}
550
551impl RelationQuery {
552    fn parse(input: &str) -> Result<Self> {
553        let (prefix, target) = input.split_once(':').ok_or_else(|| {
554            anyhow!("Relation query must use the form kind:symbol. Example: callers:main")
555        })?;
556
557        let kind = RelationKind::from_str(prefix.trim()).ok_or_else(|| {
558            anyhow!("Unsupported relation '{prefix}'. Use callers, callees, imports, or exports.")
559        })?;
560
561        let target = target.trim();
562        if target.is_empty() {
563            bail!("Relation target cannot be empty.");
564        }
565
566        Ok(Self {
567            kind,
568            target: target.to_string(),
569        })
570    }
571}
572
573impl From<DiagramFormatArg> for DiagramFormat {
574    fn from(value: DiagramFormatArg) -> Self {
575        match value {
576            DiagramFormatArg::Mermaid => DiagramFormat::Mermaid,
577            DiagramFormatArg::Graphviz => DiagramFormat::GraphViz,
578            DiagramFormatArg::D2 => DiagramFormat::D2,
579        }
580    }
581}
582
583impl From<DirectionArg> for Direction {
584    fn from(value: DirectionArg) -> Self {
585        render_default_direction(value)
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn parses_relation_query() {
595        let query = RelationQuery::parse("callers:main").unwrap();
596        assert_eq!(query.kind, RelationKind::Callers);
597        assert_eq!(query.target, "main");
598    }
599
600    #[test]
601    fn rejects_unknown_relation() {
602        assert!(RelationQuery::parse("unknown:foo").is_err());
603    }
604}