1pub mod loader;
18pub mod provenance;
19pub mod resolve;
20
21use crate::args::{Cli, GraphOperation};
22use anyhow::{Context, Result, bail};
23use loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
24use sqry_core::graph::Language;
25use sqry_core::graph::CodeGraph as UnifiedCodeGraph;
27use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
28use sqry_core::graph::unified::materialize::find_nodes_by_name;
29use sqry_core::graph::unified::{
30 EdgeFilter, MqProtocol, NodeEntry, NodeKind as UnifiedNodeKind, StringId, TraversalConfig,
31 TraversalDirection, TraversalLimits, traverse,
32};
33use std::collections::{HashMap, HashSet, VecDeque};
34use std::path::{Path, PathBuf};
35
36type UnifiedGraphSnapshot = sqry_core::graph::unified::concurrent::GraphSnapshot;
37
38#[allow(clippy::too_many_lines)] pub fn run_graph(
44 cli: &Cli,
45 operation: &GraphOperation,
46 search_path: &str,
47 format: &str,
48 verbose: bool,
49) -> Result<()> {
50 let root = PathBuf::from(search_path);
51
52 if matches!(operation, GraphOperation::Status) {
60 return super::run_graph_status_with_format(cli, search_path, format == "json");
61 }
62
63 let config = build_graph_load_config(cli);
64 let unified_graph = load_unified_graph_for_cli(&root, &config, cli, no_op_reporter())
65 .context("Failed to load unified graph")?;
66
67 match operation {
68 GraphOperation::Stats {
69 by_file,
70 by_language,
71 } => run_stats_unified(&unified_graph, *by_file, *by_language, format),
72 GraphOperation::TracePath {
73 from,
74 to,
75 languages,
76 full_paths,
77 } => run_trace_path_unified(
78 &unified_graph,
79 from,
80 to,
81 languages.as_deref(),
82 *full_paths,
83 format,
84 verbose,
85 &root,
86 ),
87 GraphOperation::Cycles {
88 min_length,
89 max_length,
90 imports_only,
91 languages,
92 } => run_cycles_unified(
93 &unified_graph,
94 *min_length,
95 *max_length,
96 *imports_only,
97 languages.as_deref(),
98 format,
99 verbose,
100 ),
101 GraphOperation::CallChainDepth {
102 symbol,
103 languages,
104 show_chain,
105 } => run_call_chain_depth_unified(
106 &unified_graph,
107 symbol,
108 languages.as_deref(),
109 *show_chain,
110 format,
111 verbose,
112 ),
113 GraphOperation::DependencyTree {
114 module,
115 max_depth,
116 cycles_only,
117 } => run_dependency_tree_unified(
118 &unified_graph,
119 module,
120 *max_depth,
121 *cycles_only,
122 format,
123 verbose,
124 ),
125 GraphOperation::CrossLanguage {
126 from_lang,
127 to_lang,
128 edge_type,
129 min_confidence,
130 } => run_cross_language_unified(
131 &unified_graph,
132 from_lang.as_deref(),
133 to_lang.as_deref(),
134 edge_type.as_deref(),
135 *min_confidence,
136 format,
137 verbose,
138 ),
139 GraphOperation::Nodes {
140 kind,
141 languages,
142 file,
143 name,
144 qualified_name,
145 limit,
146 offset,
147 full_paths,
148 } => run_nodes_unified(
149 &unified_graph,
150 root.as_path(),
151 &NodeFilterOptions {
152 kind: kind.as_deref(),
153 languages: languages.as_deref(),
154 file: file.as_deref(),
155 name: name.as_deref(),
156 qualified_name: qualified_name.as_deref(),
157 },
158 &PaginationOptions {
159 limit: *limit,
160 offset: *offset,
161 },
162 &OutputOptions {
163 full_paths: *full_paths,
164 format,
165 verbose,
166 },
167 ),
168 GraphOperation::Edges {
169 kind,
170 from,
171 to,
172 from_lang,
173 to_lang,
174 file,
175 limit,
176 offset,
177 full_paths,
178 } => run_edges_unified(
179 &unified_graph,
180 root.as_path(),
181 &EdgeFilterOptions {
182 kind: kind.as_deref(),
183 from: from.as_deref(),
184 to: to.as_deref(),
185 from_lang: from_lang.as_deref(),
186 to_lang: to_lang.as_deref(),
187 file: file.as_deref(),
188 },
189 &PaginationOptions {
190 limit: *limit,
191 offset: *offset,
192 },
193 &OutputOptions {
194 full_paths: *full_paths,
195 format,
196 verbose,
197 },
198 ),
199 GraphOperation::Complexity {
200 target,
201 sort_complexity,
202 min_complexity,
203 languages,
204 } => run_complexity_unified(
205 &unified_graph,
206 target.as_deref(),
207 *sort_complexity,
208 *min_complexity,
209 languages.as_deref(),
210 format,
211 verbose,
212 ),
213 GraphOperation::DirectCallers {
214 symbol,
215 limit,
216 languages,
217 full_paths,
218 } => run_direct_callers_unified(
219 &unified_graph,
220 root.as_path(),
221 &DirectCallOptions {
222 symbol,
223 limit: *limit,
224 languages: languages.as_deref(),
225 full_paths: *full_paths,
226 format,
227 verbose,
228 },
229 ),
230 GraphOperation::DirectCallees {
231 symbol,
232 limit,
233 languages,
234 full_paths,
235 } => run_direct_callees_unified(
236 &unified_graph,
237 root.as_path(),
238 &DirectCallOptions {
239 symbol,
240 limit: *limit,
241 languages: languages.as_deref(),
242 full_paths: *full_paths,
243 format,
244 verbose,
245 },
246 ),
247 GraphOperation::CallHierarchy {
248 symbol,
249 depth,
250 direction,
251 languages,
252 full_paths,
253 } => run_call_hierarchy_unified(
254 &unified_graph,
255 root.as_path(),
256 &CallHierarchyOptions {
257 symbol,
258 max_depth: *depth,
259 direction,
260 languages: languages.as_deref(),
261 full_paths: *full_paths,
262 format,
263 verbose,
264 },
265 ),
266 GraphOperation::IsInCycle {
267 symbol,
268 cycle_type,
269 show_cycle,
270 } => run_is_in_cycle_unified(
271 &unified_graph,
272 root.as_path(),
273 symbol,
274 cycle_type,
275 *show_cycle,
276 format,
277 verbose,
278 ),
279 GraphOperation::Provenance { symbol, json } => {
280 let json_out = *json || format == "json";
287 let snapshot = unified_graph.snapshot();
288 provenance::run(&snapshot, symbol, json_out)
289 }
290 GraphOperation::Resolve {
291 symbol,
292 explain,
293 json,
294 } => {
295 let json_out = *json || format == "json";
299 let snapshot = unified_graph.snapshot();
300 resolve::run(&snapshot, symbol, *explain, json_out)
301 }
302 GraphOperation::Status => {
303 unreachable!("Status is handled before loading the unified graph in run_graph")
304 }
305 }
306}
307
308fn build_graph_load_config(cli: &Cli) -> GraphLoadConfig {
309 GraphLoadConfig {
310 include_hidden: cli.hidden,
311 follow_symlinks: cli.follow,
312 max_depth: if cli.max_depth == 0 {
313 None
314 } else {
315 Some(cli.max_depth)
316 },
317 force_build: false, }
319}
320
321fn resolve_node_name(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
322 snapshot
323 .strings()
324 .resolve(entry.name)
325 .map_or_else(|| "?".to_string(), |s| s.to_string())
326}
327
328fn resolve_node_label(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
329 entry
330 .qualified_name
331 .and_then(|id| snapshot.strings().resolve(id))
332 .or_else(|| snapshot.strings().resolve(entry.name))
333 .map_or_else(|| "?".to_string(), |s| s.to_string())
334}
335
336fn resolve_node_language(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
337 snapshot
338 .files()
339 .language_for_file(entry.file)
340 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"))
341}
342
343fn resolve_node_file_path(
344 snapshot: &UnifiedGraphSnapshot,
345 entry: &NodeEntry,
346 full_paths: bool,
347) -> String {
348 snapshot.files().resolve(entry.file).map_or_else(
349 || "unknown".to_string(),
350 |p| {
351 if full_paths {
352 p.to_string_lossy().to_string()
353 } else {
354 p.file_name()
355 .and_then(|n| n.to_str())
356 .unwrap_or("unknown")
357 .to_string()
358 }
359 },
360 )
361}
362
363fn resolve_node_label_by_id(
364 snapshot: &UnifiedGraphSnapshot,
365 node_id: UnifiedNodeId,
366) -> Option<String> {
367 snapshot
368 .get_node(node_id)
369 .map(|entry| resolve_node_label(snapshot, entry))
370}
371fn run_stats_unified(
379 graph: &UnifiedCodeGraph,
380 by_file: bool,
381 by_language: bool,
382 format: &str,
383) -> Result<()> {
384 let snapshot = graph.snapshot();
385
386 let compute_detailed = by_file || by_language;
388 let (node_count, edge_count, cross_language_count, kind_counts) =
389 collect_edge_stats_unified(&snapshot, compute_detailed);
390
391 let lang_counts = if by_language {
392 collect_language_counts_unified(&snapshot)
393 } else {
394 HashMap::new()
395 };
396 let file_counts = if by_file {
397 collect_file_counts_unified(&snapshot)
398 } else {
399 HashMap::new()
400 };
401 let file_count = snapshot.files().len();
402
403 let stats = GraphStats {
405 node_count,
406 edge_count,
407 cross_language_count,
408 kind_counts: &kind_counts,
409 lang_counts: &lang_counts,
410 file_counts: &file_counts,
411 file_count,
412 };
413 let display_options = StatsDisplayOptions {
414 by_language,
415 by_file,
416 };
417
418 match format {
420 "json" => {
421 print_stats_unified_json(&stats, &display_options)?;
422 }
423 _ => {
424 print_stats_unified_text(&stats, &display_options);
425 }
426 }
427
428 Ok(())
429}
430
431fn collect_edge_stats_unified(
432 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
433 compute_detailed: bool,
434) -> (usize, usize, usize, HashMap<String, usize>) {
435 let node_count = snapshot.nodes().len();
436
437 let edge_stats = snapshot.edges().stats();
439 let edge_count = edge_stats.forward.csr_edge_count + edge_stats.forward.delta_edge_count
440 - edge_stats.forward.tombstone_count;
441
442 let mut kind_counts: HashMap<String, usize> = HashMap::new();
443 let mut cross_language_count = 0usize;
444
445 if compute_detailed {
447 for (src_id, tgt_id, kind) in snapshot.iter_edges() {
448 let kind_str = format!("{kind:?}");
449 *kind_counts.entry(kind_str).or_insert(0) += 1;
450
451 if let (Some(src_entry), Some(tgt_entry)) =
453 (snapshot.get_node(src_id), snapshot.get_node(tgt_id))
454 {
455 let src_lang = snapshot.files().language_for_file(src_entry.file);
456 let tgt_lang = snapshot.files().language_for_file(tgt_entry.file);
457 if src_lang != tgt_lang && src_lang.is_some() && tgt_lang.is_some() {
458 cross_language_count += 1;
459 }
460 }
461 }
462 }
463
464 (node_count, edge_count, cross_language_count, kind_counts)
465}
466
467fn collect_language_counts_unified(
468 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
469) -> HashMap<String, usize> {
470 let mut lang_counts = HashMap::new();
471 for (_node_id, entry) in snapshot.iter_nodes() {
472 if entry.is_unified_loser() {
475 continue;
476 }
477 if let Some(lang) = snapshot.files().language_for_file(entry.file) {
478 let lang_str = format!("{lang:?}");
479 *lang_counts.entry(lang_str).or_insert(0) += 1;
480 } else {
481 *lang_counts.entry("Unknown".to_string()).or_insert(0) += 1;
482 }
483 }
484 lang_counts
485}
486
487fn collect_file_counts_unified(
488 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
489) -> HashMap<String, usize> {
490 let mut file_counts = HashMap::new();
491 for (_node_id, entry) in snapshot.iter_nodes() {
492 if entry.is_unified_loser() {
495 continue;
496 }
497 if let Some(path) = snapshot.files().resolve(entry.file) {
498 let file_str = path.to_string_lossy().to_string();
499 *file_counts.entry(file_str).or_insert(0) += 1;
500 }
501 }
502 file_counts
503}
504
505struct GraphStats<'a> {
507 node_count: usize,
508 edge_count: usize,
509 cross_language_count: usize,
510 kind_counts: &'a HashMap<String, usize>,
511 lang_counts: &'a HashMap<String, usize>,
512 file_counts: &'a HashMap<String, usize>,
513 file_count: usize,
514}
515
516struct StatsDisplayOptions {
518 by_language: bool,
519 by_file: bool,
520}
521
522fn print_stats_unified_text(stats: &GraphStats<'_>, options: &StatsDisplayOptions) {
524 println!("Graph Statistics (Unified Graph)");
525 println!("=================================");
526 println!();
527 println!("Total Nodes: {node_count}", node_count = stats.node_count);
528 println!("Total Edges: {edge_count}", edge_count = stats.edge_count);
529 println!("Files: {file_count}", file_count = stats.file_count);
530
531 if !stats.kind_counts.is_empty() {
533 println!();
534 println!(
535 "Cross-Language Edges: {cross_language_count}",
536 cross_language_count = stats.cross_language_count
537 );
538 println!();
539
540 println!("Edges by Kind:");
541 let mut sorted_kinds: Vec<_> = stats.kind_counts.iter().collect();
542 sorted_kinds.sort_by_key(|(kind, _)| kind.as_str());
543 for (kind, count) in sorted_kinds {
544 println!(" {kind}: {count}");
545 }
546 }
547 println!();
548
549 if options.by_language && !stats.lang_counts.is_empty() {
550 println!("Nodes by Language:");
551 let mut sorted_langs: Vec<_> = stats.lang_counts.iter().collect();
552 sorted_langs.sort_by_key(|(lang, _)| lang.as_str());
553 for (lang, count) in sorted_langs {
554 println!(" {lang}: {count}");
555 }
556 println!();
557 }
558
559 println!("Files: {file_count}", file_count = stats.file_count);
560 if options.by_file && !stats.file_counts.is_empty() {
561 println!();
562 println!("Nodes by File (top 10):");
563 let mut sorted_files: Vec<_> = stats.file_counts.iter().collect();
564 sorted_files.sort_by(|a, b| b.1.cmp(a.1));
565 for (file, count) in sorted_files.into_iter().take(10) {
566 println!(" {file}: {count}");
567 }
568 }
569}
570
571fn print_stats_unified_json(stats: &GraphStats<'_>, options: &StatsDisplayOptions) -> Result<()> {
573 use serde_json::{Map, Value, json};
574
575 let mut output = Map::new();
576 output.insert("node_count".into(), json!(stats.node_count));
577 output.insert("edge_count".into(), json!(stats.edge_count));
578 output.insert(
579 "cross_language_edge_count".into(),
580 json!(stats.cross_language_count),
581 );
582 output.insert("edges_by_kind".into(), json!(stats.kind_counts));
583 output.insert("file_count".into(), json!(stats.file_count));
584
585 if options.by_language {
586 output.insert("nodes_by_language".into(), json!(stats.lang_counts));
587 output.insert("language_count".into(), json!(stats.lang_counts.len()));
588 }
589
590 if options.by_file {
591 output.insert("nodes_by_file".into(), json!(stats.file_counts));
592 }
593
594 let value = Value::Object(output);
595 println!("{}", serde_json::to_string_pretty(&value)?);
596
597 Ok(())
598}
599
600use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
603
604fn run_trace_path_unified(
609 graph: &UnifiedCodeGraph,
610 from: &str,
611 to: &str,
612 languages: Option<&str>,
613 full_paths: bool,
614 format: &str,
615 verbose: bool,
616 workspace_root: &Path,
617) -> Result<()> {
618 let snapshot = graph.snapshot();
619
620 let start_candidates = find_nodes_by_name(&snapshot, from);
622 if start_candidates.is_empty() {
623 bail!(
624 "Symbol '{from}' not found in graph. Use `sqry --lang` to inspect available languages."
625 );
626 }
627
628 let target_candidates = find_nodes_by_name(&snapshot, to);
630 if target_candidates.is_empty() {
631 bail!("Symbol '{to}' not found in graph.");
632 }
633
634 let language_list = parse_language_filter(languages)?;
636 let language_filter: HashSet<_> = language_list.into_iter().collect();
637
638 let filtered_starts =
639 filter_nodes_by_language_unified(&snapshot, start_candidates, &language_filter);
640
641 if filtered_starts.is_empty() {
642 bail!(
643 "Symbol '{}' not found in requested languages: {}",
644 from,
645 display_languages(&language_filter)
646 );
647 }
648
649 let filtered_targets: HashSet<_> =
650 filter_nodes_by_language_unified(&snapshot, target_candidates, &language_filter)
651 .into_iter()
652 .collect();
653
654 if filtered_targets.is_empty() {
655 bail!(
656 "Symbol '{}' not found in requested languages: {}",
657 to,
658 display_languages(&language_filter)
659 );
660 }
661
662 let storage = sqry_core::graph::unified::persistence::GraphStorage::new(workspace_root);
667 let analysis = sqry_core::graph::unified::analysis::try_load_path_analysis(&storage, "calls");
668
669 let path = if let Some((_csr, ref scc_data, ref cond_dag)) = analysis {
670 let any_reachable = filtered_starts.iter().any(|&start| {
673 let Some(start_scc) = scc_data.scc_of(start) else {
674 return false;
675 };
676 filtered_targets.iter().any(|target| {
677 scc_data
678 .scc_of(*target)
679 .is_some_and(|target_scc| cond_dag.can_reach(start_scc, target_scc))
680 })
681 });
682
683 if any_reachable {
684 find_path_unified_bfs(
687 &snapshot,
688 &filtered_starts,
689 &filtered_targets,
690 &language_filter,
691 )
692 } else {
693 log::info!("Analysis reachability check: no path possible, skipping BFS");
694 None
695 }
696 } else {
697 find_path_unified_bfs(
699 &snapshot,
700 &filtered_starts,
701 &filtered_targets,
702 &language_filter,
703 )
704 };
705
706 let path = path.ok_or_else(|| anyhow::anyhow!("No path found from '{from}' to '{to}'"))?;
707
708 if path.is_empty() {
709 bail!("Path resolution returned no nodes");
710 }
711
712 write_trace_path_output_unified(&snapshot, &path, full_paths, verbose, format)?;
713
714 Ok(())
715}
716
717fn filter_nodes_by_language_unified(
718 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
719 candidates: Vec<UnifiedNodeId>,
720 language_filter: &HashSet<Language>,
721) -> Vec<UnifiedNodeId> {
722 if language_filter.is_empty() {
723 return candidates;
724 }
725
726 candidates
727 .into_iter()
728 .filter(|&node_id| {
729 if let Some(entry) = snapshot.get_node(node_id) {
730 snapshot
731 .files()
732 .language_for_file(entry.file)
733 .is_some_and(|lang| language_filter.contains(&lang))
734 } else {
735 false
736 }
737 })
738 .collect()
739}
740
741fn write_trace_path_output_unified(
742 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
743 path: &[UnifiedNodeId],
744 full_paths: bool,
745 verbose: bool,
746 format: &str,
747) -> Result<()> {
748 match format {
749 "json" => print_trace_path_unified_json(snapshot, path, full_paths, verbose),
750 "dot" | "mermaid" | "d2" => {
751 eprintln!(
754 "Note: Visualization format '{format}' not yet migrated to unified graph. Using text output."
755 );
756 print_trace_path_unified_text(snapshot, path, full_paths, verbose);
757 Ok(())
758 }
759 _ => {
760 print_trace_path_unified_text(snapshot, path, full_paths, verbose);
761 Ok(())
762 }
763 }
764}
765
766fn find_path_unified_bfs(
772 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
773 starts: &[UnifiedNodeId],
774 targets: &HashSet<UnifiedNodeId>,
775 language_filter: &HashSet<Language>,
776) -> Option<Vec<UnifiedNodeId>> {
777 let config = TraversalConfig {
780 direction: TraversalDirection::Outgoing,
781 edge_filter: EdgeFilter::calls_only(),
782 limits: TraversalLimits {
783 max_depth: u32::MAX,
784 max_nodes: None,
785 max_edges: None,
786 max_paths: None,
787 },
788 };
789
790 let mut strategy = LanguageFilterStrategy {
792 snapshot,
793 language_filter,
794 };
795
796 let result = traverse(
797 snapshot,
798 starts,
799 &config,
800 if language_filter.is_empty() {
801 None
802 } else {
803 Some(&mut strategy)
804 },
805 );
806
807 let target_idx = result
809 .nodes
810 .iter()
811 .enumerate()
812 .find(|(_, n)| targets.contains(&n.node_id))
813 .map(|(idx, _)| idx)?;
814
815 let mut parent_idx: HashMap<usize, usize> = HashMap::new();
818 for edge in &result.edges {
819 parent_idx.entry(edge.target_idx).or_insert(edge.source_idx);
821 }
822
823 let mut path_indices = Vec::new();
824 let mut current = target_idx;
825 path_indices.push(current);
826
827 while let Some(&parent) = parent_idx.get(¤t) {
828 path_indices.push(parent);
829 current = parent;
830 }
831
832 path_indices.reverse();
833
834 let first_node_id = result.nodes[path_indices[0]].node_id;
836 if !starts.contains(&first_node_id) {
837 return None;
838 }
839
840 Some(
842 path_indices
843 .iter()
844 .map(|&idx| result.nodes[idx].node_id)
845 .collect(),
846 )
847}
848
849struct LanguageFilterStrategy<'a> {
851 snapshot: &'a sqry_core::graph::unified::concurrent::GraphSnapshot,
852 language_filter: &'a HashSet<Language>,
853}
854
855impl sqry_core::graph::unified::TraversalStrategy for LanguageFilterStrategy<'_> {
856 fn should_enqueue(
857 &mut self,
858 node_id: UnifiedNodeId,
859 _from: UnifiedNodeId,
860 _edge: &sqry_core::graph::unified::edge::EdgeKind,
861 _depth: u32,
862 ) -> bool {
863 if self.language_filter.is_empty() {
864 return true;
865 }
866 let Some(entry) = self.snapshot.get_node(node_id) else {
867 return false;
868 };
869 self.snapshot
870 .files()
871 .language_for_file(entry.file)
872 .is_some_and(|l| self.language_filter.contains(&l))
873 }
874}
875
876fn print_trace_path_unified_text(
878 snapshot: &UnifiedGraphSnapshot,
879 path: &[UnifiedNodeId],
880 full_paths: bool,
881 verbose: bool,
882) {
883 let start_name = path
885 .first()
886 .and_then(|&id| snapshot.get_node(id))
887 .map_or_else(
888 || "?".to_string(),
889 |entry| resolve_node_name(snapshot, entry),
890 );
891
892 let end_name = path
893 .last()
894 .and_then(|&id| snapshot.get_node(id))
895 .map_or_else(
896 || "?".to_string(),
897 |entry| resolve_node_name(snapshot, entry),
898 );
899
900 println!(
901 "Path from '{start_name}' to '{end_name}' ({} steps):",
902 path.len().saturating_sub(1)
903 );
904 println!();
905
906 for (i, &node_id) in path.iter().enumerate() {
907 if let Some(entry) = snapshot.get_node(node_id) {
908 let qualified_name = resolve_node_label(snapshot, entry);
909 let file_path = resolve_node_file_path(snapshot, entry, full_paths);
910 let language = resolve_node_language(snapshot, entry);
911
912 let step = i + 1;
913 println!(" {step}. {qualified_name} ({language} in {file_path})");
914
915 if verbose {
916 println!(
917 " └─ {file_path}:{}:{}",
918 entry.start_line, entry.start_column
919 );
920 }
921
922 if i < path.len() - 1 {
923 println!(" │");
924 println!(" ↓");
925 }
926 }
927 }
928}
929
930fn print_trace_path_unified_json(
932 snapshot: &UnifiedGraphSnapshot,
933 path: &[UnifiedNodeId],
934 full_paths: bool,
935 verbose: bool,
936) -> Result<()> {
937 use serde_json::json;
938
939 let nodes: Vec<_> = path
940 .iter()
941 .filter_map(|&node_id| {
942 let entry = snapshot.get_node(node_id)?;
943 let qualified_name = resolve_node_label(snapshot, entry);
944 let file_path = resolve_node_file_path(snapshot, entry, full_paths);
945 let language = resolve_node_language(snapshot, entry);
946
947 if verbose {
948 Some(json!({
949 "id": format!("{node_id:?}"),
950 "name": qualified_name,
951 "language": language,
952 "file": file_path,
953 "span": {
954 "start": { "line": entry.start_line, "column": entry.start_column },
955 "end": { "line": entry.end_line, "column": entry.end_column }
956 }
957 }))
958 } else {
959 Some(json!({
960 "id": format!("{node_id:?}"),
961 "name": qualified_name,
962 "language": language,
963 "file": file_path
964 }))
965 }
966 })
967 .collect();
968
969 let output = json!({
970 "path": nodes,
971 "length": path.len(),
972 "steps": path.len().saturating_sub(1)
973 });
974
975 println!("{}", serde_json::to_string_pretty(&output)?);
976 Ok(())
977}
978
979fn run_cycles_unified(
986 graph: &UnifiedCodeGraph,
987 min_length: usize,
988 max_length: Option<usize>,
989 imports_only: bool,
990 languages: Option<&str>,
991 format: &str,
992 verbose: bool,
993) -> Result<()> {
994 let snapshot = graph.snapshot();
995
996 let language_list = parse_language_filter(languages)?;
997 let language_filter: HashSet<_> = language_list.into_iter().collect();
998
999 let cycles = detect_cycles_unified(&snapshot, imports_only, &language_filter);
1001
1002 let filtered_cycles: Vec<_> = cycles
1004 .into_iter()
1005 .filter(|cycle| {
1006 let len = cycle.len();
1007 len >= min_length && max_length.is_none_or(|max| len <= max)
1008 })
1009 .collect();
1010
1011 if verbose {
1012 eprintln!(
1013 "Found {} cycles (min_length={}, max_length={:?})",
1014 filtered_cycles.len(),
1015 min_length,
1016 max_length
1017 );
1018 }
1019
1020 match format {
1021 "json" => print_cycles_unified_json(&filtered_cycles, &snapshot)?,
1022 _ => print_cycles_unified_text(&filtered_cycles, &snapshot),
1023 }
1024
1025 Ok(())
1026}
1027
1028fn detect_cycles_unified(
1030 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1031 imports_only: bool,
1032 language_filter: &HashSet<Language>,
1033) -> Vec<Vec<UnifiedNodeId>> {
1034 let mut adjacency: HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> = HashMap::new();
1036
1037 for (src_id, tgt_id, kind) in snapshot.iter_edges() {
1038 if imports_only && !matches!(kind, UnifiedEdgeKind::Imports { .. }) {
1040 continue;
1041 }
1042
1043 adjacency.entry(src_id).or_default().push(tgt_id);
1044 }
1045
1046 let mut cycles = Vec::new();
1047 let mut visited = HashSet::new();
1048 let mut rec_stack = HashSet::new();
1049 let mut path = Vec::new();
1050
1051 for (node_id, entry) in snapshot.iter_nodes() {
1052 if entry.is_unified_loser() {
1055 continue;
1056 }
1057 if !language_filter.is_empty() {
1059 let node_lang = snapshot.files().language_for_file(entry.file);
1060 if !node_lang.is_some_and(|l| language_filter.contains(&l)) {
1061 continue;
1062 }
1063 }
1064
1065 if !visited.contains(&node_id) {
1066 detect_cycles_unified_dfs(
1067 snapshot,
1068 node_id,
1069 &adjacency,
1070 &mut visited,
1071 &mut rec_stack,
1072 &mut path,
1073 &mut cycles,
1074 );
1075 }
1076 }
1077
1078 cycles
1079}
1080
1081#[allow(clippy::only_used_in_recursion)]
1083fn detect_cycles_unified_dfs(
1084 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1085 node: UnifiedNodeId,
1086 adjacency: &HashMap<UnifiedNodeId, Vec<UnifiedNodeId>>,
1087 visited: &mut HashSet<UnifiedNodeId>,
1088 rec_stack: &mut HashSet<UnifiedNodeId>,
1089 path: &mut Vec<UnifiedNodeId>,
1090 cycles: &mut Vec<Vec<UnifiedNodeId>>,
1091) {
1092 visited.insert(node);
1093 rec_stack.insert(node);
1094 path.push(node);
1095
1096 if let Some(neighbors) = adjacency.get(&node) {
1098 for &neighbor in neighbors {
1099 if rec_stack.contains(&neighbor) {
1100 record_cycle_if_new(path, neighbor, cycles);
1101 continue;
1102 }
1103
1104 if !visited.contains(&neighbor) {
1105 detect_cycles_unified_dfs(
1106 snapshot, neighbor, adjacency, visited, rec_stack, path, cycles,
1107 );
1108 }
1109 }
1110 }
1111
1112 path.pop();
1113 rec_stack.remove(&node);
1114}
1115
1116fn record_cycle_if_new(
1117 path: &[UnifiedNodeId],
1118 neighbor: UnifiedNodeId,
1119 cycles: &mut Vec<Vec<UnifiedNodeId>>,
1120) {
1121 if let Some(cycle_start) = path.iter().position(|&n| n == neighbor) {
1123 let cycle: Vec<_> = path[cycle_start..].to_vec();
1124 if !cycles.contains(&cycle) {
1125 cycles.push(cycle);
1126 }
1127 }
1128}
1129
1130fn print_cycles_unified_text(cycles: &[Vec<UnifiedNodeId>], snapshot: &UnifiedGraphSnapshot) {
1132 if cycles.is_empty() {
1133 println!("No cycles found.");
1134 return;
1135 }
1136
1137 let cycle_count = cycles.len();
1138 println!("Found {cycle_count} cycle(s):");
1139 println!();
1140
1141 for (i, cycle) in cycles.iter().enumerate() {
1142 let cycle_index = i + 1;
1143 let cycle_length = cycle.len();
1144 println!("Cycle {cycle_index} (length {cycle_length}):");
1145
1146 for &node_id in cycle {
1147 if let Some(entry) = snapshot.get_node(node_id) {
1148 let name = resolve_node_label(snapshot, entry);
1149 let language = resolve_node_language(snapshot, entry);
1150
1151 println!(" → {name} ({language})");
1152 }
1153 }
1154
1155 if let Some(&first) = cycle.first()
1157 && let Some(entry) = snapshot.get_node(first)
1158 {
1159 let name = resolve_node_label(snapshot, entry);
1160
1161 println!(" → {name} (cycle)");
1162 }
1163
1164 println!();
1165 }
1166}
1167
1168fn print_cycles_unified_json(
1170 cycles: &[Vec<UnifiedNodeId>],
1171 snapshot: &UnifiedGraphSnapshot,
1172) -> Result<()> {
1173 use serde_json::json;
1174
1175 let cycle_data: Vec<_> = cycles
1176 .iter()
1177 .map(|cycle| {
1178 let nodes: Vec<_> = cycle
1179 .iter()
1180 .filter_map(|&node_id| {
1181 let entry = snapshot.get_node(node_id)?;
1182 let name = resolve_node_label(snapshot, entry);
1183 let language = resolve_node_language(snapshot, entry);
1184 let file = resolve_node_file_path(snapshot, entry, true);
1185
1186 Some(json!({
1187 "id": format!("{node_id:?}"),
1188 "name": name,
1189 "language": language,
1190 "file": file
1191 }))
1192 })
1193 .collect();
1194
1195 json!({
1196 "length": cycle.len(),
1197 "nodes": nodes
1198 })
1199 })
1200 .collect();
1201
1202 let output = json!({
1203 "count": cycles.len(),
1204 "cycles": cycle_data
1205 });
1206
1207 println!("{}", serde_json::to_string_pretty(&output)?);
1208 Ok(())
1209}
1210
1211type UnifiedDepthResult = (UnifiedNodeId, usize, Option<Vec<Vec<UnifiedNodeId>>>);
1217
1218fn run_call_chain_depth_unified(
1224 graph: &UnifiedCodeGraph,
1225 symbol: &str,
1226 languages: Option<&str>,
1227 show_chain: bool,
1228 format: &str,
1229 verbose: bool,
1230) -> Result<()> {
1231 let snapshot = graph.snapshot();
1232 let lang_filter = parse_language_filter_unified(languages);
1233
1234 let matching_nodes = filter_matching_nodes_by_language(&snapshot, symbol, &lang_filter);
1236
1237 if matching_nodes.is_empty() {
1238 bail!("Symbol '{symbol}' not found in graph (after language filtering)");
1239 }
1240
1241 let mut results = build_depth_results(&snapshot, &matching_nodes, show_chain);
1242
1243 results.sort_by_key(|(_, depth, _)| std::cmp::Reverse(*depth));
1245
1246 if verbose {
1247 eprintln!(
1248 "Call chain depth analysis: {} symbol(s) matching '{}'",
1249 results.len(),
1250 symbol
1251 );
1252 }
1253
1254 write_call_chain_depth_output(&results, &snapshot, show_chain, verbose, format)
1256}
1257
1258fn filter_matching_nodes_by_language(
1259 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1260 symbol: &str,
1261 lang_filter: &[String],
1262) -> Vec<UnifiedNodeId> {
1263 let mut matching_nodes = find_nodes_by_name(snapshot, symbol);
1264 if lang_filter.is_empty() {
1265 return matching_nodes;
1266 }
1267
1268 matching_nodes.retain(|&node_id| {
1269 let Some(entry) = snapshot.get_node(node_id) else {
1270 return false;
1271 };
1272 let Some(lang) = snapshot.files().language_for_file(entry.file) else {
1273 return false;
1274 };
1275 lang_filter
1276 .iter()
1277 .any(|filter| filter.eq_ignore_ascii_case(&format!("{lang:?}")))
1278 });
1279
1280 matching_nodes
1281}
1282
1283fn build_depth_results(
1284 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1285 matching_nodes: &[UnifiedNodeId],
1286 show_chain: bool,
1287) -> Vec<UnifiedDepthResult> {
1288 let mut results = Vec::new();
1289 for &node_id in matching_nodes {
1290 let depth = calculate_call_chain_depth_unified(snapshot, node_id);
1291 let chains = show_chain.then(|| build_call_chain_unified(snapshot, node_id));
1292 results.push((node_id, depth, chains));
1293 }
1294 results
1295}
1296
1297fn write_call_chain_depth_output(
1298 results: &[UnifiedDepthResult],
1299 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1300 show_chain: bool,
1301 verbose: bool,
1302 format: &str,
1303) -> Result<()> {
1304 if format == "json" {
1305 print_call_chain_depth_unified_json(results, snapshot, show_chain, verbose)
1306 } else {
1307 print_call_chain_depth_unified_text(results, snapshot, show_chain, verbose);
1308 Ok(())
1309 }
1310}
1311
1312fn calculate_call_chain_depth_unified(
1337 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1338 start: UnifiedNodeId,
1339) -> usize {
1340 let config = TraversalConfig {
1341 direction: TraversalDirection::Outgoing,
1342 edge_filter: EdgeFilter::calls_only(),
1343 limits: TraversalLimits {
1344 max_depth: u32::MAX,
1345 max_nodes: None,
1346 max_edges: None,
1347 max_paths: None,
1348 },
1349 };
1350
1351 let result = traverse(snapshot, &[start], &config, None);
1352
1353 result
1355 .edges
1356 .iter()
1357 .map(|e| e.depth as usize)
1358 .max()
1359 .unwrap_or(0)
1360}
1361
1362fn build_call_chain_unified(
1372 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1373 start: UnifiedNodeId,
1374) -> Vec<Vec<UnifiedNodeId>> {
1375 let mut chains = Vec::new();
1376 let mut queue = VecDeque::new();
1377
1378 queue.push_back(vec![start]);
1379
1380 while let Some(path) = queue.pop_front() {
1381 let current = *path.last().unwrap();
1382 let callees = snapshot.get_callees(current);
1383
1384 if callees.is_empty() {
1385 chains.push(path);
1387 } else {
1388 for callee in callees {
1389 if !path.contains(&callee) {
1391 let mut new_path = path.clone();
1392 new_path.push(callee);
1393 queue.push_back(new_path);
1394 }
1395 }
1396 }
1397
1398 if chains.len() >= 100 {
1400 break;
1401 }
1402 }
1403
1404 chains
1405}
1406
1407fn print_call_chain_depth_unified_text(
1409 results: &[UnifiedDepthResult],
1410 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1411 show_chain: bool,
1412 verbose: bool,
1413) {
1414 if results.is_empty() {
1415 println!("No results found.");
1416 return;
1417 }
1418
1419 println!("Call Chain Depth Analysis");
1420 println!("========================");
1421 println!();
1422
1423 for (node_id, depth, chains) in results {
1424 if let Some(entry) = snapshot.get_node(*node_id) {
1425 print_call_chain_entry(
1426 snapshot,
1427 entry,
1428 *depth,
1429 chains.as_ref(),
1430 show_chain,
1431 verbose,
1432 );
1433 }
1434 }
1435}
1436
1437fn print_call_chain_entry(
1438 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1439 entry: &NodeEntry,
1440 depth: usize,
1441 chains: Option<&Vec<Vec<UnifiedNodeId>>>,
1442 show_chain: bool,
1443 verbose: bool,
1444) {
1445 let name = entry
1446 .qualified_name
1447 .and_then(|id| snapshot.strings().resolve(id))
1448 .or_else(|| snapshot.strings().resolve(entry.name))
1449 .map_or_else(|| "?".to_string(), |s| s.to_string());
1450
1451 let language = snapshot
1452 .files()
1453 .language_for_file(entry.file)
1454 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
1455
1456 println!("Symbol: {name} ({language})");
1457 println!("Depth: {depth}");
1458
1459 if verbose {
1460 let file = snapshot.files().resolve(entry.file).map_or_else(
1461 || "unknown".to_string(),
1462 |p| p.to_string_lossy().to_string(),
1463 );
1464 println!("File: {file}");
1465 let line = entry.start_line;
1466 let column = entry.start_column;
1467 println!("Line: {line}:{column}");
1468 }
1469
1470 if let Some(chain_list) = chains.filter(|_| show_chain) {
1471 print_call_chain_list(snapshot, chain_list);
1472 }
1473
1474 println!();
1475}
1476
1477fn print_call_chain_list(
1478 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1479 chain_list: &[Vec<UnifiedNodeId>],
1480) {
1481 let chain_count = chain_list.len();
1482 println!("Chains: {chain_count} path(s)");
1483 for (i, chain) in chain_list.iter().take(5).enumerate() {
1484 let chain_index = i + 1;
1485 println!(" Chain {chain_index}:");
1486 for (j, &chain_node_id) in chain.iter().enumerate() {
1487 if let Some(chain_entry) = snapshot.get_node(chain_node_id) {
1488 let chain_name = chain_entry
1489 .qualified_name
1490 .and_then(|id| snapshot.strings().resolve(id))
1491 .or_else(|| snapshot.strings().resolve(chain_entry.name))
1492 .map_or_else(|| "?".to_string(), |s| s.to_string());
1493 let step = j + 1;
1494 println!(" {step}. {chain_name}");
1495 }
1496 }
1497 }
1498 if chain_list.len() > 5 {
1499 let remaining = chain_list.len() - 5;
1500 println!(" ... and {remaining} more chains");
1501 }
1502}
1503
1504fn print_call_chain_depth_unified_json(
1506 results: &[UnifiedDepthResult],
1507 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1508 _show_chain: bool,
1509 verbose: bool,
1510) -> Result<()> {
1511 use serde_json::json;
1512
1513 let items: Vec<_> = results
1514 .iter()
1515 .filter_map(|(node_id, depth, chains)| {
1516 let entry = snapshot.get_node(*node_id)?;
1517
1518 let name = entry
1519 .qualified_name
1520 .and_then(|id| snapshot.strings().resolve(id))
1521 .or_else(|| snapshot.strings().resolve(entry.name))
1522 .map_or_else(|| "?".to_string(), |s| s.to_string());
1523
1524 let language = snapshot
1525 .files()
1526 .language_for_file(entry.file)
1527 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
1528
1529 let mut obj = json!({
1530 "symbol": name,
1531 "language": language,
1532 "depth": depth,
1533 });
1534
1535 if verbose {
1536 let file = snapshot.files().resolve(entry.file).map_or_else(
1537 || "unknown".to_string(),
1538 |p| p.to_string_lossy().to_string(),
1539 );
1540 obj["file"] = json!(file);
1541 }
1542
1543 if let Some(chain_list) = chains {
1544 let chain_json: Vec<Vec<String>> = chain_list
1545 .iter()
1546 .map(|chain| {
1547 chain
1548 .iter()
1549 .filter_map(|&nid| {
1550 snapshot.get_node(nid).map(|e| {
1551 e.qualified_name
1552 .and_then(|id| snapshot.strings().resolve(id))
1553 .or_else(|| snapshot.strings().resolve(e.name))
1554 .map_or_else(|| "?".to_string(), |s| s.to_string())
1555 })
1556 })
1557 .collect()
1558 })
1559 .collect();
1560 obj["chains"] = json!(chain_json);
1561 }
1562
1563 Some(obj)
1564 })
1565 .collect();
1566
1567 let output = json!({
1568 "results": items,
1569 "count": results.len()
1570 });
1571
1572 println!("{}", serde_json::to_string_pretty(&output)?);
1573 Ok(())
1574}
1575
1576fn parse_language_filter_unified(languages: Option<&str>) -> Vec<String> {
1580 if let Some(langs) = languages {
1581 langs.split(',').map(|s| s.trim().to_string()).collect()
1582 } else {
1583 Vec::new()
1584 }
1585}
1586
1587struct UnifiedSubGraph {
1595 nodes: Vec<UnifiedNodeId>,
1597 edges: Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)>,
1599}
1600
1601fn resolve_module_arg_unified(snapshot: &UnifiedGraphSnapshot, module: &str) -> Vec<UnifiedNodeId> {
1614 let by_name = find_nodes_by_name(snapshot, module);
1615 if !by_name.is_empty() {
1616 return by_name;
1617 }
1618 if let Some(file_id) = snapshot.files().get(Path::new(module)) {
1619 let nodes_in_file = snapshot.indices().by_file(file_id);
1620 if !nodes_in_file.is_empty() {
1621 return nodes_in_file.to_vec();
1622 }
1623 }
1624 Vec::new()
1625}
1626
1627fn run_dependency_tree_unified(
1632 graph: &UnifiedCodeGraph,
1633 module: &str,
1634 max_depth: Option<usize>,
1635 cycles_only: bool,
1636 format: &str,
1637 verbose: bool,
1638) -> Result<()> {
1639 let snapshot = graph.snapshot();
1640
1641 let root_nodes = resolve_module_arg_unified(&snapshot, module);
1646 if root_nodes.is_empty() {
1647 bail!(
1648 "Module '{module}' not found in graph (tried symbol-name lookup and file-path \
1649 lookup; pass either a symbol name or a repo-relative or absolute file path \
1650 that was indexed)"
1651 );
1652 }
1653
1654 let mut subgraph = build_dependency_tree_unified(&snapshot, &root_nodes);
1656
1657 if subgraph.nodes.is_empty() {
1658 bail!("Module '{module}' has no dependencies");
1659 }
1660
1661 if let Some(depth_limit) = max_depth {
1663 subgraph = filter_by_depth_unified(&snapshot, &subgraph, &root_nodes, depth_limit);
1664 }
1665
1666 if cycles_only {
1668 subgraph = filter_cycles_only_unified(&subgraph);
1669 if subgraph.nodes.is_empty() {
1670 println!("No circular dependencies found for module '{module}'");
1671 return Ok(());
1672 }
1673 }
1674
1675 if verbose {
1676 eprintln!(
1677 "Dependency tree: {} nodes, {} edges",
1678 subgraph.nodes.len(),
1679 subgraph.edges.len()
1680 );
1681 }
1682
1683 match format {
1685 "json" => print_dependency_tree_unified_json(&subgraph, &snapshot, verbose),
1686 "dot" | "mermaid" | "d2" => {
1687 println!("Note: Visualization format '{format}' uses text output for unified graph.");
1689 println!();
1690 print_dependency_tree_unified_text(&subgraph, &snapshot, cycles_only, verbose);
1691 Ok(())
1692 }
1693 _ => {
1694 print_dependency_tree_unified_text(&subgraph, &snapshot, cycles_only, verbose);
1695 Ok(())
1696 }
1697 }
1698}
1699
1700fn build_dependency_tree_unified(
1704 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1705 root_nodes: &[UnifiedNodeId],
1706) -> UnifiedSubGraph {
1707 let (visited_nodes, mut edges) = collect_dependency_edges_unified(snapshot, root_nodes);
1708 let node_set: HashSet<_> = visited_nodes.iter().copied().collect();
1709 add_internal_edges_unified(snapshot, &node_set, &mut edges);
1710
1711 UnifiedSubGraph {
1712 nodes: visited_nodes.into_iter().collect(),
1713 edges,
1714 }
1715}
1716
1717fn collect_dependency_edges_unified(
1741 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1742 root_nodes: &[UnifiedNodeId],
1743) -> (
1744 HashSet<UnifiedNodeId>,
1745 Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)>,
1746) {
1747 let config = TraversalConfig {
1748 direction: TraversalDirection::Outgoing,
1749 edge_filter: EdgeFilter::all(),
1750 limits: TraversalLimits {
1751 max_depth: u32::MAX,
1752 max_nodes: None,
1753 max_edges: None,
1754 max_paths: None,
1755 },
1756 };
1757
1758 let result = traverse(snapshot, root_nodes, &config, None);
1759
1760 let visited_nodes: HashSet<UnifiedNodeId> = result.nodes.iter().map(|n| n.node_id).collect();
1761
1762 let edges: Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)> = result
1763 .edges
1764 .iter()
1765 .map(|e| {
1766 (
1767 result.nodes[e.source_idx].node_id,
1768 result.nodes[e.target_idx].node_id,
1769 e.raw_kind.clone(),
1770 )
1771 })
1772 .collect();
1773
1774 (visited_nodes, edges)
1775}
1776
1777fn add_internal_edges_unified(
1778 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1779 node_set: &HashSet<UnifiedNodeId>,
1780 edges: &mut Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)>,
1781) {
1782 for (from, to, kind) in snapshot.iter_edges() {
1783 if node_set.contains(&from)
1784 && node_set.contains(&to)
1785 && !edge_exists_unified(edges, from, to)
1786 {
1787 edges.push((from, to, kind));
1788 }
1789 }
1790}
1791
1792fn edge_exists_unified(
1793 edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
1794 from: UnifiedNodeId,
1795 to: UnifiedNodeId,
1796) -> bool {
1797 edges.iter().any(|&(f, t, _)| f == from && t == to)
1798}
1799
1800fn filter_by_depth_unified(
1806 _snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1807 subgraph: &UnifiedSubGraph,
1808 root_nodes: &[UnifiedNodeId],
1809 max_depth: usize,
1810) -> UnifiedSubGraph {
1811 let mut depths: HashMap<UnifiedNodeId, usize> = HashMap::new();
1813 let mut queue = VecDeque::new();
1814
1815 let mut adj: HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> = HashMap::new();
1817 for &(from, to, _) in &subgraph.edges {
1818 adj.entry(from).or_default().push(to);
1819 }
1820
1821 let node_set: HashSet<_> = subgraph.nodes.iter().copied().collect();
1823 for &root in root_nodes {
1824 if node_set.contains(&root) {
1825 depths.insert(root, 0);
1826 queue.push_back((root, 0));
1827 }
1828 }
1829
1830 let mut visited = HashSet::new();
1832 while let Some((current, depth)) = queue.pop_front() {
1833 if !visited.insert(current) {
1834 continue;
1835 }
1836
1837 if depth >= max_depth {
1838 continue;
1839 }
1840
1841 if let Some(neighbors) = adj.get(¤t) {
1842 for &neighbor in neighbors {
1843 depths.entry(neighbor).or_insert(depth + 1);
1844 queue.push_back((neighbor, depth + 1));
1845 }
1846 }
1847 }
1848
1849 let filtered_nodes: Vec<_> = subgraph
1851 .nodes
1852 .iter()
1853 .filter(|n| depths.get(n).is_some_and(|&d| d <= max_depth))
1854 .copied()
1855 .collect();
1856
1857 let filtered_node_set: HashSet<_> = filtered_nodes.iter().copied().collect();
1858
1859 let filtered_edges: Vec<_> = subgraph
1861 .edges
1862 .iter()
1863 .filter(|(from, to, _)| filtered_node_set.contains(from) && filtered_node_set.contains(to))
1864 .map(|&(from, to, ref kind)| (from, to, kind.clone()))
1865 .collect();
1866
1867 UnifiedSubGraph {
1868 nodes: filtered_nodes,
1869 edges: filtered_edges,
1870 }
1871}
1872
1873fn filter_cycles_only_unified(subgraph: &UnifiedSubGraph) -> UnifiedSubGraph {
1875 let adj = build_adjacency_unified(&subgraph.edges);
1876 let in_cycle = collect_cycle_nodes_unified(&subgraph.nodes, &adj);
1877 let filtered_nodes: Vec<_> = subgraph
1878 .nodes
1879 .iter()
1880 .filter(|n| in_cycle.contains(n))
1881 .copied()
1882 .collect();
1883
1884 let node_set: HashSet<_> = filtered_nodes.iter().copied().collect();
1885 let filtered_edges = filter_edges_by_nodes_unified(&subgraph.edges, &node_set);
1886
1887 UnifiedSubGraph {
1888 nodes: filtered_nodes,
1889 edges: filtered_edges,
1890 }
1891}
1892
1893fn build_adjacency_unified(
1894 edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
1895) -> HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> {
1896 let mut adj: HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> = HashMap::new();
1897 for &(from, to, _) in edges {
1898 adj.entry(from).or_default().push(to);
1899 }
1900 adj
1901}
1902
1903fn collect_cycle_nodes_unified(
1904 nodes: &[UnifiedNodeId],
1905 adj: &HashMap<UnifiedNodeId, Vec<UnifiedNodeId>>,
1906) -> HashSet<UnifiedNodeId> {
1907 let mut in_cycle = HashSet::new();
1908 let mut visited = HashSet::new();
1909 let mut rec_stack = HashSet::new();
1910
1911 for &node in nodes {
1912 if !visited.contains(&node) {
1913 let mut path = Vec::new();
1914 dfs_cycles_unified(
1915 node,
1916 adj,
1917 &mut visited,
1918 &mut rec_stack,
1919 &mut in_cycle,
1920 &mut path,
1921 );
1922 }
1923 }
1924
1925 in_cycle
1926}
1927
1928fn dfs_cycles_unified(
1929 node: UnifiedNodeId,
1930 adj: &HashMap<UnifiedNodeId, Vec<UnifiedNodeId>>,
1931 visited: &mut HashSet<UnifiedNodeId>,
1932 rec_stack: &mut HashSet<UnifiedNodeId>,
1933 in_cycle: &mut HashSet<UnifiedNodeId>,
1934 path: &mut Vec<UnifiedNodeId>,
1935) {
1936 visited.insert(node);
1937 rec_stack.insert(node);
1938 path.push(node);
1939
1940 if let Some(neighbors) = adj.get(&node) {
1941 for &neighbor in neighbors {
1942 if !visited.contains(&neighbor) {
1943 dfs_cycles_unified(neighbor, adj, visited, rec_stack, in_cycle, path);
1944 } else if rec_stack.contains(&neighbor) {
1945 let cycle_start = path.iter().position(|&n| n == neighbor).unwrap_or(0);
1946 for &cycle_node in &path[cycle_start..] {
1947 in_cycle.insert(cycle_node);
1948 }
1949 in_cycle.insert(neighbor);
1950 }
1951 }
1952 }
1953
1954 path.pop();
1955 rec_stack.remove(&node);
1956}
1957
1958fn filter_edges_by_nodes_unified(
1959 edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
1960 node_set: &HashSet<UnifiedNodeId>,
1961) -> Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)> {
1962 edges
1963 .iter()
1964 .filter(|(from, to, _)| node_set.contains(from) && node_set.contains(to))
1965 .map(|&(from, to, ref kind)| (from, to, kind.clone()))
1966 .collect()
1967}
1968
1969fn print_dependency_tree_unified_text(
1971 subgraph: &UnifiedSubGraph,
1972 snapshot: &UnifiedGraphSnapshot,
1973 cycles_only: bool,
1974 verbose: bool,
1975) {
1976 let title = if cycles_only {
1977 "Dependency Tree (Cycles Only)"
1978 } else {
1979 "Dependency Tree"
1980 };
1981
1982 println!("{title}");
1983 println!("{}", "=".repeat(title.len()));
1984 println!();
1985
1986 let node_count = subgraph.nodes.len();
1988 println!("Nodes ({node_count}):");
1989 for &node_id in &subgraph.nodes {
1990 if let Some(entry) = snapshot.get_node(node_id) {
1991 let name = resolve_node_label(snapshot, entry);
1992 let language = resolve_node_language(snapshot, entry);
1993
1994 if verbose {
1995 let file = resolve_node_file_path(snapshot, entry, true);
1996 let line = entry.start_line;
1997 println!(" {name} ({language}) - {file}:{line}");
1998 } else {
1999 println!(" {name} ({language})");
2000 }
2001 }
2002 }
2003
2004 println!();
2005 let edge_count = subgraph.edges.len();
2006 println!("Edges ({edge_count}):");
2007 for (from_id, to_id, kind) in &subgraph.edges {
2008 let from_name =
2009 resolve_node_label_by_id(snapshot, *from_id).unwrap_or_else(|| "?".to_string());
2010 let to_name = resolve_node_label_by_id(snapshot, *to_id).unwrap_or_else(|| "?".to_string());
2011
2012 println!(" {from_name} --[{kind:?}]--> {to_name}");
2013 }
2014}
2015
2016fn print_dependency_tree_unified_json(
2018 subgraph: &UnifiedSubGraph,
2019 snapshot: &UnifiedGraphSnapshot,
2020 verbose: bool,
2021) -> Result<()> {
2022 use serde_json::json;
2023
2024 let nodes: Vec<_> = subgraph
2025 .nodes
2026 .iter()
2027 .filter_map(|&node_id| {
2028 let entry = snapshot.get_node(node_id)?;
2029 let name = resolve_node_label(snapshot, entry);
2030 let language = resolve_node_language(snapshot, entry);
2031
2032 let mut obj = json!({
2033 "id": format!("{node_id:?}"),
2034 "name": name,
2035 "language": language,
2036 });
2037
2038 if verbose {
2039 let file = resolve_node_file_path(snapshot, entry, true);
2040 obj["file"] = json!(file);
2041 obj["line"] = json!(entry.start_line);
2042 }
2043
2044 Some(obj)
2045 })
2046 .collect();
2047
2048 let edges: Vec<_> = subgraph
2049 .edges
2050 .iter()
2051 .filter_map(|(from_id, to_id, kind)| {
2052 let from_name = resolve_node_label_by_id(snapshot, *from_id)?;
2053 let to_name = resolve_node_label_by_id(snapshot, *to_id)?;
2054
2055 Some(json!({
2056 "from": from_name,
2057 "to": to_name,
2058 "kind": format!("{kind:?}"),
2059 }))
2060 })
2061 .collect();
2062
2063 let output = json!({
2064 "nodes": nodes,
2065 "edges": edges,
2066 "node_count": subgraph.nodes.len(),
2067 "edge_count": subgraph.edges.len(),
2068 });
2069
2070 println!("{}", serde_json::to_string_pretty(&output)?);
2071 Ok(())
2072}
2073
2074type UnifiedCrossLangEdge = (
2078 UnifiedNodeId,
2079 UnifiedNodeId,
2080 UnifiedEdgeKind,
2081 sqry_core::graph::Language, sqry_core::graph::Language, );
2084
2085fn run_cross_language_unified(
2087 graph: &UnifiedCodeGraph,
2088 from_lang: Option<&str>,
2089 to_lang: Option<&str>,
2090 edge_type: Option<&str>,
2091 _min_confidence: f64,
2092 format: &str,
2093 verbose: bool,
2094) -> Result<()> {
2095 let snapshot = graph.snapshot();
2096
2097 let from_language = from_lang.map(parse_language).transpose()?;
2099 let to_language = to_lang.map(parse_language).transpose()?;
2100
2101 let mut cross_lang_edges: Vec<UnifiedCrossLangEdge> = Vec::new();
2103
2104 for (src_id, tgt_id, kind) in snapshot.iter_edges() {
2105 let (src_lang, tgt_lang) = match (snapshot.get_node(src_id), snapshot.get_node(tgt_id)) {
2107 (Some(src_entry), Some(tgt_entry)) => {
2108 let src_l = snapshot.files().language_for_file(src_entry.file);
2109 let tgt_l = snapshot.files().language_for_file(tgt_entry.file);
2110 match (src_l, tgt_l) {
2111 (Some(s), Some(t)) => (s, t),
2112 _ => continue,
2113 }
2114 }
2115 _ => continue,
2116 };
2117
2118 if src_lang == tgt_lang {
2120 continue;
2121 }
2122
2123 if let Some(filter_lang) = from_language
2125 && src_lang != filter_lang
2126 {
2127 continue;
2128 }
2129
2130 if let Some(filter_lang) = to_language
2132 && tgt_lang != filter_lang
2133 {
2134 continue;
2135 }
2136
2137 if let Some(kind_str) = edge_type
2139 && !edge_kind_matches_unified(&kind, kind_str)
2140 {
2141 continue;
2142 }
2143
2144 cross_lang_edges.push((src_id, tgt_id, kind.clone(), src_lang, tgt_lang));
2145 }
2146
2147 match format {
2152 "json" => print_cross_language_unified_json(&cross_lang_edges, &snapshot, verbose)?,
2153 _ => print_cross_language_unified_text(&cross_lang_edges, &snapshot, verbose),
2154 }
2155
2156 Ok(())
2157}
2158
2159fn edge_kind_matches_unified(kind: &UnifiedEdgeKind, filter: &str) -> bool {
2161 let kind_str = format!("{kind:?}").to_lowercase();
2162 let filter_lower = filter.to_lowercase();
2163 kind_str.contains(&filter_lower)
2164}
2165
2166fn print_cross_language_unified_text(
2168 edges: &[UnifiedCrossLangEdge],
2169 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2170 verbose: bool,
2171) {
2172 println!("Cross-Language Relationships (Unified Graph)");
2173 println!("=============================================");
2174 println!();
2175 let edge_count = edges.len();
2176 println!("Found {edge_count} cross-language edges");
2177 println!();
2178
2179 for (src_id, tgt_id, kind, src_lang, tgt_lang) in edges {
2180 let src_name = snapshot
2181 .get_node(*src_id)
2182 .and_then(|e| {
2183 e.qualified_name
2184 .and_then(|id| snapshot.strings().resolve(id))
2185 .or_else(|| snapshot.strings().resolve(e.name))
2186 })
2187 .map_or_else(|| "?".to_string(), |s| s.to_string());
2188
2189 let tgt_name = snapshot
2190 .get_node(*tgt_id)
2191 .and_then(|e| {
2192 e.qualified_name
2193 .and_then(|id| snapshot.strings().resolve(id))
2194 .or_else(|| snapshot.strings().resolve(e.name))
2195 })
2196 .map_or_else(|| "?".to_string(), |s| s.to_string());
2197
2198 println!(" {src_lang:?} → {tgt_lang:?}");
2199 println!(" {src_name} → {tgt_name}");
2200 println!(" Kind: {kind:?}");
2201
2202 if verbose
2203 && let (Some(src_entry), Some(tgt_entry)) =
2204 (snapshot.get_node(*src_id), snapshot.get_node(*tgt_id))
2205 {
2206 let src_file = snapshot.files().resolve(src_entry.file).map_or_else(
2207 || "unknown".to_string(),
2208 |p| p.to_string_lossy().to_string(),
2209 );
2210 let tgt_file = snapshot.files().resolve(tgt_entry.file).map_or_else(
2211 || "unknown".to_string(),
2212 |p| p.to_string_lossy().to_string(),
2213 );
2214 let src_line = src_entry.start_line;
2215 let tgt_line = tgt_entry.start_line;
2216 println!(" From: {src_file}:{src_line}");
2217 println!(" To: {tgt_file}:{tgt_line}");
2218 }
2219
2220 println!();
2221 }
2222}
2223
2224fn print_cross_language_unified_json(
2226 edges: &[UnifiedCrossLangEdge],
2227 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2228 verbose: bool,
2229) -> Result<()> {
2230 use serde_json::{Value, json};
2231
2232 let items: Vec<_> = edges
2233 .iter()
2234 .filter_map(|(src_id, tgt_id, kind, src_lang, tgt_lang)| {
2235 let src_entry = snapshot.get_node(*src_id)?;
2236 let tgt_entry = snapshot.get_node(*tgt_id)?;
2237
2238 let src_name = src_entry
2239 .qualified_name
2240 .and_then(|id| snapshot.strings().resolve(id))
2241 .or_else(|| snapshot.strings().resolve(src_entry.name))
2242 .map_or_else(|| "?".to_string(), |s| s.to_string());
2243
2244 let tgt_name = tgt_entry
2245 .qualified_name
2246 .and_then(|id| snapshot.strings().resolve(id))
2247 .or_else(|| snapshot.strings().resolve(tgt_entry.name))
2248 .map_or_else(|| "?".to_string(), |s| s.to_string());
2249
2250 let mut obj = json!({
2251 "from": {
2252 "symbol": src_name,
2253 "language": format!("{src_lang:?}")
2254 },
2255 "to": {
2256 "symbol": tgt_name,
2257 "language": format!("{tgt_lang:?}")
2258 },
2259 "kind": format!("{kind:?}"),
2260 });
2261
2262 if verbose {
2263 let src_file = snapshot.files().resolve(src_entry.file).map_or_else(
2264 || "unknown".to_string(),
2265 |p| p.to_string_lossy().to_string(),
2266 );
2267 let tgt_file = snapshot.files().resolve(tgt_entry.file).map_or_else(
2268 || "unknown".to_string(),
2269 |p| p.to_string_lossy().to_string(),
2270 );
2271
2272 obj["from"]["file"] = Value::from(src_file);
2273 obj["from"]["line"] = Value::from(src_entry.start_line);
2274 obj["to"]["file"] = Value::from(tgt_file);
2275 obj["to"]["line"] = Value::from(tgt_entry.start_line);
2276 }
2277
2278 Some(obj)
2279 })
2280 .collect();
2281
2282 let output = json!({
2283 "edges": items,
2284 "count": edges.len()
2285 });
2286
2287 println!("{}", serde_json::to_string_pretty(&output)?);
2288 Ok(())
2289}
2290
2291const DEFAULT_GRAPH_LIST_LIMIT: usize = 1000;
2294const MAX_GRAPH_LIST_LIMIT: usize = 10_000;
2295
2296struct PaginationOptions {
2298 limit: usize,
2299 offset: usize,
2300}
2301
2302struct OutputOptions<'a> {
2304 full_paths: bool,
2305 format: &'a str,
2306 verbose: bool,
2307}
2308
2309struct NodeFilterOptions<'a> {
2311 kind: Option<&'a str>,
2312 languages: Option<&'a str>,
2313 file: Option<&'a str>,
2314 name: Option<&'a str>,
2315 qualified_name: Option<&'a str>,
2316}
2317
2318struct EdgeFilterOptions<'a> {
2320 kind: Option<&'a str>,
2321 from: Option<&'a str>,
2322 to: Option<&'a str>,
2323 from_lang: Option<&'a str>,
2324 to_lang: Option<&'a str>,
2325 file: Option<&'a str>,
2326}
2327
2328fn run_nodes_unified(
2330 graph: &UnifiedCodeGraph,
2331 root: &Path,
2332 filters: &NodeFilterOptions<'_>,
2333 pagination: &PaginationOptions,
2334 output: &OutputOptions<'_>,
2335) -> Result<()> {
2336 let snapshot = graph.snapshot();
2337 let kind_filter = parse_node_kind_filter(filters.kind)?;
2338 let language_filter = parse_language_filter(filters.languages)?
2339 .into_iter()
2340 .collect::<HashSet<_>>();
2341 let file_filter = filters.file.map(normalize_filter_input);
2342 let effective_limit = normalize_graph_limit(pagination.limit);
2343 let show_full_paths = output.full_paths || output.verbose;
2344
2345 let mut matches = Vec::new();
2346 for (node_id, entry) in snapshot.iter_nodes() {
2347 if entry.is_unified_loser() {
2350 continue;
2351 }
2352 if !kind_filter.is_empty() && !kind_filter.contains(&entry.kind) {
2353 continue;
2354 }
2355
2356 if !language_filter.is_empty() {
2357 let Some(lang) = snapshot.files().language_for_file(entry.file) else {
2358 continue;
2359 };
2360 if !language_filter.contains(&lang) {
2361 continue;
2362 }
2363 }
2364
2365 if let Some(filter) = file_filter.as_deref()
2366 && !file_filter_matches(&snapshot, entry.file, root, filter)
2367 {
2368 continue;
2369 }
2370
2371 if let Some(filter) = filters.name
2372 && !resolve_node_name(&snapshot, entry).contains(filter)
2373 {
2374 continue;
2375 }
2376
2377 if let Some(filter) = filters.qualified_name {
2378 let Some(qualified) = resolve_optional_string(&snapshot, entry.qualified_name) else {
2379 continue;
2380 };
2381 if !qualified.contains(filter) {
2382 continue;
2383 }
2384 }
2385
2386 matches.push(node_id);
2387 }
2388
2389 let total = matches.len();
2390 let start = pagination.offset.min(total);
2391 let end = (start + effective_limit).min(total);
2392 let truncated = total > start + effective_limit;
2393 let page = &matches[start..end];
2394 let page_info = ListPage::new(total, effective_limit, pagination.offset, truncated);
2395 let render_paths = RenderPaths::new(root, show_full_paths);
2396
2397 if output.format == "json" {
2398 print_nodes_unified_json(&snapshot, page, &page_info, &render_paths)
2399 } else {
2400 print_nodes_unified_text(&snapshot, page, &page_info, &render_paths, output.verbose);
2401 Ok(())
2402 }
2403}
2404
2405fn run_edges_unified(
2407 graph: &UnifiedCodeGraph,
2408 root: &Path,
2409 filters: &EdgeFilterOptions<'_>,
2410 pagination: &PaginationOptions,
2411 output: &OutputOptions<'_>,
2412) -> Result<()> {
2413 let snapshot = graph.snapshot();
2414 let kind_filter = parse_edge_kind_filter(filters.kind)?;
2415 let from_language = filters.from_lang.map(parse_language).transpose()?;
2416 let to_language = filters.to_lang.map(parse_language).transpose()?;
2417 let file_filter = filters.file.map(normalize_filter_input);
2418 let effective_limit = normalize_graph_limit(pagination.limit);
2419 let show_full_paths = output.full_paths || output.verbose;
2420
2421 let mut matches = Vec::new();
2422 for (src_id, tgt_id, kind) in snapshot.iter_edges() {
2423 if !kind_filter.is_empty() && !kind_filter.contains(kind.tag()) {
2424 continue;
2425 }
2426
2427 let (Some(src_entry), Some(tgt_entry)) =
2428 (snapshot.get_node(src_id), snapshot.get_node(tgt_id))
2429 else {
2430 continue;
2431 };
2432
2433 if let Some(filter_lang) = from_language {
2434 let Some(lang) = snapshot.files().language_for_file(src_entry.file) else {
2435 continue;
2436 };
2437 if lang != filter_lang {
2438 continue;
2439 }
2440 }
2441
2442 if let Some(filter_lang) = to_language {
2443 let Some(lang) = snapshot.files().language_for_file(tgt_entry.file) else {
2444 continue;
2445 };
2446 if lang != filter_lang {
2447 continue;
2448 }
2449 }
2450
2451 if let Some(filter) = filters.from
2452 && !node_label_matches(&snapshot, src_entry, filter)
2453 {
2454 continue;
2455 }
2456
2457 if let Some(filter) = filters.to
2458 && !node_label_matches(&snapshot, tgt_entry, filter)
2459 {
2460 continue;
2461 }
2462
2463 if let Some(filter) = file_filter.as_deref()
2464 && !file_filter_matches(&snapshot, src_entry.file, root, filter)
2465 {
2466 continue;
2467 }
2468
2469 matches.push((src_id, tgt_id, kind));
2470 }
2471
2472 let total = matches.len();
2473 let start = pagination.offset.min(total);
2474 let end = (start + effective_limit).min(total);
2475 let truncated = total > start + effective_limit;
2476 let page = &matches[start..end];
2477 let page_info = ListPage::new(total, effective_limit, pagination.offset, truncated);
2478 let render_paths = RenderPaths::new(root, show_full_paths);
2479
2480 if output.format == "json" {
2481 print_edges_unified_json(&snapshot, page, &page_info, &render_paths)
2482 } else {
2483 print_edges_unified_text(&snapshot, page, &page_info, &render_paths, output.verbose);
2484 Ok(())
2485 }
2486}
2487
2488fn print_nodes_unified_text(
2489 snapshot: &UnifiedGraphSnapshot,
2490 nodes: &[UnifiedNodeId],
2491 page: &ListPage,
2492 paths: &RenderPaths<'_>,
2493 verbose: bool,
2494) {
2495 println!("Graph Nodes (Unified Graph)");
2496 println!("===========================");
2497 println!();
2498 let shown = nodes.len();
2499 println!(
2500 "Found {total} node(s). Showing {shown} (offset {offset}, limit {limit}).",
2501 total = page.total,
2502 offset = page.offset,
2503 limit = page.limit
2504 );
2505 if page.truncated {
2506 println!("Results truncated. Use --limit/--offset to page.");
2507 }
2508 println!();
2509
2510 for (index, node_id) in nodes.iter().enumerate() {
2511 let Some(entry) = snapshot.get_node(*node_id) else {
2512 continue;
2513 };
2514 let display_index = page.offset + index + 1;
2515 let name = resolve_node_name(snapshot, entry);
2516 let qualified = resolve_optional_string(snapshot, entry.qualified_name);
2517 let language = resolve_node_language_text(snapshot, entry);
2518 let kind = entry.kind.as_str();
2519 let file = render_file_path(snapshot, entry.file, paths.root, paths.full_paths);
2520
2521 println!("{display_index}. {name} ({kind}, {language})");
2522 println!(
2523 " File: {file}:{}:{}",
2524 entry.start_line, entry.start_column
2525 );
2526 if let Some(qualified) = qualified.as_ref()
2527 && qualified != &name
2528 {
2529 println!(" Qualified: {qualified}");
2530 }
2531
2532 if verbose {
2533 println!(" Id: {}", format_node_id(*node_id));
2534 if let Some(signature) = resolve_optional_string(snapshot, entry.signature) {
2535 println!(" Signature: {signature}");
2536 }
2537 if let Some(visibility) = resolve_optional_string(snapshot, entry.visibility) {
2538 println!(" Visibility: {visibility}");
2539 }
2540 println!(
2541 " Location: {}:{}-{}:{}",
2542 entry.start_line, entry.start_column, entry.end_line, entry.end_column
2543 );
2544 println!(" Byte range: {}-{}", entry.start_byte, entry.end_byte);
2545 println!(
2546 " Flags: async={}, static={}",
2547 entry.is_async, entry.is_static
2548 );
2549 if let Some(doc) = resolve_optional_string(snapshot, entry.doc) {
2550 let condensed = condense_whitespace(&doc);
2551 println!(" Doc: {condensed}");
2552 }
2553 }
2554
2555 println!();
2556 }
2557}
2558
2559fn print_nodes_unified_json(
2560 snapshot: &UnifiedGraphSnapshot,
2561 nodes: &[UnifiedNodeId],
2562 page: &ListPage,
2563 paths: &RenderPaths<'_>,
2564) -> Result<()> {
2565 use serde_json::json;
2566
2567 let items: Vec<_> = nodes
2568 .iter()
2569 .filter_map(|node_id| {
2570 let entry = snapshot.get_node(*node_id)?;
2571 let name = resolve_node_name(snapshot, entry);
2572 let qualified = resolve_optional_string(snapshot, entry.qualified_name);
2573 let language = resolve_node_language_json(snapshot, entry);
2574 let file = render_file_path(snapshot, entry.file, paths.root, paths.full_paths);
2575 let signature = resolve_optional_string(snapshot, entry.signature);
2576 let doc = resolve_optional_string(snapshot, entry.doc);
2577 let visibility = resolve_optional_string(snapshot, entry.visibility);
2578
2579 Some(json!({
2580 "id": node_id_json(*node_id),
2581 "name": name,
2582 "qualified_name": qualified,
2583 "kind": entry.kind.as_str(),
2584 "language": language,
2585 "file": file,
2586 "location": {
2587 "start_line": entry.start_line,
2588 "start_column": entry.start_column,
2589 "end_line": entry.end_line,
2590 "end_column": entry.end_column,
2591 },
2592 "byte_range": {
2593 "start": entry.start_byte,
2594 "end": entry.end_byte,
2595 },
2596 "signature": signature,
2597 "doc": doc,
2598 "visibility": visibility,
2599 "is_async": entry.is_async,
2600 "is_static": entry.is_static,
2601 }))
2602 })
2603 .collect();
2604
2605 let output = json!({
2606 "count": page.total,
2607 "limit": page.limit,
2608 "offset": page.offset,
2609 "truncated": page.truncated,
2610 "nodes": items,
2611 });
2612
2613 println!("{}", serde_json::to_string_pretty(&output)?);
2614 Ok(())
2615}
2616
2617fn print_edges_unified_text(
2618 snapshot: &UnifiedGraphSnapshot,
2619 edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
2620 page: &ListPage,
2621 paths: &RenderPaths<'_>,
2622 verbose: bool,
2623) {
2624 println!("Graph Edges (Unified Graph)");
2625 println!("===========================");
2626 println!();
2627 let shown = edges.len();
2628 println!(
2629 "Found {total} edge(s). Showing {shown} (offset {offset}, limit {limit}).",
2630 total = page.total,
2631 offset = page.offset,
2632 limit = page.limit
2633 );
2634 if page.truncated {
2635 println!("Results truncated. Use --limit/--offset to page.");
2636 }
2637 println!();
2638
2639 for (index, (src_id, tgt_id, kind)) in edges.iter().enumerate() {
2640 let (Some(src_entry), Some(tgt_entry)) =
2641 (snapshot.get_node(*src_id), snapshot.get_node(*tgt_id))
2642 else {
2643 continue;
2644 };
2645 let display_index = page.offset + index + 1;
2646 let src_name = resolve_node_label(snapshot, src_entry);
2647 let tgt_name = resolve_node_label(snapshot, tgt_entry);
2648 let src_lang = resolve_node_language_text(snapshot, src_entry);
2649 let tgt_lang = resolve_node_language_text(snapshot, tgt_entry);
2650 let file = render_file_path(snapshot, src_entry.file, paths.root, paths.full_paths);
2651
2652 println!("{display_index}. {src_name} ({src_lang}) → {tgt_name} ({tgt_lang})");
2653 println!(" Kind: {}", kind.tag());
2654 println!(" File: {file}");
2655
2656 if verbose {
2657 println!(
2658 " Source: {}:{}:{}",
2659 file, src_entry.start_line, src_entry.start_column
2660 );
2661 let target_file =
2662 render_file_path(snapshot, tgt_entry.file, paths.root, paths.full_paths);
2663 println!(
2664 " Target: {}:{}:{}",
2665 target_file, tgt_entry.start_line, tgt_entry.start_column
2666 );
2667 println!(" Source Id: {}", format_node_id(*src_id));
2668 println!(" Target Id: {}", format_node_id(*tgt_id));
2669 print_edge_metadata_text(snapshot, kind);
2670 }
2671
2672 println!();
2673 }
2674}
2675
2676fn print_edges_unified_json(
2677 snapshot: &UnifiedGraphSnapshot,
2678 edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
2679 page: &ListPage,
2680 paths: &RenderPaths<'_>,
2681) -> Result<()> {
2682 use serde_json::json;
2683
2684 let items: Vec<_> = edges
2685 .iter()
2686 .filter_map(|(src_id, tgt_id, kind)| {
2687 let src_entry = snapshot.get_node(*src_id)?;
2688 let tgt_entry = snapshot.get_node(*tgt_id)?;
2689 let file = render_file_path(snapshot, src_entry.file, paths.root, paths.full_paths);
2690
2691 Some(json!({
2692 "source": node_ref_json(snapshot, *src_id, src_entry, paths.root, paths.full_paths),
2693 "target": node_ref_json(snapshot, *tgt_id, tgt_entry, paths.root, paths.full_paths),
2694 "kind": kind.tag(),
2695 "file": file,
2696 "metadata": edge_metadata_json(snapshot, kind),
2697 }))
2698 })
2699 .collect();
2700
2701 let output = json!({
2702 "count": page.total,
2703 "limit": page.limit,
2704 "offset": page.offset,
2705 "truncated": page.truncated,
2706 "edges": items,
2707 });
2708
2709 println!("{}", serde_json::to_string_pretty(&output)?);
2710 Ok(())
2711}
2712
2713type UnifiedComplexityResult = (UnifiedNodeId, usize);
2717
2718fn run_complexity_unified(
2720 graph: &UnifiedCodeGraph,
2721 target: Option<&str>,
2722 sort: bool,
2723 min_complexity: usize,
2724 languages: Option<&str>,
2725 format: &str,
2726 verbose: bool,
2727) -> Result<()> {
2728 let snapshot = graph.snapshot();
2729
2730 let language_list = parse_language_filter_for_complexity(languages)?;
2732 let language_filter: HashSet<_> = language_list.into_iter().collect();
2733
2734 let mut complexities =
2736 calculate_complexity_metrics_unified(&snapshot, target, &language_filter);
2737
2738 complexities.retain(|(_, score)| *score >= min_complexity);
2740
2741 if sort {
2743 complexities.sort_by(|a, b| b.1.cmp(&a.1));
2744 }
2745
2746 if verbose {
2747 eprintln!(
2748 "Analyzed {} functions (min_complexity={})",
2749 complexities.len(),
2750 min_complexity
2751 );
2752 }
2753
2754 match format {
2755 "json" => print_complexity_unified_json(&complexities, &snapshot)?,
2756 _ => print_complexity_unified_text(&complexities, &snapshot),
2757 }
2758
2759 Ok(())
2760}
2761
2762fn parse_language_filter_for_complexity(languages: Option<&str>) -> Result<Vec<Language>> {
2764 if let Some(langs) = languages {
2765 langs.split(',').map(|s| parse_language(s.trim())).collect()
2766 } else {
2767 Ok(Vec::new())
2768 }
2769}
2770
2771fn calculate_complexity_metrics_unified(
2773 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2774 target: Option<&str>,
2775 language_filter: &HashSet<Language>,
2776) -> Vec<UnifiedComplexityResult> {
2777 use sqry_core::graph::unified::node::NodeKind as UnifiedNodeKind;
2778
2779 let mut complexities = Vec::new();
2780
2781 for (node_id, entry) in snapshot.iter_nodes() {
2782 if entry.is_unified_loser() {
2785 continue;
2786 }
2787 if !node_matches_language_filter(snapshot, entry, language_filter) {
2788 continue;
2789 }
2790
2791 if !matches!(
2792 entry.kind,
2793 UnifiedNodeKind::Function | UnifiedNodeKind::Method
2794 ) {
2795 continue;
2796 }
2797
2798 if !node_matches_target(snapshot, entry, target) {
2799 continue;
2800 }
2801
2802 let score = calculate_complexity_score_unified(snapshot, node_id);
2804 complexities.push((node_id, score));
2805 }
2806
2807 complexities
2808}
2809
2810fn node_matches_language_filter(
2811 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2812 entry: &NodeEntry,
2813 language_filter: &HashSet<Language>,
2814) -> bool {
2815 if language_filter.is_empty() {
2816 return true;
2817 }
2818
2819 let Some(lang) = snapshot.files().language_for_file(entry.file) else {
2820 return false;
2821 };
2822 language_filter.contains(&lang)
2823}
2824
2825fn node_matches_target(
2826 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2827 entry: &NodeEntry,
2828 target: Option<&str>,
2829) -> bool {
2830 let Some(target_name) = target else {
2831 return true;
2832 };
2833
2834 let name = entry
2835 .qualified_name
2836 .and_then(|id| snapshot.strings().resolve(id))
2837 .or_else(|| snapshot.strings().resolve(entry.name))
2838 .map_or_else(String::new, |s| s.to_string());
2839
2840 name.contains(target_name)
2841}
2842
2843fn calculate_complexity_score_unified(
2845 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2846 node_id: UnifiedNodeId,
2847) -> usize {
2848 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKindEnum;
2849
2850 let mut call_count = 0;
2852 let mut max_depth = 0;
2853
2854 for edge_ref in snapshot.edges().edges_from(node_id) {
2856 if matches!(edge_ref.kind, UnifiedEdgeKindEnum::Calls { .. }) {
2857 call_count += 1;
2858
2859 let depth = calculate_call_depth_unified(snapshot, edge_ref.target, 1);
2861 max_depth = max_depth.max(depth);
2862 }
2863 }
2864
2865 call_count + max_depth
2867}
2868
2869fn calculate_call_depth_unified(
2871 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2872 node_id: UnifiedNodeId,
2873 current_depth: usize,
2874) -> usize {
2875 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKindEnum;
2876
2877 const MAX_DEPTH: usize = 20; if current_depth >= MAX_DEPTH {
2880 return current_depth;
2881 }
2882
2883 let mut max_child_depth = current_depth;
2884
2885 for edge_ref in snapshot.edges().edges_from(node_id) {
2886 if matches!(edge_ref.kind, UnifiedEdgeKindEnum::Calls { .. }) {
2887 let child_depth =
2888 calculate_call_depth_unified(snapshot, edge_ref.target, current_depth + 1);
2889 max_child_depth = max_child_depth.max(child_depth);
2890 }
2891 }
2892
2893 max_child_depth
2894}
2895
2896fn print_complexity_unified_text(
2898 complexities: &[UnifiedComplexityResult],
2899 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2900) {
2901 println!("Code Complexity Metrics (Unified Graph)");
2902 println!("=======================================");
2903 println!();
2904 let complexity_count = complexities.len();
2905 println!("Analyzed {complexity_count} functions");
2906 println!();
2907
2908 if complexities.is_empty() {
2909 println!("No functions found matching the criteria.");
2910 return;
2911 }
2912
2913 let scores: Vec<_> = complexities.iter().map(|(_, score)| *score).collect();
2915 let total: usize = scores.iter().sum();
2916 #[allow(clippy::cast_precision_loss)] let avg = total as f64 / scores.len() as f64;
2918 let max = *scores.iter().max().unwrap_or(&0);
2919
2920 println!("Statistics:");
2921 println!(" Average complexity: {avg:.1}");
2922 println!(" Maximum complexity: {max}");
2923 println!();
2924
2925 println!("Functions by complexity:");
2926 for (node_id, score) in complexities {
2927 let bars = "█".repeat((*score).min(50));
2928
2929 let (name, file, lang_str) = if let Some(entry) = snapshot.get_node(*node_id) {
2930 let n = entry
2931 .qualified_name
2932 .and_then(|id| snapshot.strings().resolve(id))
2933 .or_else(|| snapshot.strings().resolve(entry.name))
2934 .map_or_else(|| "?".to_string(), |s| s.to_string());
2935
2936 let f = snapshot.files().resolve(entry.file).map_or_else(
2937 || "unknown".to_string(),
2938 |p| p.to_string_lossy().to_string(),
2939 );
2940
2941 let l = snapshot
2942 .files()
2943 .language_for_file(entry.file)
2944 .map_or_else(|| "Unknown".to_string(), |lang| format!("{lang:?}"));
2945
2946 (n, f, l)
2947 } else {
2948 (
2949 "?".to_string(),
2950 "unknown".to_string(),
2951 "Unknown".to_string(),
2952 )
2953 };
2954
2955 println!(" {bars} {score:3} {lang_str}:{file}:{name}");
2956 }
2957}
2958
2959fn print_complexity_unified_json(
2961 complexities: &[UnifiedComplexityResult],
2962 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2963) -> Result<()> {
2964 use serde_json::json;
2965
2966 let items: Vec<_> = complexities
2967 .iter()
2968 .filter_map(|(node_id, score)| {
2969 let entry = snapshot.get_node(*node_id)?;
2970
2971 let name = entry
2972 .qualified_name
2973 .and_then(|id| snapshot.strings().resolve(id))
2974 .or_else(|| snapshot.strings().resolve(entry.name))
2975 .map_or_else(|| "?".to_string(), |s| s.to_string());
2976
2977 let file = snapshot.files().resolve(entry.file).map_or_else(
2978 || "unknown".to_string(),
2979 |p| p.to_string_lossy().to_string(),
2980 );
2981
2982 let language = snapshot
2983 .files()
2984 .language_for_file(entry.file)
2985 .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
2986
2987 Some(json!({
2988 "symbol": name,
2989 "file": file,
2990 "language": language,
2991 "complexity": score,
2992 }))
2993 })
2994 .collect();
2995
2996 let output = json!({
2997 "function_count": complexities.len(),
2998 "functions": items,
2999 });
3000
3001 println!("{}", serde_json::to_string_pretty(&output)?);
3002 Ok(())
3003}
3004
3005const VALID_NODE_KIND_NAMES: &[&str] = &[
3008 "function",
3009 "method",
3010 "class",
3011 "interface",
3012 "trait",
3013 "module",
3014 "variable",
3015 "constant",
3016 "type",
3017 "struct",
3018 "enum",
3019 "enum_variant",
3020 "macro",
3021 "call_site",
3022 "import",
3023 "export",
3024 "lifetime",
3025 "component",
3026 "service",
3027 "resource",
3028 "endpoint",
3029 "test",
3030 "other",
3031];
3032
3033const VALID_EDGE_KIND_TAGS: &[&str] = &[
3034 "defines",
3035 "contains",
3036 "calls",
3037 "references",
3038 "imports",
3039 "exports",
3040 "type_of",
3041 "inherits",
3042 "implements",
3043 "lifetime_constraint",
3044 "trait_method_binding",
3045 "macro_expansion",
3046 "ffi_call",
3047 "http_request",
3048 "grpc_call",
3049 "web_assembly_call",
3050 "db_query",
3051 "table_read",
3052 "table_write",
3053 "triggered_by",
3054 "message_queue",
3055 "web_socket",
3056 "graphql_operation",
3057 "process_exec",
3058 "file_ipc",
3059 "protocol_call",
3060];
3061
3062struct ListPage {
3063 total: usize,
3064 limit: usize,
3065 offset: usize,
3066 truncated: bool,
3067}
3068
3069impl ListPage {
3070 fn new(total: usize, limit: usize, offset: usize, truncated: bool) -> Self {
3071 Self {
3072 total,
3073 limit,
3074 offset,
3075 truncated,
3076 }
3077 }
3078}
3079
3080struct RenderPaths<'a> {
3081 root: &'a Path,
3082 full_paths: bool,
3083}
3084
3085impl<'a> RenderPaths<'a> {
3086 fn new(root: &'a Path, full_paths: bool) -> Self {
3087 Self { root, full_paths }
3088 }
3089}
3090
3091fn normalize_graph_limit(limit: usize) -> usize {
3092 if limit == 0 {
3093 DEFAULT_GRAPH_LIST_LIMIT
3094 } else {
3095 limit.min(MAX_GRAPH_LIST_LIMIT)
3096 }
3097}
3098
3099fn normalize_filter_input(input: &str) -> String {
3100 input.trim().replace('\\', "/").to_ascii_lowercase()
3101}
3102
3103fn normalize_path_for_match(path: &Path) -> String {
3104 path.to_string_lossy()
3105 .replace('\\', "/")
3106 .to_ascii_lowercase()
3107}
3108
3109fn file_filter_matches(
3110 snapshot: &UnifiedGraphSnapshot,
3111 file_id: sqry_core::graph::unified::FileId,
3112 root: &Path,
3113 filter: &str,
3114) -> bool {
3115 let Some(path) = snapshot.files().resolve(file_id) else {
3116 return false;
3117 };
3118 let normalized = normalize_path_for_match(&path);
3119 if normalized.contains(filter) {
3120 return true;
3121 }
3122
3123 if let Ok(relative) = path.strip_prefix(root) {
3124 let normalized_relative = normalize_path_for_match(relative);
3125 if normalized_relative.contains(filter) {
3126 return true;
3127 }
3128 }
3129
3130 false
3131}
3132
3133fn render_file_path(
3134 snapshot: &UnifiedGraphSnapshot,
3135 file_id: sqry_core::graph::unified::FileId,
3136 root: &Path,
3137 full_paths: bool,
3138) -> String {
3139 snapshot.files().resolve(file_id).map_or_else(
3140 || "unknown".to_string(),
3141 |path| {
3142 if full_paths {
3143 path.to_string_lossy().to_string()
3144 } else if let Ok(relative) = path.strip_prefix(root) {
3145 relative.to_string_lossy().to_string()
3146 } else {
3147 path.to_string_lossy().to_string()
3148 }
3149 },
3150 )
3151}
3152
3153fn resolve_optional_string(
3154 snapshot: &UnifiedGraphSnapshot,
3155 value: Option<StringId>,
3156) -> Option<String> {
3157 value
3158 .and_then(|id| snapshot.strings().resolve(id))
3159 .map(|s| s.to_string())
3160}
3161
3162fn resolve_node_language_text(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
3163 snapshot
3164 .files()
3165 .language_for_file(entry.file)
3166 .map_or_else(|| "Unknown".to_string(), |lang| format!("{lang:?}"))
3167}
3168
3169fn resolve_node_language_json(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
3170 snapshot
3171 .files()
3172 .language_for_file(entry.file)
3173 .map_or_else(|| "unknown".to_string(), |lang| lang.to_string())
3174}
3175
3176fn node_label_matches(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry, filter: &str) -> bool {
3177 let name = resolve_node_name(snapshot, entry);
3178 if name.contains(filter) {
3179 return true;
3180 }
3181
3182 if let Some(qualified) = resolve_optional_string(snapshot, entry.qualified_name)
3183 && qualified.contains(filter)
3184 {
3185 return true;
3186 }
3187
3188 false
3189}
3190
3191fn condense_whitespace(value: &str) -> String {
3192 value.split_whitespace().collect::<Vec<_>>().join(" ")
3193}
3194
3195fn format_node_id(node_id: UnifiedNodeId) -> String {
3196 format!(
3197 "index={}, generation={}",
3198 node_id.index(),
3199 node_id.generation()
3200 )
3201}
3202
3203fn node_id_json(node_id: UnifiedNodeId) -> serde_json::Value {
3204 use serde_json::json;
3205
3206 json!({
3207 "index": node_id.index(),
3208 "generation": node_id.generation(),
3209 })
3210}
3211
3212fn node_ref_json(
3213 snapshot: &UnifiedGraphSnapshot,
3214 node_id: UnifiedNodeId,
3215 entry: &NodeEntry,
3216 root: &Path,
3217 full_paths: bool,
3218) -> serde_json::Value {
3219 use serde_json::json;
3220
3221 let name = resolve_node_name(snapshot, entry);
3222 let qualified = resolve_optional_string(snapshot, entry.qualified_name);
3223 let language = resolve_node_language_json(snapshot, entry);
3224 let file = render_file_path(snapshot, entry.file, root, full_paths);
3225
3226 json!({
3227 "id": node_id_json(node_id),
3228 "name": name,
3229 "qualified_name": qualified,
3230 "language": language,
3231 "file": file,
3232 "location": {
3233 "start_line": entry.start_line,
3234 "start_column": entry.start_column,
3235 "end_line": entry.end_line,
3236 "end_column": entry.end_column,
3237 },
3238 })
3239}
3240
3241fn resolve_string_id(snapshot: &UnifiedGraphSnapshot, id: StringId) -> Option<String> {
3242 snapshot.strings().resolve(id).map(|s| s.to_string())
3243}
3244
3245#[allow(clippy::too_many_lines)] fn edge_metadata_json(
3247 snapshot: &UnifiedGraphSnapshot,
3248 kind: &UnifiedEdgeKind,
3249) -> serde_json::Value {
3250 use serde_json::json;
3251
3252 match kind {
3253 UnifiedEdgeKind::Defines
3254 | UnifiedEdgeKind::Contains
3255 | UnifiedEdgeKind::References
3256 | UnifiedEdgeKind::TypeOf { .. }
3257 | UnifiedEdgeKind::Inherits
3258 | UnifiedEdgeKind::Implements
3259 | UnifiedEdgeKind::WebAssemblyCall
3260 | UnifiedEdgeKind::GenericBound
3261 | UnifiedEdgeKind::AnnotatedWith
3262 | UnifiedEdgeKind::AnnotationParam
3263 | UnifiedEdgeKind::LambdaCaptures
3264 | UnifiedEdgeKind::ModuleExports
3265 | UnifiedEdgeKind::ModuleRequires
3266 | UnifiedEdgeKind::ModuleOpens
3267 | UnifiedEdgeKind::ModuleProvides
3268 | UnifiedEdgeKind::TypeArgument
3269 | UnifiedEdgeKind::ExtensionReceiver
3270 | UnifiedEdgeKind::CompanionOf
3271 | UnifiedEdgeKind::SealedPermit => json!({}),
3272 UnifiedEdgeKind::Calls {
3273 argument_count,
3274 is_async,
3275 ..
3276 } => json!({
3277 "argument_count": argument_count,
3278 "is_async": is_async,
3279 }),
3280 UnifiedEdgeKind::Imports { alias, is_wildcard } => json!({
3281 "alias": alias.and_then(|id| resolve_string_id(snapshot, id)),
3282 "is_wildcard": is_wildcard,
3283 }),
3284 UnifiedEdgeKind::Exports { kind, alias } => json!({
3285 "kind": kind,
3286 "alias": alias.and_then(|id| resolve_string_id(snapshot, id)),
3287 }),
3288 UnifiedEdgeKind::LifetimeConstraint { constraint_kind } => json!({
3289 "constraint_kind": constraint_kind,
3290 }),
3291 UnifiedEdgeKind::TraitMethodBinding {
3292 trait_name,
3293 impl_type,
3294 is_ambiguous,
3295 } => json!({
3296 "trait_name": resolve_string_id(snapshot, *trait_name),
3297 "impl_type": resolve_string_id(snapshot, *impl_type),
3298 "is_ambiguous": is_ambiguous,
3299 }),
3300 UnifiedEdgeKind::MacroExpansion {
3301 expansion_kind,
3302 is_verified,
3303 } => json!({
3304 "expansion_kind": expansion_kind,
3305 "is_verified": is_verified,
3306 }),
3307 UnifiedEdgeKind::FfiCall { convention } => json!({
3308 "convention": convention,
3309 }),
3310 UnifiedEdgeKind::HttpRequest { method, url } => json!({
3311 "method": method,
3312 "url": url.and_then(|id| resolve_string_id(snapshot, id)),
3313 }),
3314 UnifiedEdgeKind::GrpcCall { service, method } => json!({
3315 "service": resolve_string_id(snapshot, *service),
3316 "method": resolve_string_id(snapshot, *method),
3317 }),
3318 UnifiedEdgeKind::DbQuery { query_type, table } => json!({
3319 "query_type": query_type,
3320 "table": table.and_then(|id| resolve_string_id(snapshot, id)),
3321 }),
3322 UnifiedEdgeKind::TableRead { table_name, schema } => json!({
3323 "table_name": resolve_string_id(snapshot, *table_name),
3324 "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3325 }),
3326 UnifiedEdgeKind::TableWrite {
3327 table_name,
3328 schema,
3329 operation,
3330 } => json!({
3331 "table_name": resolve_string_id(snapshot, *table_name),
3332 "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3333 "operation": operation,
3334 }),
3335 UnifiedEdgeKind::TriggeredBy {
3336 trigger_name,
3337 schema,
3338 } => json!({
3339 "trigger_name": resolve_string_id(snapshot, *trigger_name),
3340 "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3341 }),
3342 UnifiedEdgeKind::MessageQueue { protocol, topic } => {
3343 let protocol_value = match protocol {
3344 MqProtocol::Kafka => Some("kafka".to_string()),
3345 MqProtocol::Sqs => Some("sqs".to_string()),
3346 MqProtocol::RabbitMq => Some("rabbit_mq".to_string()),
3347 MqProtocol::Nats => Some("nats".to_string()),
3348 MqProtocol::Redis => Some("redis".to_string()),
3349 MqProtocol::Other(id) => resolve_string_id(snapshot, *id),
3350 };
3351 json!({
3352 "protocol": protocol_value,
3353 "topic": topic.and_then(|id| resolve_string_id(snapshot, id)),
3354 })
3355 }
3356 UnifiedEdgeKind::WebSocket { event } => json!({
3357 "event": event.and_then(|id| resolve_string_id(snapshot, id)),
3358 }),
3359 UnifiedEdgeKind::GraphQLOperation { operation } => json!({
3360 "operation": resolve_string_id(snapshot, *operation),
3361 }),
3362 UnifiedEdgeKind::ProcessExec { command } => json!({
3363 "command": resolve_string_id(snapshot, *command),
3364 }),
3365 UnifiedEdgeKind::FileIpc { path_pattern } => json!({
3366 "path_pattern": path_pattern.and_then(|id| resolve_string_id(snapshot, id)),
3367 }),
3368 UnifiedEdgeKind::ProtocolCall { protocol, metadata } => json!({
3369 "protocol": resolve_string_id(snapshot, *protocol),
3370 "metadata": metadata.and_then(|id| resolve_string_id(snapshot, id)),
3371 }),
3372 UnifiedEdgeKind::Wraps {
3373 kind,
3374 chain_position,
3375 } => json!({
3376 "kind": kind,
3377 "chain_position": chain_position,
3378 }),
3379 UnifiedEdgeKind::ChannelPeer {
3380 direction,
3381 buffer_kind,
3382 } => json!({
3383 "direction": direction,
3384 "buffer_kind": buffer_kind,
3385 }),
3386 UnifiedEdgeKind::Instantiates {
3387 type_args,
3388 inference_kind,
3389 } => json!({
3390 "type_args": type_args
3391 .iter()
3392 .map(|ta| json!({
3393 "name": resolve_string_id(snapshot, ta.name),
3394 "default_typed": ta.default_typed,
3395 }))
3396 .collect::<Vec<_>>(),
3397 "inference_kind": inference_kind,
3398 }),
3399 }
3400}
3401
3402fn print_edge_metadata_text(snapshot: &UnifiedGraphSnapshot, kind: &UnifiedEdgeKind) {
3403 let metadata = edge_metadata_json(snapshot, kind);
3404 let Some(map) = metadata.as_object() else {
3405 return;
3406 };
3407 if map.is_empty() {
3408 return;
3409 }
3410 if let Ok(serialized) = serde_json::to_string(map) {
3411 println!(" Metadata: {serialized}");
3412 }
3413}
3414
3415fn parse_node_kind_filter(kinds: Option<&str>) -> Result<HashSet<UnifiedNodeKind>> {
3416 let mut filter = HashSet::new();
3417 let Some(kinds) = kinds else {
3418 return Ok(filter);
3419 };
3420 for raw in kinds.split(',') {
3421 let trimmed = raw.trim();
3422 if trimmed.is_empty() {
3423 continue;
3424 }
3425 let normalized = trimmed.to_ascii_lowercase();
3426 let Some(kind) = UnifiedNodeKind::parse(&normalized) else {
3427 return Err(anyhow::anyhow!(
3428 "Unknown node kind: {trimmed}. Valid kinds: {}",
3429 VALID_NODE_KIND_NAMES.join(", ")
3430 ));
3431 };
3432 filter.insert(kind);
3433 }
3434 Ok(filter)
3435}
3436
3437fn parse_edge_kind_filter(kinds: Option<&str>) -> Result<HashSet<String>> {
3438 let mut filter = HashSet::new();
3439 let Some(kinds) = kinds else {
3440 return Ok(filter);
3441 };
3442 for raw in kinds.split(',') {
3443 let trimmed = raw.trim();
3444 if trimmed.is_empty() {
3445 continue;
3446 }
3447 let normalized = trimmed.to_ascii_lowercase();
3448 if !VALID_EDGE_KIND_TAGS.contains(&normalized.as_str()) {
3449 return Err(anyhow::anyhow!(
3450 "Unknown edge kind: {trimmed}. Valid kinds: {}",
3451 VALID_EDGE_KIND_TAGS.join(", ")
3452 ));
3453 }
3454 filter.insert(normalized);
3455 }
3456 Ok(filter)
3457}
3458
3459fn display_languages(languages: &HashSet<Language>) -> String {
3460 let mut items: Vec<Language> = languages.iter().copied().collect();
3461 items.sort();
3462 items
3463 .into_iter()
3464 .map(|lang| lang.to_string())
3465 .collect::<Vec<_>>()
3466 .join(", ")
3467}
3468
3469fn parse_language_filter(languages: Option<&str>) -> Result<Vec<Language>> {
3470 if let Some(langs) = languages {
3471 langs.split(',').map(|s| parse_language(s.trim())).collect()
3472 } else {
3473 Ok(Vec::new())
3474 }
3475}
3476
3477fn parse_language(s: &str) -> Result<Language> {
3478 match s.to_lowercase().as_str() {
3479 "javascript" | "js" => Ok(Language::JavaScript),
3481 "typescript" | "ts" => Ok(Language::TypeScript),
3482 "python" | "py" => Ok(Language::Python),
3483 "cpp" | "c++" | "cxx" => Ok(Language::Cpp),
3484 "rust" | "rs" => Ok(Language::Rust),
3486 "go" => Ok(Language::Go),
3487 "java" => Ok(Language::Java),
3488 "c" => Ok(Language::C),
3489 "csharp" | "cs" => Ok(Language::CSharp),
3490 "ruby" => Ok(Language::Ruby),
3492 "php" => Ok(Language::Php),
3493 "swift" => Ok(Language::Swift),
3494 "kotlin" => Ok(Language::Kotlin),
3496 "scala" => Ok(Language::Scala),
3497 "sql" => Ok(Language::Sql),
3498 "dart" => Ok(Language::Dart),
3499 "lua" => Ok(Language::Lua),
3501 "perl" => Ok(Language::Perl),
3502 "shell" | "bash" => Ok(Language::Shell),
3503 "groovy" => Ok(Language::Groovy),
3504 "elixir" | "ex" => Ok(Language::Elixir),
3506 "r" => Ok(Language::R),
3507 "haskell" | "hs" => Ok(Language::Haskell),
3509 "svelte" => Ok(Language::Svelte),
3510 "vue" => Ok(Language::Vue),
3511 "zig" => Ok(Language::Zig),
3512 "http" => Ok(Language::Http),
3514 _ => bail!("Unknown language: {s}"),
3515 }
3516}
3517
3518struct DirectCallOptions<'a> {
3522 symbol: &'a str,
3524 limit: usize,
3526 languages: Option<&'a str>,
3528 full_paths: bool,
3530 format: &'a str,
3532 verbose: bool,
3534}
3535
3536fn direct_call_row(
3538 snapshot: &UnifiedGraphSnapshot,
3539 root: &Path,
3540 node_id: sqry_core::graph::unified::node::NodeId,
3541 full_paths: bool,
3542) -> Option<serde_json::Value> {
3543 use serde_json::json;
3544 let entry = snapshot.nodes().get(node_id)?;
3545 let strings = snapshot.strings();
3546 let files = snapshot.files();
3547 let name = strings.resolve(entry.name).unwrap_or_default().to_string();
3548 let qualified_name = entry
3549 .qualified_name
3550 .and_then(|id| strings.resolve(id))
3551 .map_or_else(|| name.clone(), |s| s.to_string());
3552 let language = files
3553 .language_for_file(entry.file)
3554 .map_or_else(|| "unknown".to_string(), |l| l.to_string());
3555 let file_path = files
3556 .resolve(entry.file)
3557 .map(|p| {
3558 if full_paths {
3559 p.display().to_string()
3560 } else {
3561 p.strip_prefix(root)
3562 .unwrap_or(p.as_ref())
3563 .display()
3564 .to_string()
3565 }
3566 })
3567 .unwrap_or_default();
3568 Some(json!({
3569 "name": name,
3570 "qualified_name": qualified_name,
3571 "kind": format!("{:?}", entry.kind),
3572 "file": file_path,
3573 "line": entry.start_line,
3574 "language": language,
3575 }))
3576}
3577
3578fn emit_direct_call_output(
3580 symbol: &str,
3581 key: &'static str,
3582 label_noun: &'static str,
3583 rows: &[serde_json::Value],
3584 limit: usize,
3585 format: &str,
3586) -> Result<()> {
3587 use serde_json::json;
3588 if format == "json" {
3589 let output = json!({
3590 "symbol": symbol,
3591 key: rows,
3592 "total": rows.len(),
3593 "truncated": rows.len() >= limit,
3594 });
3595 println!("{}", serde_json::to_string_pretty(&output)?);
3596 } else {
3597 println!("{label_noun}s of '{symbol}':");
3598 println!();
3599 if rows.is_empty() {
3600 println!(" (no {label_noun}s found)");
3601 } else {
3602 for row in rows {
3603 let name = row["qualified_name"].as_str().unwrap_or("");
3604 let file = row["file"].as_str().unwrap_or("");
3605 let line = row["line"].as_u64().unwrap_or(0);
3606 println!(" {name} ({file}:{line})");
3607 }
3608 println!();
3609 println!("Total: {total} {label_noun}(s)", total = rows.len());
3610 }
3611 }
3612 Ok(())
3613}
3614
3615fn run_direct_callers_unified(
3651 graph: &UnifiedCodeGraph,
3652 root: &Path,
3653 options: &DirectCallOptions<'_>,
3654) -> Result<()> {
3655 let snapshot = std::sync::Arc::new(graph.snapshot());
3656 let files = snapshot.files();
3657
3658 let language_filter = parse_language_filter(options.languages)?
3659 .into_iter()
3660 .collect::<HashSet<_>>();
3661
3662 let target_nodes = find_nodes_by_name(&snapshot, options.symbol);
3666 if target_nodes.is_empty() {
3667 bail!(
3668 "Symbol '{symbol}' not found in the graph",
3669 symbol = options.symbol
3670 );
3671 }
3672
3673 if options.verbose {
3674 eprintln!(
3675 "Found {count} node(s) matching symbol '{symbol}'",
3676 count = target_nodes.len(),
3677 symbol = options.symbol
3678 );
3679 }
3680
3681 let db = sqry_db::queries::dispatch::make_query_db_cold(std::sync::Arc::clone(&snapshot), root);
3685 let key = sqry_db::queries::RelationKey::exact(options.symbol);
3686 let caller_ids = sqry_db::queries::dispatch::mcp_callers_query(&db, &key);
3687
3688 let mut rows = Vec::new();
3690 for &caller_id in caller_ids.iter() {
3691 if rows.len() >= options.limit {
3692 break;
3693 }
3694 let Some(entry) = snapshot.nodes().get(caller_id) else {
3695 continue;
3696 };
3697 if !language_filter.is_empty()
3698 && let Some(lang) = files.language_for_file(entry.file)
3699 && !language_filter.contains(&lang)
3700 {
3701 continue;
3702 }
3703 if let Some(row) = direct_call_row(&snapshot, root, caller_id, options.full_paths) {
3704 rows.push(row);
3705 }
3706 }
3707
3708 emit_direct_call_output(
3709 options.symbol,
3710 "callers",
3711 "caller",
3712 &rows,
3713 options.limit,
3714 options.format,
3715 )
3716}
3717
3718fn run_direct_callees_unified(
3744 graph: &UnifiedCodeGraph,
3745 root: &Path,
3746 options: &DirectCallOptions<'_>,
3747) -> Result<()> {
3748 let snapshot = std::sync::Arc::new(graph.snapshot());
3749 let files = snapshot.files();
3750
3751 let language_filter = parse_language_filter(options.languages)?
3752 .into_iter()
3753 .collect::<HashSet<_>>();
3754
3755 let source_nodes = find_nodes_by_name(&snapshot, options.symbol);
3757 if source_nodes.is_empty() {
3758 bail!(
3759 "Symbol '{symbol}' not found in the graph",
3760 symbol = options.symbol
3761 );
3762 }
3763
3764 if options.verbose {
3765 eprintln!(
3766 "Found {count} node(s) matching symbol '{symbol}'",
3767 count = source_nodes.len(),
3768 symbol = options.symbol
3769 );
3770 }
3771
3772 let db = sqry_db::queries::dispatch::make_query_db_cold(std::sync::Arc::clone(&snapshot), root);
3776 let key = sqry_db::queries::RelationKey::exact(options.symbol);
3777 let callee_ids = sqry_db::queries::dispatch::mcp_callees_query(&db, &key);
3778
3779 let mut rows = Vec::new();
3781 for &callee_id in callee_ids.iter() {
3782 if rows.len() >= options.limit {
3783 break;
3784 }
3785 let Some(entry) = snapshot.nodes().get(callee_id) else {
3786 continue;
3787 };
3788 if !language_filter.is_empty()
3789 && let Some(lang) = files.language_for_file(entry.file)
3790 && !language_filter.contains(&lang)
3791 {
3792 continue;
3793 }
3794 if let Some(row) = direct_call_row(&snapshot, root, callee_id, options.full_paths) {
3795 rows.push(row);
3796 }
3797 }
3798
3799 emit_direct_call_output(
3800 options.symbol,
3801 "callees",
3802 "callee",
3803 &rows,
3804 options.limit,
3805 options.format,
3806 )
3807}
3808
3809struct CallHierarchyOptions<'a> {
3813 symbol: &'a str,
3815 max_depth: usize,
3817 direction: &'a str,
3819 languages: Option<&'a str>,
3821 full_paths: bool,
3823 format: &'a str,
3825 verbose: bool,
3827}
3828
3829fn run_call_hierarchy_unified(
3831 graph: &UnifiedCodeGraph,
3832 root: &Path,
3833 options: &CallHierarchyOptions<'_>,
3834) -> Result<()> {
3835 use serde_json::json;
3836
3837 let snapshot = graph.snapshot();
3838
3839 let language_filter = parse_language_filter(options.languages)?
3840 .into_iter()
3841 .collect::<HashSet<_>>();
3842
3843 let start_nodes = find_nodes_by_name(&snapshot, options.symbol);
3845
3846 if start_nodes.is_empty() {
3847 bail!("Symbol '{}' not found in the graph", options.symbol);
3848 }
3849
3850 if options.verbose {
3851 eprintln!(
3852 "Found {} node(s) matching symbol '{}' (direction={})",
3853 start_nodes.len(),
3854 options.symbol,
3855 options.direction
3856 );
3857 }
3858
3859 let include_incoming = options.direction == "incoming" || options.direction == "both";
3860 let include_outgoing = options.direction == "outgoing" || options.direction == "both";
3861
3862 let mut result = json!({
3863 "symbol": options.symbol,
3864 "direction": options.direction,
3865 "max_depth": options.max_depth
3866 });
3867
3868 if include_incoming {
3870 let incoming = build_call_hierarchy_tree(
3871 &snapshot,
3872 &start_nodes,
3873 options.max_depth,
3874 true, &language_filter,
3876 root,
3877 options.full_paths,
3878 );
3879 result["incoming"] = incoming;
3880 }
3881
3882 if include_outgoing {
3884 let outgoing = build_call_hierarchy_tree(
3885 &snapshot,
3886 &start_nodes,
3887 options.max_depth,
3888 false, &language_filter,
3890 root,
3891 options.full_paths,
3892 );
3893 result["outgoing"] = outgoing;
3894 }
3895
3896 if options.format == "json" {
3897 println!("{}", serde_json::to_string_pretty(&result)?);
3898 } else {
3899 println!("Call hierarchy for '{symbol}':", symbol = options.symbol);
3900 println!();
3901
3902 if include_incoming {
3903 println!("Incoming calls (callers):");
3904 if let Some(incoming) = result["incoming"].as_array() {
3905 print_hierarchy_text(incoming, 1);
3906 }
3907 println!();
3908 }
3909
3910 if include_outgoing {
3911 println!("Outgoing calls (callees):");
3912 if let Some(outgoing) = result["outgoing"].as_array() {
3913 print_hierarchy_text(outgoing, 1);
3914 }
3915 }
3916 }
3917
3918 Ok(())
3919}
3920
3921#[allow(clippy::items_after_statements, clippy::too_many_lines)]
3923fn build_call_hierarchy_tree(
3924 snapshot: &UnifiedGraphSnapshot,
3925 start_nodes: &[sqry_core::graph::unified::node::NodeId],
3926 max_depth: usize,
3927 incoming: bool,
3928 language_filter: &HashSet<Language>,
3929 root: &Path,
3930 full_paths: bool,
3931) -> serde_json::Value {
3932 use serde_json::json;
3933 use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
3934
3935 let _strings = snapshot.strings();
3936 let _files = snapshot.files();
3937
3938 let mut result = Vec::new();
3939 let mut visited = HashSet::new();
3940
3941 struct TraversalConfig<'a> {
3943 max_depth: usize,
3944 incoming: bool,
3945 language_filter: &'a HashSet<Language>,
3946 root: &'a Path,
3947 full_paths: bool,
3948 }
3949
3950 fn traverse(
3951 snapshot: &UnifiedGraphSnapshot,
3952 node_id: UnifiedNodeId,
3953 depth: usize,
3954 config: &TraversalConfig<'_>,
3955 visited: &mut HashSet<UnifiedNodeId>,
3956 ) -> serde_json::Value {
3957 let strings = snapshot.strings();
3958 let files = snapshot.files();
3959
3960 let Some(entry) = snapshot.nodes().get(node_id) else {
3961 return json!(null);
3962 };
3963
3964 let name = strings.resolve(entry.name).unwrap_or_default().to_string();
3965 let qualified_name = entry
3966 .qualified_name
3967 .and_then(|id| strings.resolve(id))
3968 .map_or_else(|| name.clone(), |s| s.to_string());
3969 let language = files
3970 .language_for_file(entry.file)
3971 .map_or_else(|| "unknown".to_string(), |l| l.to_string());
3972 let file_path = files
3973 .resolve(entry.file)
3974 .map(|p| {
3975 if config.full_paths {
3976 p.display().to_string()
3977 } else {
3978 p.strip_prefix(config.root)
3979 .unwrap_or(p.as_ref())
3980 .display()
3981 .to_string()
3982 }
3983 })
3984 .unwrap_or_default();
3985
3986 let mut node_json = json!({
3987 "name": name,
3988 "qualified_name": qualified_name,
3989 "kind": format!("{:?}", entry.kind),
3990 "file": file_path,
3991 "line": entry.start_line,
3992 "language": language
3993 });
3994
3995 if depth < config.max_depth && !visited.contains(&node_id) {
3997 visited.insert(node_id);
3998
3999 let mut children = Vec::new();
4000 let edges = if config.incoming {
4001 snapshot.edges().reverse().edges_from(node_id)
4002 } else {
4003 snapshot.edges().edges_from(node_id)
4004 };
4005
4006 for edge_ref in edges {
4007 if !matches!(edge_ref.kind, UnifiedEdgeKind::Calls { .. }) {
4008 continue;
4009 }
4010
4011 let related_id = edge_ref.target;
4012
4013 if !config.language_filter.is_empty()
4015 && let Some(related_entry) = snapshot.nodes().get(related_id)
4016 && let Some(lang) = files.language_for_file(related_entry.file)
4017 && !config.language_filter.contains(&lang)
4018 {
4019 continue;
4020 }
4021
4022 let child = traverse(snapshot, related_id, depth + 1, config, visited);
4023
4024 if !child.is_null() {
4025 children.push(child);
4026 }
4027 }
4028
4029 if !children.is_empty() {
4030 node_json["children"] = json!(children);
4031 }
4032 }
4033
4034 node_json
4035 }
4036
4037 let config = TraversalConfig {
4038 max_depth,
4039 incoming,
4040 language_filter,
4041 root,
4042 full_paths,
4043 };
4044
4045 for &node_id in start_nodes {
4046 let tree = traverse(snapshot, node_id, 0, &config, &mut visited);
4047 if !tree.is_null() {
4048 result.push(tree);
4049 }
4050 }
4051
4052 json!(result)
4053}
4054
4055fn print_hierarchy_text(nodes: &[serde_json::Value], indent: usize) {
4057 let prefix = " ".repeat(indent);
4058 for node in nodes {
4059 let name = node["qualified_name"].as_str().unwrap_or("?");
4060 let file = node["file"].as_str().unwrap_or("?");
4061 let line = node["line"].as_u64().unwrap_or(0);
4062 println!("{prefix}{name} ({file}:{line})");
4063
4064 if let Some(children) = node["children"].as_array() {
4065 print_hierarchy_text(children, indent + 1);
4066 }
4067 }
4068}
4069
4070fn run_is_in_cycle_unified(
4097 graph: &UnifiedCodeGraph,
4098 root: &Path,
4099 symbol: &str,
4100 cycle_type: &str,
4101 show_cycle: bool,
4102 format: &str,
4103 verbose: bool,
4104) -> Result<()> {
4105 use serde_json::json;
4106 use sqry_core::graph::unified::{
4107 FileScope, ResolutionMode, SymbolQuery, SymbolResolutionOutcome,
4108 };
4109 use sqry_core::query::CircularType;
4110 use std::sync::Arc;
4111
4112 let cycle_types: Vec<CircularType> = if cycle_type.eq_ignore_ascii_case("all") {
4119 vec![CircularType::Calls, CircularType::Imports]
4120 } else {
4121 let parsed = CircularType::try_parse(cycle_type).with_context(|| {
4122 format!("Invalid cycle type: {cycle_type}. Use: calls, imports, modules, all")
4123 })?;
4124 vec![parsed]
4125 };
4126
4127 let snapshot = Arc::new(graph.snapshot());
4128
4129 let target_id = match snapshot.resolve_symbol(&SymbolQuery {
4133 symbol,
4134 file_scope: FileScope::Any,
4135 mode: ResolutionMode::Strict,
4136 }) {
4137 SymbolResolutionOutcome::Resolved(node_id) => node_id,
4138 SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
4139 bail!("Symbol '{symbol}' not found in the graph");
4140 }
4141 SymbolResolutionOutcome::Ambiguous(candidates) => {
4142 bail!(
4143 "Symbol '{symbol}' is ambiguous ({} candidates). Use a canonical qualified name.",
4144 candidates.len()
4145 );
4146 }
4147 };
4148
4149 if verbose {
4150 eprintln!(
4151 "Checking if symbol '{}' ({:?}) is in a {} cycle",
4152 symbol, target_id, cycle_type
4153 );
4154 }
4155
4156 let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), root);
4169 let predicate_bounds = sqry_db::queries::CycleBounds {
4170 min_depth: 2,
4171 max_depth: None,
4172 max_results: 100,
4173 should_include_self_loops: false,
4174 };
4175 let mut in_cycle = false;
4176 let mut found_cycles: Vec<serde_json::Value> = Vec::new();
4177 for &ct in &cycle_types {
4178 if db.get::<sqry_db::queries::IsInCycleQuery>(&sqry_db::queries::IsInCycleKey {
4179 node_id: target_id,
4180 circular_type: ct,
4181 bounds: predicate_bounds,
4182 }) {
4183 in_cycle = true;
4184 if show_cycle {
4185 let cycle_lookup_bounds = sqry_db::queries::CycleBounds {
4186 min_depth: 2,
4187 max_depth: None,
4188 max_results: usize::MAX,
4189 should_include_self_loops: false,
4190 };
4191 let all_cycles =
4192 db.get::<sqry_db::queries::CyclesQuery>(&sqry_db::queries::CyclesKey {
4193 circular_type: ct,
4194 bounds: cycle_lookup_bounds,
4195 });
4196 if let Some(component) = all_cycles
4197 .iter()
4198 .find(|component| component.contains(&target_id))
4199 {
4200 let strings = snapshot.strings();
4201 let cycle_names: Vec<String> = component
4202 .iter()
4203 .filter_map(|&node_id| {
4204 snapshot.get_node(node_id).and_then(|entry| {
4205 entry
4206 .qualified_name
4207 .and_then(|id| strings.resolve(id))
4208 .or_else(|| strings.resolve(entry.name))
4209 .map(|s| s.to_string())
4210 })
4211 })
4212 .collect();
4213 found_cycles.push(json!({
4214 "node": format!("{target_id:?}"),
4215 "cycle": cycle_names
4216 }));
4217 }
4218 }
4219 }
4220 }
4221
4222 if format == "json" {
4223 let output = if show_cycle {
4224 json!({
4225 "symbol": symbol,
4226 "in_cycle": in_cycle,
4227 "cycle_type": cycle_type,
4228 "cycles": found_cycles
4229 })
4230 } else {
4231 json!({
4232 "symbol": symbol,
4233 "in_cycle": in_cycle,
4234 "cycle_type": cycle_type
4235 })
4236 };
4237 println!("{}", serde_json::to_string_pretty(&output)?);
4238 } else if in_cycle {
4239 println!("Symbol '{symbol}' IS in a {cycle_type} cycle.");
4240 if show_cycle {
4241 for (i, cycle) in found_cycles.iter().enumerate() {
4242 println!();
4243 println!("Cycle {}:", i + 1);
4244 if let Some(names) = cycle["cycle"].as_array() {
4245 for (j, name) in names.iter().enumerate() {
4246 let prefix = if j == 0 { " " } else { " → " };
4247 println!("{prefix}{name}", name = name.as_str().unwrap_or("?"));
4248 }
4249 if let Some(first) = names.first() {
4251 println!(" → {} (cycle)", first.as_str().unwrap_or("?"));
4252 }
4253 }
4254 }
4255 }
4256 } else {
4257 println!("Symbol '{symbol}' is NOT in any {cycle_type} cycle.");
4258 }
4259
4260 Ok(())
4261}
4262
4263#[cfg(test)]
4264mod tests {
4265 use super::*;
4266 use sqry_core::graph::unified::edge::ResolvedVia;
4267
4268 #[test]
4273 fn test_parse_language_javascript_variants() {
4274 assert_eq!(parse_language("javascript").unwrap(), Language::JavaScript);
4275 assert_eq!(parse_language("js").unwrap(), Language::JavaScript);
4276 assert_eq!(parse_language("JavaScript").unwrap(), Language::JavaScript);
4277 assert_eq!(parse_language("JS").unwrap(), Language::JavaScript);
4278 }
4279
4280 #[test]
4281 fn test_parse_language_typescript_variants() {
4282 assert_eq!(parse_language("typescript").unwrap(), Language::TypeScript);
4283 assert_eq!(parse_language("ts").unwrap(), Language::TypeScript);
4284 assert_eq!(parse_language("TypeScript").unwrap(), Language::TypeScript);
4285 }
4286
4287 #[test]
4288 fn test_parse_language_python_variants() {
4289 assert_eq!(parse_language("python").unwrap(), Language::Python);
4290 assert_eq!(parse_language("py").unwrap(), Language::Python);
4291 assert_eq!(parse_language("PYTHON").unwrap(), Language::Python);
4292 }
4293
4294 #[test]
4295 fn test_parse_language_cpp_variants() {
4296 assert_eq!(parse_language("cpp").unwrap(), Language::Cpp);
4297 assert_eq!(parse_language("c++").unwrap(), Language::Cpp);
4298 assert_eq!(parse_language("cxx").unwrap(), Language::Cpp);
4299 assert_eq!(parse_language("CPP").unwrap(), Language::Cpp);
4300 }
4301
4302 #[test]
4303 fn test_parse_language_rust_variants() {
4304 assert_eq!(parse_language("rust").unwrap(), Language::Rust);
4305 assert_eq!(parse_language("rs").unwrap(), Language::Rust);
4306 }
4307
4308 #[test]
4309 fn test_parse_language_go() {
4310 assert_eq!(parse_language("go").unwrap(), Language::Go);
4311 assert_eq!(parse_language("Go").unwrap(), Language::Go);
4312 }
4313
4314 #[test]
4315 fn test_parse_language_java() {
4316 assert_eq!(parse_language("java").unwrap(), Language::Java);
4317 }
4318
4319 #[test]
4320 fn test_parse_language_c() {
4321 assert_eq!(parse_language("c").unwrap(), Language::C);
4322 assert_eq!(parse_language("C").unwrap(), Language::C);
4323 }
4324
4325 #[test]
4326 fn test_parse_language_csharp_variants() {
4327 assert_eq!(parse_language("csharp").unwrap(), Language::CSharp);
4328 assert_eq!(parse_language("cs").unwrap(), Language::CSharp);
4329 assert_eq!(parse_language("CSharp").unwrap(), Language::CSharp);
4330 }
4331
4332 #[test]
4333 fn test_parse_language_ruby() {
4334 assert_eq!(parse_language("ruby").unwrap(), Language::Ruby);
4335 }
4336
4337 #[test]
4338 fn test_parse_language_php() {
4339 assert_eq!(parse_language("php").unwrap(), Language::Php);
4340 }
4341
4342 #[test]
4343 fn test_parse_language_swift() {
4344 assert_eq!(parse_language("swift").unwrap(), Language::Swift);
4345 }
4346
4347 #[test]
4348 fn test_parse_language_kotlin() {
4349 assert_eq!(parse_language("kotlin").unwrap(), Language::Kotlin);
4350 }
4351
4352 #[test]
4353 fn test_parse_language_scala() {
4354 assert_eq!(parse_language("scala").unwrap(), Language::Scala);
4355 }
4356
4357 #[test]
4358 fn test_parse_language_sql() {
4359 assert_eq!(parse_language("sql").unwrap(), Language::Sql);
4360 }
4361
4362 #[test]
4363 fn test_parse_language_dart() {
4364 assert_eq!(parse_language("dart").unwrap(), Language::Dart);
4365 }
4366
4367 #[test]
4368 fn test_parse_language_lua() {
4369 assert_eq!(parse_language("lua").unwrap(), Language::Lua);
4370 }
4371
4372 #[test]
4373 fn test_parse_language_perl() {
4374 assert_eq!(parse_language("perl").unwrap(), Language::Perl);
4375 }
4376
4377 #[test]
4378 fn test_parse_language_shell_variants() {
4379 assert_eq!(parse_language("shell").unwrap(), Language::Shell);
4380 assert_eq!(parse_language("bash").unwrap(), Language::Shell);
4381 }
4382
4383 #[test]
4384 fn test_parse_language_groovy() {
4385 assert_eq!(parse_language("groovy").unwrap(), Language::Groovy);
4386 }
4387
4388 #[test]
4389 fn test_parse_language_elixir_variants() {
4390 assert_eq!(parse_language("elixir").unwrap(), Language::Elixir);
4391 assert_eq!(parse_language("ex").unwrap(), Language::Elixir);
4392 }
4393
4394 #[test]
4395 fn test_parse_language_r() {
4396 assert_eq!(parse_language("r").unwrap(), Language::R);
4397 assert_eq!(parse_language("R").unwrap(), Language::R);
4398 }
4399
4400 #[test]
4401 fn test_parse_language_haskell_variants() {
4402 assert_eq!(parse_language("haskell").unwrap(), Language::Haskell);
4403 assert_eq!(parse_language("hs").unwrap(), Language::Haskell);
4404 }
4405
4406 #[test]
4407 fn test_parse_language_svelte() {
4408 assert_eq!(parse_language("svelte").unwrap(), Language::Svelte);
4409 }
4410
4411 #[test]
4412 fn test_parse_language_vue() {
4413 assert_eq!(parse_language("vue").unwrap(), Language::Vue);
4414 }
4415
4416 #[test]
4417 fn test_parse_language_zig() {
4418 assert_eq!(parse_language("zig").unwrap(), Language::Zig);
4419 }
4420
4421 #[test]
4422 fn test_parse_language_http() {
4423 assert_eq!(parse_language("http").unwrap(), Language::Http);
4424 }
4425
4426 #[test]
4427 fn test_parse_language_unknown() {
4428 let result = parse_language("unknown_language");
4429 assert!(result.is_err());
4430 assert!(result.unwrap_err().to_string().contains("Unknown language"));
4431 }
4432
4433 #[test]
4438 fn test_parse_language_filter_none() {
4439 let result = parse_language_filter(None).unwrap();
4440 assert!(result.is_empty());
4441 }
4442
4443 #[test]
4444 fn test_parse_language_filter_single() {
4445 let result = parse_language_filter(Some("rust")).unwrap();
4446 assert_eq!(result.len(), 1);
4447 assert_eq!(result[0], Language::Rust);
4448 }
4449
4450 #[test]
4451 fn test_parse_language_filter_multiple() {
4452 let result = parse_language_filter(Some("rust,python,go")).unwrap();
4453 assert_eq!(result.len(), 3);
4454 assert!(result.contains(&Language::Rust));
4455 assert!(result.contains(&Language::Python));
4456 assert!(result.contains(&Language::Go));
4457 }
4458
4459 #[test]
4460 fn test_parse_language_filter_with_spaces() {
4461 let result = parse_language_filter(Some("rust , python , go")).unwrap();
4462 assert_eq!(result.len(), 3);
4463 }
4464
4465 #[test]
4466 fn test_parse_language_filter_with_aliases() {
4467 let result = parse_language_filter(Some("js,ts,py")).unwrap();
4468 assert_eq!(result.len(), 3);
4469 assert!(result.contains(&Language::JavaScript));
4470 assert!(result.contains(&Language::TypeScript));
4471 assert!(result.contains(&Language::Python));
4472 }
4473
4474 #[test]
4475 fn test_parse_language_filter_invalid() {
4476 let result = parse_language_filter(Some("rust,invalid,python"));
4477 assert!(result.is_err());
4478 }
4479
4480 #[test]
4485 fn test_parse_language_filter_unified_none() {
4486 let result = parse_language_filter_unified(None);
4487 assert!(result.is_empty());
4488 }
4489
4490 #[test]
4491 fn test_parse_language_filter_unified_single() {
4492 let result = parse_language_filter_unified(Some("rust"));
4493 assert_eq!(result.len(), 1);
4494 assert_eq!(result[0], "rust");
4495 }
4496
4497 #[test]
4498 fn test_parse_language_filter_unified_multiple() {
4499 let result = parse_language_filter_unified(Some("rust,python,go"));
4500 assert_eq!(result.len(), 3);
4501 assert!(result.contains(&"rust".to_string()));
4502 assert!(result.contains(&"python".to_string()));
4503 assert!(result.contains(&"go".to_string()));
4504 }
4505
4506 #[test]
4507 fn test_parse_language_filter_unified_with_spaces() {
4508 let result = parse_language_filter_unified(Some(" rust , python "));
4509 assert_eq!(result.len(), 2);
4510 assert!(result.contains(&"rust".to_string()));
4511 assert!(result.contains(&"python".to_string()));
4512 }
4513
4514 #[test]
4519 fn test_parse_language_filter_for_complexity_none() {
4520 let result = parse_language_filter_for_complexity(None).unwrap();
4521 assert!(result.is_empty());
4522 }
4523
4524 #[test]
4525 fn test_parse_language_filter_for_complexity_single() {
4526 let result = parse_language_filter_for_complexity(Some("rust")).unwrap();
4527 assert_eq!(result.len(), 1);
4528 assert_eq!(result[0], Language::Rust);
4529 }
4530
4531 #[test]
4532 fn test_parse_language_filter_for_complexity_multiple() {
4533 let result = parse_language_filter_for_complexity(Some("rust,python")).unwrap();
4534 assert_eq!(result.len(), 2);
4535 }
4536
4537 #[test]
4542 fn test_display_languages_empty() {
4543 let languages: HashSet<Language> = HashSet::new();
4544 assert_eq!(display_languages(&languages), "");
4545 }
4546
4547 #[test]
4548 fn test_display_languages_single() {
4549 let mut languages = HashSet::new();
4550 languages.insert(Language::Rust);
4551 let result = display_languages(&languages);
4552 assert_eq!(result, "rust");
4553 }
4554
4555 #[test]
4556 fn test_display_languages_multiple() {
4557 let mut languages = HashSet::new();
4558 languages.insert(Language::Rust);
4559 languages.insert(Language::Python);
4560 let result = display_languages(&languages);
4561 assert!(result.contains("py"));
4563 assert!(result.contains("rust"));
4564 assert!(result.contains(", "));
4565 }
4566
4567 #[test]
4572 fn test_edge_kind_matches_unified_calls() {
4573 let kind = UnifiedEdgeKind::Calls {
4574 argument_count: 2,
4575 is_async: false,
4576 resolved_via: ResolvedVia::Direct,
4577 };
4578 assert!(edge_kind_matches_unified(&kind, "calls"));
4579 assert!(edge_kind_matches_unified(&kind, "Calls"));
4580 assert!(edge_kind_matches_unified(&kind, "CALLS"));
4581 }
4582
4583 #[test]
4584 fn test_edge_kind_matches_unified_imports() {
4585 let kind = UnifiedEdgeKind::Imports {
4586 alias: None,
4587 is_wildcard: false,
4588 };
4589 assert!(edge_kind_matches_unified(&kind, "imports"));
4590 assert!(edge_kind_matches_unified(&kind, "import"));
4591 }
4592
4593 #[test]
4594 fn test_edge_kind_matches_unified_no_match() {
4595 let kind = UnifiedEdgeKind::Calls {
4596 argument_count: 0,
4597 is_async: false,
4598 resolved_via: ResolvedVia::Direct,
4599 };
4600 assert!(!edge_kind_matches_unified(&kind, "imports"));
4601 assert!(!edge_kind_matches_unified(&kind, "exports"));
4602 }
4603
4604 #[test]
4605 fn test_edge_kind_matches_unified_partial() {
4606 let kind = UnifiedEdgeKind::Calls {
4607 argument_count: 1,
4608 is_async: true,
4609 resolved_via: ResolvedVia::Direct,
4610 };
4611 assert!(edge_kind_matches_unified(&kind, "async"));
4613 }
4614
4615 #[test]
4620 fn test_parse_node_kind_filter_none() {
4621 let result = parse_node_kind_filter(None).unwrap();
4622 assert!(result.is_empty());
4623 }
4624
4625 #[test]
4626 fn test_parse_node_kind_filter_valid() {
4627 let result = parse_node_kind_filter(Some("Function,macro,call_site")).unwrap();
4628 assert_eq!(result.len(), 3);
4629 assert!(result.contains(&UnifiedNodeKind::Function));
4630 assert!(result.contains(&UnifiedNodeKind::Macro));
4631 assert!(result.contains(&UnifiedNodeKind::CallSite));
4632 }
4633
4634 #[test]
4635 fn test_parse_node_kind_filter_invalid() {
4636 let result = parse_node_kind_filter(Some("function,unknown"));
4637 assert!(result.is_err());
4638 }
4639
4640 #[test]
4645 fn test_parse_edge_kind_filter_none() {
4646 let result = parse_edge_kind_filter(None).unwrap();
4647 assert!(result.is_empty());
4648 }
4649
4650 #[test]
4651 fn test_parse_edge_kind_filter_valid() {
4652 let result = parse_edge_kind_filter(Some("calls,table_read,HTTP_REQUEST")).unwrap();
4653 assert!(result.contains("calls"));
4654 assert!(result.contains("table_read"));
4655 assert!(result.contains("http_request"));
4656 }
4657
4658 #[test]
4659 fn test_parse_edge_kind_filter_invalid() {
4660 let result = parse_edge_kind_filter(Some("calls,unknown_edge"));
4661 assert!(result.is_err());
4662 }
4663
4664 #[test]
4669 fn test_normalize_graph_limit_default_on_zero() {
4670 assert_eq!(normalize_graph_limit(0), DEFAULT_GRAPH_LIST_LIMIT);
4671 }
4672
4673 #[test]
4674 fn test_normalize_graph_limit_clamps_max() {
4675 assert_eq!(
4676 normalize_graph_limit(MAX_GRAPH_LIST_LIMIT + 1),
4677 MAX_GRAPH_LIST_LIMIT
4678 );
4679 }
4680
4681 #[test]
4686 fn test_find_path_no_graph_returns_none() {
4687 use sqry_core::graph::unified::concurrent::CodeGraph;
4688 use sqry_core::graph::unified::node::NodeId;
4689
4690 let graph = CodeGraph::new();
4691 let snapshot = graph.snapshot();
4692 let starts = vec![NodeId::new(0, 0)];
4693 let targets: HashSet<NodeId> = [NodeId::new(1, 0)].into_iter().collect();
4694 let filter: HashSet<Language> = HashSet::new();
4695
4696 let path = find_path_unified_bfs(&snapshot, &starts, &targets, &filter);
4697 assert!(path.is_none(), "No path should exist in an empty graph");
4698 }
4699
4700 crate::large_stack_test! {
4705 #[test]
4706 fn test_build_graph_load_config_defaults() {
4707 use clap::Parser as _;
4708 let cli = crate::args::Cli::parse_from(["sqry"]);
4709 let config = build_graph_load_config(&cli);
4710
4711 assert!(!config.include_hidden);
4712 assert!(!config.follow_symlinks);
4713 assert_eq!(config.max_depth, Some(32));
4715 assert!(!config.force_build);
4716 }
4717 }
4718
4719 crate::large_stack_test! {
4720 #[test]
4721 fn test_build_graph_load_config_hidden_flag() {
4722 use clap::Parser as _;
4723 let cli = crate::args::Cli::parse_from(["sqry", "--hidden"]);
4724 let config = build_graph_load_config(&cli);
4725 assert!(config.include_hidden);
4726 }
4727 }
4728
4729 crate::large_stack_test! {
4730 #[test]
4731 fn test_build_graph_load_config_max_depth_nonzero() {
4732 use clap::Parser as _;
4733 let cli = crate::args::Cli::parse_from(["sqry", "--max-depth", "5"]);
4734 let config = build_graph_load_config(&cli);
4735 assert_eq!(config.max_depth, Some(5));
4736 }
4737 }
4738
4739 crate::large_stack_test! {
4740 #[test]
4741 fn test_build_graph_load_config_follow_symlinks() {
4742 use clap::Parser as _;
4743 let cli = crate::args::Cli::parse_from(["sqry", "--follow"]);
4744 let config = build_graph_load_config(&cli);
4745 assert!(config.follow_symlinks);
4746 }
4747 }
4748
4749 #[test]
4754 fn test_language_filter_strategy_empty_filter_allows_all() {
4755 use sqry_core::graph::unified::TraversalStrategy;
4757 use sqry_core::graph::unified::concurrent::CodeGraph;
4758 use sqry_core::graph::unified::edge::{EdgeKind, ResolvedVia};
4759 use sqry_core::graph::unified::node::NodeId;
4760
4761 let graph = CodeGraph::new();
4762 let snapshot = graph.snapshot();
4763 let filter: HashSet<Language> = HashSet::new();
4764
4765 let mut strategy = LanguageFilterStrategy {
4766 snapshot: &snapshot,
4767 language_filter: &filter,
4768 };
4769
4770 let node = NodeId::new(0, 0);
4771 let from = NodeId::new(1, 0);
4772 let edge = EdgeKind::Calls {
4773 argument_count: 0,
4774 is_async: false,
4775 resolved_via: ResolvedVia::Direct,
4776 };
4777 assert!(
4778 strategy.should_enqueue(node, from, &edge, 1),
4779 "Empty language filter must vacuously match any node"
4780 );
4781 }
4782}