1use 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
17pub 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 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
123struct TraversalContext<'a> {
125 snapshot: &'a GraphSnapshot,
126 relation_kind: RelationKind,
127 max_depth: usize,
128 max_edges: usize,
129}
130
131fn 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
160fn 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
170fn 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
188fn 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 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
421fn 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 extra_nodes: Vec<Node>,
506}
507
508#[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
528fn 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}