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