1use 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
20pub 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 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
126fn 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
136fn collect_graph_data_unified(
155 relation: &RelationQuery,
156 snapshot: &GraphSnapshot,
157 max_depth: usize,
158 max_nodes: usize,
159) -> GraphData {
160 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 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 let mut nodes = NodeSet::default();
197 let mut edges: Vec<DiagramEdge> = Vec::new();
198
199 for &root in &root_nodes {
201 nodes.add_node(snapshot, root);
202 }
203
204 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
248fn 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
262fn edge_filter_exports_only() -> EdgeFilter {
264 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 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
416fn 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 extra_nodes: Vec<Node>,
501}
502
503#[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
523fn 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}