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(
141 relation: &RelationQuery,
142 snapshot: &GraphSnapshot,
143 max_depth: usize,
144 max_nodes: usize,
145) -> GraphData {
146 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 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 let mut nodes = NodeSet::default();
183 let mut edges: Vec<DiagramEdge> = Vec::new();
184
185 for &root in &root_nodes {
187 nodes.add_node(snapshot, root);
188 }
189
190 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
234fn 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
248fn edge_filter_exports_only() -> EdgeFilter {
250 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
397fn 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 extra_nodes: Vec<Node>,
482}
483
484#[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
504fn 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}