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.
140///
141/// # Frontier invariant (DB19)
142///
143/// Root nodes are resolved once at the handler boundary via
144/// [`resolve_nodes`] and passed as a `&[NodeId]` into [`traverse`]. The
145/// kernel walks the NodeId-keyed graph and never re-resolves names at
146/// depth ≥ 1. This is the same invariant DB17/DB18 locked for
147/// `trace_path`, `subgraph`, `call-chain-depth`, and `dependency-tree`
148/// — a same-simple-name node at depth 1 never broadens the frontier
149/// because the kernel operates on generational-indexed `NodeId`s, not
150/// names.
151///
152/// Regressions that reintroduced DB15-class same-name broadening would
153/// surface in the CLI `migration_golden_cli_test` golden tests.
154fn collect_graph_data_unified(
155    relation: &RelationQuery,
156    snapshot: &GraphSnapshot,
157    max_depth: usize,
158    max_nodes: usize,
159) -> GraphData {
160    // Resolve root nodes
161    let root_nodes = resolve_nodes(snapshot, &relation.target, relation.kind);
162
163    if root_nodes.is_empty() {
164        let placeholder = placeholder_node(&relation.target);
165        return GraphData {
166            nodes: Vec::new(),
167            edges: Vec::new(),
168            extra_nodes: vec![placeholder],
169        };
170    }
171
172    // Map relation kind to traversal direction and edge filter
173    let (direction, edge_filter) = match relation.kind {
174        RelationKind::Callers => (TraversalDirection::Incoming, EdgeFilter::calls_only()),
175        RelationKind::Callees => (TraversalDirection::Outgoing, EdgeFilter::calls_only()),
176        RelationKind::Imports => (TraversalDirection::Incoming, edge_filter_imports_only()),
177        RelationKind::Exports => (TraversalDirection::Incoming, edge_filter_exports_only()),
178    };
179
180    let max_edges = max_nodes.saturating_mul(max_depth.max(1)).max(32);
181
182    let config = TraversalConfig {
183        direction,
184        edge_filter,
185        limits: TraversalLimits {
186            max_depth: u32::try_from(max_depth).unwrap_or(u32::MAX),
187            max_nodes: Some(max_nodes),
188            max_edges: Some(max_edges),
189            max_paths: None,
190        },
191    };
192
193    let result = traverse(snapshot, &root_nodes, &config, None);
194
195    // Convert TraversalResult into GraphData
196    let mut nodes = NodeSet::default();
197    let mut edges: Vec<DiagramEdge> = Vec::new();
198
199    // Add root nodes first to preserve ordering
200    for &root in &root_nodes {
201        nodes.add_node(snapshot, root);
202    }
203
204    // Build sorted diagram edges from kernel result
205    let mut diagram_edges: Vec<(DiagramEdge, (String, String, &'static str))> = result
206        .edges
207        .iter()
208        .map(|mat_edge| {
209            let source_id = result.nodes[mat_edge.source_idx].node_id;
210            let target_id = result.nodes[mat_edge.target_idx].node_id;
211
212            let label = edge_label_for_kind(snapshot, &mat_edge.raw_kind);
213
214            let source_key = node_sort_key(snapshot, source_id);
215            let target_key = node_sort_key(snapshot, target_id);
216            let sort_key = (
217                source_key.0.clone(),
218                target_key.0.clone(),
219                mat_edge.raw_kind.tag(),
220            );
221
222            (
223                DiagramEdge {
224                    source: source_id,
225                    target: target_id,
226                    label,
227                },
228                sort_key,
229            )
230        })
231        .collect();
232
233    diagram_edges.sort_by(|a, b| a.1.cmp(&b.1));
234
235    for (edge, _sort_key) in diagram_edges {
236        nodes.add_node(snapshot, edge.source);
237        nodes.add_node(snapshot, edge.target);
238        edges.push(edge);
239    }
240
241    GraphData {
242        nodes: nodes.into_vec(),
243        edges,
244        extra_nodes: Vec::new(),
245    }
246}
247
248/// Edge filter that only includes import edges.
249fn edge_filter_imports_only() -> EdgeFilter {
250    EdgeFilter {
251        include_calls: false,
252        include_imports: true,
253        include_references: false,
254        include_inheritance: false,
255        include_structural: false,
256        include_type_edges: false,
257        include_database: false,
258        include_service: false,
259    }
260}
261
262/// Edge filter that only includes export edges.
263fn edge_filter_exports_only() -> EdgeFilter {
264    // Exports are classified under `include_imports` in EdgeFilter (both import+export).
265    // We include imports flag which covers both Import and Export classifications.
266    EdgeFilter {
267        include_calls: false,
268        include_imports: true,
269        include_references: false,
270        include_inheritance: false,
271        include_structural: false,
272        include_type_edges: false,
273        include_database: false,
274        include_service: false,
275    }
276}
277
278fn resolve_nodes(snapshot: &GraphSnapshot, name: &str, relation_kind: RelationKind) -> Vec<NodeId> {
279    let required_kind = required_node_kind(relation_kind);
280    let matches = collect_node_matches(snapshot, name, required_kind);
281    let mut candidates = select_node_candidates(relation_kind, &matches);
282
283    if candidates.is_empty() {
284        return Vec::new();
285    }
286
287    candidates.sort_by_key(|node_id| node_sort_key(snapshot, *node_id));
288    if relation_kind == RelationKind::Imports {
289        candidates
290    } else {
291        candidates.truncate(1);
292        candidates
293    }
294}
295
296struct NodeMatches {
297    qualified: Vec<NodeId>,
298    name: Vec<NodeId>,
299    pattern: Vec<NodeId>,
300}
301
302fn required_node_kind(relation_kind: RelationKind) -> Option<NodeKind> {
303    match relation_kind {
304        RelationKind::Imports => Some(NodeKind::Import),
305        _ => None,
306    }
307}
308
309fn collect_node_matches(
310    snapshot: &GraphSnapshot,
311    name: &str,
312    required_kind: Option<NodeKind>,
313) -> NodeMatches {
314    let mut qualified = Vec::new();
315    let mut name_matches = Vec::new();
316    let mut pattern = Vec::new();
317
318    for (node_id, entry) in snapshot.iter_nodes() {
319        // Gate 0d iter-2 fix: skip unified losers from CLI
320        // `visualize` matching. See `NodeEntry::is_unified_loser`.
321        if entry.is_unified_loser() {
322            continue;
323        }
324        if required_kind.is_some_and(|kind| entry.kind != kind) {
325            continue;
326        }
327        let name_str = snapshot.strings().resolve(entry.name);
328        let qualified_str = entry
329            .qualified_name
330            .and_then(|id| snapshot.strings().resolve(id));
331        let name_ref = name_str.as_ref().map(AsRef::as_ref);
332        let qualified_ref = qualified_str.as_ref().map(AsRef::as_ref);
333
334        if matches!(qualified_ref, Some(candidate) if candidate == name) {
335            qualified.push(node_id);
336            continue;
337        }
338
339        if matches!(name_ref, Some(candidate) if candidate == name) {
340            name_matches.push(node_id);
341            continue;
342        }
343
344        if matches!(qualified_ref, Some(candidate) if candidate.contains(name))
345            || matches!(name_ref, Some(candidate) if candidate.contains(name))
346        {
347            pattern.push(node_id);
348        }
349    }
350
351    NodeMatches {
352        qualified,
353        name: name_matches,
354        pattern,
355    }
356}
357
358fn select_node_candidates(relation_kind: RelationKind, matches: &NodeMatches) -> Vec<NodeId> {
359    if relation_kind == RelationKind::Imports {
360        return merge_node_candidates(&matches.qualified, &matches.name, &matches.pattern);
361    }
362
363    if !matches.qualified.is_empty() {
364        return matches.qualified.clone();
365    }
366    if !matches.name.is_empty() {
367        return matches.name.clone();
368    }
369
370    matches.pattern.clone()
371}
372
373fn merge_node_candidates(
374    qualified: &[NodeId],
375    name_matches: &[NodeId],
376    pattern: &[NodeId],
377) -> Vec<NodeId> {
378    let mut merged = Vec::new();
379    let mut seen = HashSet::new();
380
381    for node_id in qualified.iter().chain(name_matches).chain(pattern) {
382        if seen.insert(*node_id) {
383            merged.push(*node_id);
384        }
385    }
386
387    merged
388}
389
390fn node_sort_key(snapshot: &GraphSnapshot, id: NodeId) -> (String, String, u32, u64) {
391    if let Some(entry) = snapshot.get_node(id) {
392        let name = node_display_name(snapshot, entry);
393        let file = snapshot
394            .files()
395            .resolve(entry.file)
396            .map(|p| p.as_ref().to_string_lossy().to_string())
397            .unwrap_or_default();
398        (file, name, id.index(), id.generation())
399    } else {
400        (String::new(), String::new(), id.index(), id.generation())
401    }
402}
403
404fn node_display_name(
405    snapshot: &GraphSnapshot,
406    entry: &sqry_core::graph::unified::storage::arena::NodeEntry,
407) -> String {
408    entry
409        .qualified_name
410        .and_then(|sid| snapshot.strings().resolve(sid))
411        .or_else(|| snapshot.strings().resolve(entry.name))
412        .map(|s| s.to_string())
413        .unwrap_or_default()
414}
415
416/// Extract a diagram label from edge kind (for import/export/call edges).
417fn edge_label_for_kind(snapshot: &GraphSnapshot, kind: &EdgeKind) -> Option<String> {
418    match kind {
419        EdgeKind::Calls { is_async, .. } => {
420            if *is_async {
421                Some("async".to_string())
422            } else {
423                None
424            }
425        }
426        EdgeKind::Imports { alias, is_wildcard } => {
427            let alias_name = alias
428                .and_then(|id| snapshot.strings().resolve(id))
429                .map(|value| value.to_string());
430            import_edge_label(alias_name.as_deref(), *is_wildcard)
431        }
432        EdgeKind::Exports { kind, alias } => {
433            let alias_name = alias
434                .and_then(|id| snapshot.strings().resolve(id))
435                .map(|value| value.to_string());
436            export_edge_label(*kind, alias_name.as_deref())
437        }
438        _ => None,
439    }
440}
441
442fn import_edge_label(alias: Option<&str>, is_wildcard: bool) -> Option<String> {
443    match (alias, is_wildcard) {
444        (None, false) => None,
445        (Some(alias), false) => Some(format!("as {alias}")),
446        (None, true) => Some("*".to_string()),
447        (Some(alias), true) => Some(format!("* as {alias}")),
448    }
449}
450
451fn export_edge_label(kind: ExportKind, alias: Option<&str>) -> Option<String> {
452    let kind_label = match kind {
453        ExportKind::Direct => None,
454        ExportKind::Reexport => Some("reexport"),
455        ExportKind::Default => Some("default"),
456        ExportKind::Namespace => Some("namespace"),
457    };
458
459    match (kind_label, alias) {
460        (None, None) => None,
461        (Some(kind), None) => Some(kind.to_string()),
462        (None, Some(alias)) => Some(format!("as {alias}")),
463        (Some(kind), Some(alias)) => Some(format!("{kind} as {alias}")),
464    }
465}
466
467fn create_formatter(format: DiagramFormatArg) -> Box<dyn DiagramFormatter> {
468    match format {
469        DiagramFormatArg::Mermaid => Box::new(MermaidFormatter::new()),
470        DiagramFormatArg::Graphviz => Box::new(GraphVizFormatter::new()),
471        DiagramFormatArg::D2 => Box::new(D2Formatter::new()),
472    }
473}
474
475fn write_text_output(diagram: &Diagram, path: Option<&PathBuf>) -> Result<()> {
476    if let Some(path) = path {
477        fs::write(path, &diagram.content)
478            .with_context(|| format!("Failed to write diagram to {}", path.display()))?;
479        println!("Diagram saved to {}", path.display());
480    } else {
481        println!("{}", diagram.content);
482    }
483    Ok(())
484}
485
486fn render_default_direction(dir: DirectionArg) -> Direction {
487    match dir {
488        DirectionArg::TopDown => Direction::TopDown,
489        DirectionArg::BottomUp => Direction::BottomUp,
490        DirectionArg::LeftRight => Direction::LeftRight,
491        DirectionArg::RightLeft => Direction::RightLeft,
492    }
493}
494
495#[derive(Debug)]
496struct GraphData {
497    nodes: Vec<NodeId>,
498    edges: Vec<DiagramEdge>,
499    /// Placeholder nodes for no-match queries (not in graph).
500    extra_nodes: Vec<Node>,
501}
502
503/// Tracks visited nodes by key (`qualified_name` or name), maintaining insertion order.
504#[derive(Default)]
505struct NodeSet {
506    seen: HashSet<String>,
507    ordered: Vec<NodeId>,
508}
509
510impl NodeSet {
511    fn add_node(&mut self, snapshot: &GraphSnapshot, node_id: NodeId) {
512        let key = node_key(snapshot, node_id);
513        if self.seen.insert(key) {
514            self.ordered.push(node_id);
515        }
516    }
517
518    fn into_vec(self) -> Vec<NodeId> {
519        self.ordered
520    }
521}
522
523/// Get a unique key for a node (`qualified_name` if present, else name).
524fn node_key(snapshot: &GraphSnapshot, node_id: NodeId) -> String {
525    if let Some(entry) = snapshot.get_node(node_id) {
526        entry
527            .qualified_name
528            .and_then(|sid| snapshot.strings().resolve(sid))
529            .or_else(|| snapshot.strings().resolve(entry.name))
530            .map(|s| s.to_string())
531            .unwrap_or_default()
532    } else {
533        String::new()
534    }
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538enum RelationKind {
539    Callers,
540    Callees,
541    Imports,
542    Exports,
543}
544
545impl RelationKind {
546    fn from_str(value: &str) -> Option<Self> {
547        match value.to_lowercase().as_str() {
548            "callers" => Some(Self::Callers),
549            "callees" => Some(Self::Callees),
550            "imports" => Some(Self::Imports),
551            "exports" => Some(Self::Exports),
552            _ => None,
553        }
554    }
555
556    fn graph_type(self) -> GraphType {
557        match self {
558            RelationKind::Callers | RelationKind::Callees => GraphType::CallGraph,
559            RelationKind::Imports | RelationKind::Exports => GraphType::DependencyGraph,
560        }
561    }
562}
563
564#[derive(Debug)]
565struct RelationQuery {
566    kind: RelationKind,
567    target: String,
568}
569
570impl RelationQuery {
571    fn parse(input: &str) -> Result<Self> {
572        let (prefix, target) = input.split_once(':').ok_or_else(|| {
573            anyhow!("Relation query must use the form kind:symbol. Example: callers:main")
574        })?;
575
576        let kind = RelationKind::from_str(prefix.trim()).ok_or_else(|| {
577            anyhow!("Unsupported relation '{prefix}'. Use callers, callees, imports, or exports.")
578        })?;
579
580        let target = target.trim();
581        if target.is_empty() {
582            bail!("Relation target cannot be empty.");
583        }
584
585        Ok(Self {
586            kind,
587            target: target.to_string(),
588        })
589    }
590}
591
592impl From<DiagramFormatArg> for DiagramFormat {
593    fn from(value: DiagramFormatArg) -> Self {
594        match value {
595            DiagramFormatArg::Mermaid => DiagramFormat::Mermaid,
596            DiagramFormatArg::Graphviz => DiagramFormat::GraphViz,
597            DiagramFormatArg::D2 => DiagramFormat::D2,
598        }
599    }
600}
601
602impl From<DirectionArg> for Direction {
603    fn from(value: DirectionArg) -> Self {
604        render_default_direction(value)
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn parses_relation_query() {
614        let query = RelationQuery::parse("callers:main").unwrap();
615        assert_eq!(query.kind, RelationKind::Callers);
616        assert_eq!(query.target, "main");
617    }
618
619    #[test]
620    fn rejects_unknown_relation() {
621        assert!(RelationQuery::parse("unknown:foo").is_err());
622    }
623}