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};
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 =
65 load_unified_graph_for_cli(&root, &config, cli).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 } => json!({
3276 "argument_count": argument_count,
3277 "is_async": is_async,
3278 }),
3279 UnifiedEdgeKind::Imports { alias, is_wildcard } => json!({
3280 "alias": alias.and_then(|id| resolve_string_id(snapshot, id)),
3281 "is_wildcard": is_wildcard,
3282 }),
3283 UnifiedEdgeKind::Exports { kind, alias } => json!({
3284 "kind": kind,
3285 "alias": alias.and_then(|id| resolve_string_id(snapshot, id)),
3286 }),
3287 UnifiedEdgeKind::LifetimeConstraint { constraint_kind } => json!({
3288 "constraint_kind": constraint_kind,
3289 }),
3290 UnifiedEdgeKind::TraitMethodBinding {
3291 trait_name,
3292 impl_type,
3293 is_ambiguous,
3294 } => json!({
3295 "trait_name": resolve_string_id(snapshot, *trait_name),
3296 "impl_type": resolve_string_id(snapshot, *impl_type),
3297 "is_ambiguous": is_ambiguous,
3298 }),
3299 UnifiedEdgeKind::MacroExpansion {
3300 expansion_kind,
3301 is_verified,
3302 } => json!({
3303 "expansion_kind": expansion_kind,
3304 "is_verified": is_verified,
3305 }),
3306 UnifiedEdgeKind::FfiCall { convention } => json!({
3307 "convention": convention,
3308 }),
3309 UnifiedEdgeKind::HttpRequest { method, url } => json!({
3310 "method": method,
3311 "url": url.and_then(|id| resolve_string_id(snapshot, id)),
3312 }),
3313 UnifiedEdgeKind::GrpcCall { service, method } => json!({
3314 "service": resolve_string_id(snapshot, *service),
3315 "method": resolve_string_id(snapshot, *method),
3316 }),
3317 UnifiedEdgeKind::DbQuery { query_type, table } => json!({
3318 "query_type": query_type,
3319 "table": table.and_then(|id| resolve_string_id(snapshot, id)),
3320 }),
3321 UnifiedEdgeKind::TableRead { table_name, schema } => json!({
3322 "table_name": resolve_string_id(snapshot, *table_name),
3323 "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3324 }),
3325 UnifiedEdgeKind::TableWrite {
3326 table_name,
3327 schema,
3328 operation,
3329 } => json!({
3330 "table_name": resolve_string_id(snapshot, *table_name),
3331 "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3332 "operation": operation,
3333 }),
3334 UnifiedEdgeKind::TriggeredBy {
3335 trigger_name,
3336 schema,
3337 } => json!({
3338 "trigger_name": resolve_string_id(snapshot, *trigger_name),
3339 "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3340 }),
3341 UnifiedEdgeKind::MessageQueue { protocol, topic } => {
3342 let protocol_value = match protocol {
3343 MqProtocol::Kafka => Some("kafka".to_string()),
3344 MqProtocol::Sqs => Some("sqs".to_string()),
3345 MqProtocol::RabbitMq => Some("rabbit_mq".to_string()),
3346 MqProtocol::Nats => Some("nats".to_string()),
3347 MqProtocol::Redis => Some("redis".to_string()),
3348 MqProtocol::Other(id) => resolve_string_id(snapshot, *id),
3349 };
3350 json!({
3351 "protocol": protocol_value,
3352 "topic": topic.and_then(|id| resolve_string_id(snapshot, id)),
3353 })
3354 }
3355 UnifiedEdgeKind::WebSocket { event } => json!({
3356 "event": event.and_then(|id| resolve_string_id(snapshot, id)),
3357 }),
3358 UnifiedEdgeKind::GraphQLOperation { operation } => json!({
3359 "operation": resolve_string_id(snapshot, *operation),
3360 }),
3361 UnifiedEdgeKind::ProcessExec { command } => json!({
3362 "command": resolve_string_id(snapshot, *command),
3363 }),
3364 UnifiedEdgeKind::FileIpc { path_pattern } => json!({
3365 "path_pattern": path_pattern.and_then(|id| resolve_string_id(snapshot, id)),
3366 }),
3367 UnifiedEdgeKind::ProtocolCall { protocol, metadata } => json!({
3368 "protocol": resolve_string_id(snapshot, *protocol),
3369 "metadata": metadata.and_then(|id| resolve_string_id(snapshot, id)),
3370 }),
3371 }
3372}
3373
3374fn print_edge_metadata_text(snapshot: &UnifiedGraphSnapshot, kind: &UnifiedEdgeKind) {
3375 let metadata = edge_metadata_json(snapshot, kind);
3376 let Some(map) = metadata.as_object() else {
3377 return;
3378 };
3379 if map.is_empty() {
3380 return;
3381 }
3382 if let Ok(serialized) = serde_json::to_string(map) {
3383 println!(" Metadata: {serialized}");
3384 }
3385}
3386
3387fn parse_node_kind_filter(kinds: Option<&str>) -> Result<HashSet<UnifiedNodeKind>> {
3388 let mut filter = HashSet::new();
3389 let Some(kinds) = kinds else {
3390 return Ok(filter);
3391 };
3392 for raw in kinds.split(',') {
3393 let trimmed = raw.trim();
3394 if trimmed.is_empty() {
3395 continue;
3396 }
3397 let normalized = trimmed.to_ascii_lowercase();
3398 let Some(kind) = UnifiedNodeKind::parse(&normalized) else {
3399 return Err(anyhow::anyhow!(
3400 "Unknown node kind: {trimmed}. Valid kinds: {}",
3401 VALID_NODE_KIND_NAMES.join(", ")
3402 ));
3403 };
3404 filter.insert(kind);
3405 }
3406 Ok(filter)
3407}
3408
3409fn parse_edge_kind_filter(kinds: Option<&str>) -> Result<HashSet<String>> {
3410 let mut filter = HashSet::new();
3411 let Some(kinds) = kinds else {
3412 return Ok(filter);
3413 };
3414 for raw in kinds.split(',') {
3415 let trimmed = raw.trim();
3416 if trimmed.is_empty() {
3417 continue;
3418 }
3419 let normalized = trimmed.to_ascii_lowercase();
3420 if !VALID_EDGE_KIND_TAGS.contains(&normalized.as_str()) {
3421 return Err(anyhow::anyhow!(
3422 "Unknown edge kind: {trimmed}. Valid kinds: {}",
3423 VALID_EDGE_KIND_TAGS.join(", ")
3424 ));
3425 }
3426 filter.insert(normalized);
3427 }
3428 Ok(filter)
3429}
3430
3431fn display_languages(languages: &HashSet<Language>) -> String {
3432 let mut items: Vec<Language> = languages.iter().copied().collect();
3433 items.sort();
3434 items
3435 .into_iter()
3436 .map(|lang| lang.to_string())
3437 .collect::<Vec<_>>()
3438 .join(", ")
3439}
3440
3441fn parse_language_filter(languages: Option<&str>) -> Result<Vec<Language>> {
3442 if let Some(langs) = languages {
3443 langs.split(',').map(|s| parse_language(s.trim())).collect()
3444 } else {
3445 Ok(Vec::new())
3446 }
3447}
3448
3449fn parse_language(s: &str) -> Result<Language> {
3450 match s.to_lowercase().as_str() {
3451 "javascript" | "js" => Ok(Language::JavaScript),
3453 "typescript" | "ts" => Ok(Language::TypeScript),
3454 "python" | "py" => Ok(Language::Python),
3455 "cpp" | "c++" | "cxx" => Ok(Language::Cpp),
3456 "rust" | "rs" => Ok(Language::Rust),
3458 "go" => Ok(Language::Go),
3459 "java" => Ok(Language::Java),
3460 "c" => Ok(Language::C),
3461 "csharp" | "cs" => Ok(Language::CSharp),
3462 "ruby" => Ok(Language::Ruby),
3464 "php" => Ok(Language::Php),
3465 "swift" => Ok(Language::Swift),
3466 "kotlin" => Ok(Language::Kotlin),
3468 "scala" => Ok(Language::Scala),
3469 "sql" => Ok(Language::Sql),
3470 "dart" => Ok(Language::Dart),
3471 "lua" => Ok(Language::Lua),
3473 "perl" => Ok(Language::Perl),
3474 "shell" | "bash" => Ok(Language::Shell),
3475 "groovy" => Ok(Language::Groovy),
3476 "elixir" | "ex" => Ok(Language::Elixir),
3478 "r" => Ok(Language::R),
3479 "haskell" | "hs" => Ok(Language::Haskell),
3481 "svelte" => Ok(Language::Svelte),
3482 "vue" => Ok(Language::Vue),
3483 "zig" => Ok(Language::Zig),
3484 "http" => Ok(Language::Http),
3486 _ => bail!("Unknown language: {s}"),
3487 }
3488}
3489
3490struct DirectCallOptions<'a> {
3494 symbol: &'a str,
3496 limit: usize,
3498 languages: Option<&'a str>,
3500 full_paths: bool,
3502 format: &'a str,
3504 verbose: bool,
3506}
3507
3508fn direct_call_row(
3510 snapshot: &UnifiedGraphSnapshot,
3511 root: &Path,
3512 node_id: sqry_core::graph::unified::node::NodeId,
3513 full_paths: bool,
3514) -> Option<serde_json::Value> {
3515 use serde_json::json;
3516 let entry = snapshot.nodes().get(node_id)?;
3517 let strings = snapshot.strings();
3518 let files = snapshot.files();
3519 let name = strings.resolve(entry.name).unwrap_or_default().to_string();
3520 let qualified_name = entry
3521 .qualified_name
3522 .and_then(|id| strings.resolve(id))
3523 .map_or_else(|| name.clone(), |s| s.to_string());
3524 let language = files
3525 .language_for_file(entry.file)
3526 .map_or_else(|| "unknown".to_string(), |l| l.to_string());
3527 let file_path = files
3528 .resolve(entry.file)
3529 .map(|p| {
3530 if full_paths {
3531 p.display().to_string()
3532 } else {
3533 p.strip_prefix(root)
3534 .unwrap_or(p.as_ref())
3535 .display()
3536 .to_string()
3537 }
3538 })
3539 .unwrap_or_default();
3540 Some(json!({
3541 "name": name,
3542 "qualified_name": qualified_name,
3543 "kind": format!("{:?}", entry.kind),
3544 "file": file_path,
3545 "line": entry.start_line,
3546 "language": language,
3547 }))
3548}
3549
3550fn emit_direct_call_output(
3552 symbol: &str,
3553 key: &'static str,
3554 label_noun: &'static str,
3555 rows: &[serde_json::Value],
3556 limit: usize,
3557 format: &str,
3558) -> Result<()> {
3559 use serde_json::json;
3560 if format == "json" {
3561 let output = json!({
3562 "symbol": symbol,
3563 key: rows,
3564 "total": rows.len(),
3565 "truncated": rows.len() >= limit,
3566 });
3567 println!("{}", serde_json::to_string_pretty(&output)?);
3568 } else {
3569 println!("{label_noun}s of '{symbol}':");
3570 println!();
3571 if rows.is_empty() {
3572 println!(" (no {label_noun}s found)");
3573 } else {
3574 for row in rows {
3575 let name = row["qualified_name"].as_str().unwrap_or("");
3576 let file = row["file"].as_str().unwrap_or("");
3577 let line = row["line"].as_u64().unwrap_or(0);
3578 println!(" {name} ({file}:{line})");
3579 }
3580 println!();
3581 println!("Total: {total} {label_noun}(s)", total = rows.len());
3582 }
3583 }
3584 Ok(())
3585}
3586
3587fn run_direct_callers_unified(
3623 graph: &UnifiedCodeGraph,
3624 root: &Path,
3625 options: &DirectCallOptions<'_>,
3626) -> Result<()> {
3627 let snapshot = std::sync::Arc::new(graph.snapshot());
3628 let files = snapshot.files();
3629
3630 let language_filter = parse_language_filter(options.languages)?
3631 .into_iter()
3632 .collect::<HashSet<_>>();
3633
3634 let target_nodes = find_nodes_by_name(&snapshot, options.symbol);
3638 if target_nodes.is_empty() {
3639 bail!(
3640 "Symbol '{symbol}' not found in the graph",
3641 symbol = options.symbol
3642 );
3643 }
3644
3645 if options.verbose {
3646 eprintln!(
3647 "Found {count} node(s) matching symbol '{symbol}'",
3648 count = target_nodes.len(),
3649 symbol = options.symbol
3650 );
3651 }
3652
3653 let db = sqry_db::queries::dispatch::make_query_db_cold(std::sync::Arc::clone(&snapshot), root);
3657 let key = sqry_db::queries::RelationKey::exact(options.symbol);
3658 let caller_ids = sqry_db::queries::dispatch::mcp_callers_query(&db, &key);
3659
3660 let mut rows = Vec::new();
3662 for &caller_id in caller_ids.iter() {
3663 if rows.len() >= options.limit {
3664 break;
3665 }
3666 let Some(entry) = snapshot.nodes().get(caller_id) else {
3667 continue;
3668 };
3669 if !language_filter.is_empty()
3670 && let Some(lang) = files.language_for_file(entry.file)
3671 && !language_filter.contains(&lang)
3672 {
3673 continue;
3674 }
3675 if let Some(row) = direct_call_row(&snapshot, root, caller_id, options.full_paths) {
3676 rows.push(row);
3677 }
3678 }
3679
3680 emit_direct_call_output(
3681 options.symbol,
3682 "callers",
3683 "caller",
3684 &rows,
3685 options.limit,
3686 options.format,
3687 )
3688}
3689
3690fn run_direct_callees_unified(
3716 graph: &UnifiedCodeGraph,
3717 root: &Path,
3718 options: &DirectCallOptions<'_>,
3719) -> Result<()> {
3720 let snapshot = std::sync::Arc::new(graph.snapshot());
3721 let files = snapshot.files();
3722
3723 let language_filter = parse_language_filter(options.languages)?
3724 .into_iter()
3725 .collect::<HashSet<_>>();
3726
3727 let source_nodes = find_nodes_by_name(&snapshot, options.symbol);
3729 if source_nodes.is_empty() {
3730 bail!(
3731 "Symbol '{symbol}' not found in the graph",
3732 symbol = options.symbol
3733 );
3734 }
3735
3736 if options.verbose {
3737 eprintln!(
3738 "Found {count} node(s) matching symbol '{symbol}'",
3739 count = source_nodes.len(),
3740 symbol = options.symbol
3741 );
3742 }
3743
3744 let db = sqry_db::queries::dispatch::make_query_db_cold(std::sync::Arc::clone(&snapshot), root);
3748 let key = sqry_db::queries::RelationKey::exact(options.symbol);
3749 let callee_ids = sqry_db::queries::dispatch::mcp_callees_query(&db, &key);
3750
3751 let mut rows = Vec::new();
3753 for &callee_id in callee_ids.iter() {
3754 if rows.len() >= options.limit {
3755 break;
3756 }
3757 let Some(entry) = snapshot.nodes().get(callee_id) else {
3758 continue;
3759 };
3760 if !language_filter.is_empty()
3761 && let Some(lang) = files.language_for_file(entry.file)
3762 && !language_filter.contains(&lang)
3763 {
3764 continue;
3765 }
3766 if let Some(row) = direct_call_row(&snapshot, root, callee_id, options.full_paths) {
3767 rows.push(row);
3768 }
3769 }
3770
3771 emit_direct_call_output(
3772 options.symbol,
3773 "callees",
3774 "callee",
3775 &rows,
3776 options.limit,
3777 options.format,
3778 )
3779}
3780
3781struct CallHierarchyOptions<'a> {
3785 symbol: &'a str,
3787 max_depth: usize,
3789 direction: &'a str,
3791 languages: Option<&'a str>,
3793 full_paths: bool,
3795 format: &'a str,
3797 verbose: bool,
3799}
3800
3801fn run_call_hierarchy_unified(
3803 graph: &UnifiedCodeGraph,
3804 root: &Path,
3805 options: &CallHierarchyOptions<'_>,
3806) -> Result<()> {
3807 use serde_json::json;
3808
3809 let snapshot = graph.snapshot();
3810
3811 let language_filter = parse_language_filter(options.languages)?
3812 .into_iter()
3813 .collect::<HashSet<_>>();
3814
3815 let start_nodes = find_nodes_by_name(&snapshot, options.symbol);
3817
3818 if start_nodes.is_empty() {
3819 bail!("Symbol '{}' not found in the graph", options.symbol);
3820 }
3821
3822 if options.verbose {
3823 eprintln!(
3824 "Found {} node(s) matching symbol '{}' (direction={})",
3825 start_nodes.len(),
3826 options.symbol,
3827 options.direction
3828 );
3829 }
3830
3831 let include_incoming = options.direction == "incoming" || options.direction == "both";
3832 let include_outgoing = options.direction == "outgoing" || options.direction == "both";
3833
3834 let mut result = json!({
3835 "symbol": options.symbol,
3836 "direction": options.direction,
3837 "max_depth": options.max_depth
3838 });
3839
3840 if include_incoming {
3842 let incoming = build_call_hierarchy_tree(
3843 &snapshot,
3844 &start_nodes,
3845 options.max_depth,
3846 true, &language_filter,
3848 root,
3849 options.full_paths,
3850 );
3851 result["incoming"] = incoming;
3852 }
3853
3854 if include_outgoing {
3856 let outgoing = build_call_hierarchy_tree(
3857 &snapshot,
3858 &start_nodes,
3859 options.max_depth,
3860 false, &language_filter,
3862 root,
3863 options.full_paths,
3864 );
3865 result["outgoing"] = outgoing;
3866 }
3867
3868 if options.format == "json" {
3869 println!("{}", serde_json::to_string_pretty(&result)?);
3870 } else {
3871 println!("Call hierarchy for '{symbol}':", symbol = options.symbol);
3872 println!();
3873
3874 if include_incoming {
3875 println!("Incoming calls (callers):");
3876 if let Some(incoming) = result["incoming"].as_array() {
3877 print_hierarchy_text(incoming, 1);
3878 }
3879 println!();
3880 }
3881
3882 if include_outgoing {
3883 println!("Outgoing calls (callees):");
3884 if let Some(outgoing) = result["outgoing"].as_array() {
3885 print_hierarchy_text(outgoing, 1);
3886 }
3887 }
3888 }
3889
3890 Ok(())
3891}
3892
3893#[allow(clippy::items_after_statements, clippy::too_many_lines)]
3895fn build_call_hierarchy_tree(
3896 snapshot: &UnifiedGraphSnapshot,
3897 start_nodes: &[sqry_core::graph::unified::node::NodeId],
3898 max_depth: usize,
3899 incoming: bool,
3900 language_filter: &HashSet<Language>,
3901 root: &Path,
3902 full_paths: bool,
3903) -> serde_json::Value {
3904 use serde_json::json;
3905 use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
3906
3907 let _strings = snapshot.strings();
3908 let _files = snapshot.files();
3909
3910 let mut result = Vec::new();
3911 let mut visited = HashSet::new();
3912
3913 struct TraversalConfig<'a> {
3915 max_depth: usize,
3916 incoming: bool,
3917 language_filter: &'a HashSet<Language>,
3918 root: &'a Path,
3919 full_paths: bool,
3920 }
3921
3922 fn traverse(
3923 snapshot: &UnifiedGraphSnapshot,
3924 node_id: UnifiedNodeId,
3925 depth: usize,
3926 config: &TraversalConfig<'_>,
3927 visited: &mut HashSet<UnifiedNodeId>,
3928 ) -> serde_json::Value {
3929 let strings = snapshot.strings();
3930 let files = snapshot.files();
3931
3932 let Some(entry) = snapshot.nodes().get(node_id) else {
3933 return json!(null);
3934 };
3935
3936 let name = strings.resolve(entry.name).unwrap_or_default().to_string();
3937 let qualified_name = entry
3938 .qualified_name
3939 .and_then(|id| strings.resolve(id))
3940 .map_or_else(|| name.clone(), |s| s.to_string());
3941 let language = files
3942 .language_for_file(entry.file)
3943 .map_or_else(|| "unknown".to_string(), |l| l.to_string());
3944 let file_path = files
3945 .resolve(entry.file)
3946 .map(|p| {
3947 if config.full_paths {
3948 p.display().to_string()
3949 } else {
3950 p.strip_prefix(config.root)
3951 .unwrap_or(p.as_ref())
3952 .display()
3953 .to_string()
3954 }
3955 })
3956 .unwrap_or_default();
3957
3958 let mut node_json = json!({
3959 "name": name,
3960 "qualified_name": qualified_name,
3961 "kind": format!("{:?}", entry.kind),
3962 "file": file_path,
3963 "line": entry.start_line,
3964 "language": language
3965 });
3966
3967 if depth < config.max_depth && !visited.contains(&node_id) {
3969 visited.insert(node_id);
3970
3971 let mut children = Vec::new();
3972 let edges = if config.incoming {
3973 snapshot.edges().reverse().edges_from(node_id)
3974 } else {
3975 snapshot.edges().edges_from(node_id)
3976 };
3977
3978 for edge_ref in edges {
3979 if !matches!(edge_ref.kind, UnifiedEdgeKind::Calls { .. }) {
3980 continue;
3981 }
3982
3983 let related_id = edge_ref.target;
3984
3985 if !config.language_filter.is_empty()
3987 && let Some(related_entry) = snapshot.nodes().get(related_id)
3988 && let Some(lang) = files.language_for_file(related_entry.file)
3989 && !config.language_filter.contains(&lang)
3990 {
3991 continue;
3992 }
3993
3994 let child = traverse(snapshot, related_id, depth + 1, config, visited);
3995
3996 if !child.is_null() {
3997 children.push(child);
3998 }
3999 }
4000
4001 if !children.is_empty() {
4002 node_json["children"] = json!(children);
4003 }
4004 }
4005
4006 node_json
4007 }
4008
4009 let config = TraversalConfig {
4010 max_depth,
4011 incoming,
4012 language_filter,
4013 root,
4014 full_paths,
4015 };
4016
4017 for &node_id in start_nodes {
4018 let tree = traverse(snapshot, node_id, 0, &config, &mut visited);
4019 if !tree.is_null() {
4020 result.push(tree);
4021 }
4022 }
4023
4024 json!(result)
4025}
4026
4027fn print_hierarchy_text(nodes: &[serde_json::Value], indent: usize) {
4029 let prefix = " ".repeat(indent);
4030 for node in nodes {
4031 let name = node["qualified_name"].as_str().unwrap_or("?");
4032 let file = node["file"].as_str().unwrap_or("?");
4033 let line = node["line"].as_u64().unwrap_or(0);
4034 println!("{prefix}{name} ({file}:{line})");
4035
4036 if let Some(children) = node["children"].as_array() {
4037 print_hierarchy_text(children, indent + 1);
4038 }
4039 }
4040}
4041
4042fn run_is_in_cycle_unified(
4069 graph: &UnifiedCodeGraph,
4070 root: &Path,
4071 symbol: &str,
4072 cycle_type: &str,
4073 show_cycle: bool,
4074 format: &str,
4075 verbose: bool,
4076) -> Result<()> {
4077 use serde_json::json;
4078 use sqry_core::graph::unified::{
4079 FileScope, ResolutionMode, SymbolQuery, SymbolResolutionOutcome,
4080 };
4081 use sqry_core::query::CircularType;
4082 use std::sync::Arc;
4083
4084 let cycle_types: Vec<CircularType> = if cycle_type.eq_ignore_ascii_case("all") {
4091 vec![CircularType::Calls, CircularType::Imports]
4092 } else {
4093 let parsed = CircularType::try_parse(cycle_type).with_context(|| {
4094 format!("Invalid cycle type: {cycle_type}. Use: calls, imports, modules, all")
4095 })?;
4096 vec![parsed]
4097 };
4098
4099 let snapshot = Arc::new(graph.snapshot());
4100
4101 let target_id = match snapshot.resolve_symbol(&SymbolQuery {
4105 symbol,
4106 file_scope: FileScope::Any,
4107 mode: ResolutionMode::Strict,
4108 }) {
4109 SymbolResolutionOutcome::Resolved(node_id) => node_id,
4110 SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
4111 bail!("Symbol '{symbol}' not found in the graph");
4112 }
4113 SymbolResolutionOutcome::Ambiguous(candidates) => {
4114 bail!(
4115 "Symbol '{symbol}' is ambiguous ({} candidates). Use a canonical qualified name.",
4116 candidates.len()
4117 );
4118 }
4119 };
4120
4121 if verbose {
4122 eprintln!(
4123 "Checking if symbol '{}' ({:?}) is in a {} cycle",
4124 symbol, target_id, cycle_type
4125 );
4126 }
4127
4128 let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), root);
4141 let predicate_bounds = sqry_db::queries::CycleBounds {
4142 min_depth: 2,
4143 max_depth: None,
4144 max_results: 100,
4145 should_include_self_loops: false,
4146 };
4147 let mut in_cycle = false;
4148 let mut found_cycles: Vec<serde_json::Value> = Vec::new();
4149 for &ct in &cycle_types {
4150 if db.get::<sqry_db::queries::IsInCycleQuery>(&sqry_db::queries::IsInCycleKey {
4151 node_id: target_id,
4152 circular_type: ct,
4153 bounds: predicate_bounds,
4154 }) {
4155 in_cycle = true;
4156 if show_cycle {
4157 let cycle_lookup_bounds = sqry_db::queries::CycleBounds {
4158 min_depth: 2,
4159 max_depth: None,
4160 max_results: usize::MAX,
4161 should_include_self_loops: false,
4162 };
4163 let all_cycles =
4164 db.get::<sqry_db::queries::CyclesQuery>(&sqry_db::queries::CyclesKey {
4165 circular_type: ct,
4166 bounds: cycle_lookup_bounds,
4167 });
4168 if let Some(component) = all_cycles
4169 .iter()
4170 .find(|component| component.contains(&target_id))
4171 {
4172 let strings = snapshot.strings();
4173 let cycle_names: Vec<String> = component
4174 .iter()
4175 .filter_map(|&node_id| {
4176 snapshot.get_node(node_id).and_then(|entry| {
4177 entry
4178 .qualified_name
4179 .and_then(|id| strings.resolve(id))
4180 .or_else(|| strings.resolve(entry.name))
4181 .map(|s| s.to_string())
4182 })
4183 })
4184 .collect();
4185 found_cycles.push(json!({
4186 "node": format!("{target_id:?}"),
4187 "cycle": cycle_names
4188 }));
4189 }
4190 }
4191 }
4192 }
4193
4194 if format == "json" {
4195 let output = if show_cycle {
4196 json!({
4197 "symbol": symbol,
4198 "in_cycle": in_cycle,
4199 "cycle_type": cycle_type,
4200 "cycles": found_cycles
4201 })
4202 } else {
4203 json!({
4204 "symbol": symbol,
4205 "in_cycle": in_cycle,
4206 "cycle_type": cycle_type
4207 })
4208 };
4209 println!("{}", serde_json::to_string_pretty(&output)?);
4210 } else if in_cycle {
4211 println!("Symbol '{symbol}' IS in a {cycle_type} cycle.");
4212 if show_cycle {
4213 for (i, cycle) in found_cycles.iter().enumerate() {
4214 println!();
4215 println!("Cycle {}:", i + 1);
4216 if let Some(names) = cycle["cycle"].as_array() {
4217 for (j, name) in names.iter().enumerate() {
4218 let prefix = if j == 0 { " " } else { " → " };
4219 println!("{prefix}{name}", name = name.as_str().unwrap_or("?"));
4220 }
4221 if let Some(first) = names.first() {
4223 println!(" → {} (cycle)", first.as_str().unwrap_or("?"));
4224 }
4225 }
4226 }
4227 }
4228 } else {
4229 println!("Symbol '{symbol}' is NOT in any {cycle_type} cycle.");
4230 }
4231
4232 Ok(())
4233}
4234
4235#[cfg(test)]
4236mod tests {
4237 use super::*;
4238
4239 #[test]
4244 fn test_parse_language_javascript_variants() {
4245 assert_eq!(parse_language("javascript").unwrap(), Language::JavaScript);
4246 assert_eq!(parse_language("js").unwrap(), Language::JavaScript);
4247 assert_eq!(parse_language("JavaScript").unwrap(), Language::JavaScript);
4248 assert_eq!(parse_language("JS").unwrap(), Language::JavaScript);
4249 }
4250
4251 #[test]
4252 fn test_parse_language_typescript_variants() {
4253 assert_eq!(parse_language("typescript").unwrap(), Language::TypeScript);
4254 assert_eq!(parse_language("ts").unwrap(), Language::TypeScript);
4255 assert_eq!(parse_language("TypeScript").unwrap(), Language::TypeScript);
4256 }
4257
4258 #[test]
4259 fn test_parse_language_python_variants() {
4260 assert_eq!(parse_language("python").unwrap(), Language::Python);
4261 assert_eq!(parse_language("py").unwrap(), Language::Python);
4262 assert_eq!(parse_language("PYTHON").unwrap(), Language::Python);
4263 }
4264
4265 #[test]
4266 fn test_parse_language_cpp_variants() {
4267 assert_eq!(parse_language("cpp").unwrap(), Language::Cpp);
4268 assert_eq!(parse_language("c++").unwrap(), Language::Cpp);
4269 assert_eq!(parse_language("cxx").unwrap(), Language::Cpp);
4270 assert_eq!(parse_language("CPP").unwrap(), Language::Cpp);
4271 }
4272
4273 #[test]
4274 fn test_parse_language_rust_variants() {
4275 assert_eq!(parse_language("rust").unwrap(), Language::Rust);
4276 assert_eq!(parse_language("rs").unwrap(), Language::Rust);
4277 }
4278
4279 #[test]
4280 fn test_parse_language_go() {
4281 assert_eq!(parse_language("go").unwrap(), Language::Go);
4282 assert_eq!(parse_language("Go").unwrap(), Language::Go);
4283 }
4284
4285 #[test]
4286 fn test_parse_language_java() {
4287 assert_eq!(parse_language("java").unwrap(), Language::Java);
4288 }
4289
4290 #[test]
4291 fn test_parse_language_c() {
4292 assert_eq!(parse_language("c").unwrap(), Language::C);
4293 assert_eq!(parse_language("C").unwrap(), Language::C);
4294 }
4295
4296 #[test]
4297 fn test_parse_language_csharp_variants() {
4298 assert_eq!(parse_language("csharp").unwrap(), Language::CSharp);
4299 assert_eq!(parse_language("cs").unwrap(), Language::CSharp);
4300 assert_eq!(parse_language("CSharp").unwrap(), Language::CSharp);
4301 }
4302
4303 #[test]
4304 fn test_parse_language_ruby() {
4305 assert_eq!(parse_language("ruby").unwrap(), Language::Ruby);
4306 }
4307
4308 #[test]
4309 fn test_parse_language_php() {
4310 assert_eq!(parse_language("php").unwrap(), Language::Php);
4311 }
4312
4313 #[test]
4314 fn test_parse_language_swift() {
4315 assert_eq!(parse_language("swift").unwrap(), Language::Swift);
4316 }
4317
4318 #[test]
4319 fn test_parse_language_kotlin() {
4320 assert_eq!(parse_language("kotlin").unwrap(), Language::Kotlin);
4321 }
4322
4323 #[test]
4324 fn test_parse_language_scala() {
4325 assert_eq!(parse_language("scala").unwrap(), Language::Scala);
4326 }
4327
4328 #[test]
4329 fn test_parse_language_sql() {
4330 assert_eq!(parse_language("sql").unwrap(), Language::Sql);
4331 }
4332
4333 #[test]
4334 fn test_parse_language_dart() {
4335 assert_eq!(parse_language("dart").unwrap(), Language::Dart);
4336 }
4337
4338 #[test]
4339 fn test_parse_language_lua() {
4340 assert_eq!(parse_language("lua").unwrap(), Language::Lua);
4341 }
4342
4343 #[test]
4344 fn test_parse_language_perl() {
4345 assert_eq!(parse_language("perl").unwrap(), Language::Perl);
4346 }
4347
4348 #[test]
4349 fn test_parse_language_shell_variants() {
4350 assert_eq!(parse_language("shell").unwrap(), Language::Shell);
4351 assert_eq!(parse_language("bash").unwrap(), Language::Shell);
4352 }
4353
4354 #[test]
4355 fn test_parse_language_groovy() {
4356 assert_eq!(parse_language("groovy").unwrap(), Language::Groovy);
4357 }
4358
4359 #[test]
4360 fn test_parse_language_elixir_variants() {
4361 assert_eq!(parse_language("elixir").unwrap(), Language::Elixir);
4362 assert_eq!(parse_language("ex").unwrap(), Language::Elixir);
4363 }
4364
4365 #[test]
4366 fn test_parse_language_r() {
4367 assert_eq!(parse_language("r").unwrap(), Language::R);
4368 assert_eq!(parse_language("R").unwrap(), Language::R);
4369 }
4370
4371 #[test]
4372 fn test_parse_language_haskell_variants() {
4373 assert_eq!(parse_language("haskell").unwrap(), Language::Haskell);
4374 assert_eq!(parse_language("hs").unwrap(), Language::Haskell);
4375 }
4376
4377 #[test]
4378 fn test_parse_language_svelte() {
4379 assert_eq!(parse_language("svelte").unwrap(), Language::Svelte);
4380 }
4381
4382 #[test]
4383 fn test_parse_language_vue() {
4384 assert_eq!(parse_language("vue").unwrap(), Language::Vue);
4385 }
4386
4387 #[test]
4388 fn test_parse_language_zig() {
4389 assert_eq!(parse_language("zig").unwrap(), Language::Zig);
4390 }
4391
4392 #[test]
4393 fn test_parse_language_http() {
4394 assert_eq!(parse_language("http").unwrap(), Language::Http);
4395 }
4396
4397 #[test]
4398 fn test_parse_language_unknown() {
4399 let result = parse_language("unknown_language");
4400 assert!(result.is_err());
4401 assert!(result.unwrap_err().to_string().contains("Unknown language"));
4402 }
4403
4404 #[test]
4409 fn test_parse_language_filter_none() {
4410 let result = parse_language_filter(None).unwrap();
4411 assert!(result.is_empty());
4412 }
4413
4414 #[test]
4415 fn test_parse_language_filter_single() {
4416 let result = parse_language_filter(Some("rust")).unwrap();
4417 assert_eq!(result.len(), 1);
4418 assert_eq!(result[0], Language::Rust);
4419 }
4420
4421 #[test]
4422 fn test_parse_language_filter_multiple() {
4423 let result = parse_language_filter(Some("rust,python,go")).unwrap();
4424 assert_eq!(result.len(), 3);
4425 assert!(result.contains(&Language::Rust));
4426 assert!(result.contains(&Language::Python));
4427 assert!(result.contains(&Language::Go));
4428 }
4429
4430 #[test]
4431 fn test_parse_language_filter_with_spaces() {
4432 let result = parse_language_filter(Some("rust , python , go")).unwrap();
4433 assert_eq!(result.len(), 3);
4434 }
4435
4436 #[test]
4437 fn test_parse_language_filter_with_aliases() {
4438 let result = parse_language_filter(Some("js,ts,py")).unwrap();
4439 assert_eq!(result.len(), 3);
4440 assert!(result.contains(&Language::JavaScript));
4441 assert!(result.contains(&Language::TypeScript));
4442 assert!(result.contains(&Language::Python));
4443 }
4444
4445 #[test]
4446 fn test_parse_language_filter_invalid() {
4447 let result = parse_language_filter(Some("rust,invalid,python"));
4448 assert!(result.is_err());
4449 }
4450
4451 #[test]
4456 fn test_parse_language_filter_unified_none() {
4457 let result = parse_language_filter_unified(None);
4458 assert!(result.is_empty());
4459 }
4460
4461 #[test]
4462 fn test_parse_language_filter_unified_single() {
4463 let result = parse_language_filter_unified(Some("rust"));
4464 assert_eq!(result.len(), 1);
4465 assert_eq!(result[0], "rust");
4466 }
4467
4468 #[test]
4469 fn test_parse_language_filter_unified_multiple() {
4470 let result = parse_language_filter_unified(Some("rust,python,go"));
4471 assert_eq!(result.len(), 3);
4472 assert!(result.contains(&"rust".to_string()));
4473 assert!(result.contains(&"python".to_string()));
4474 assert!(result.contains(&"go".to_string()));
4475 }
4476
4477 #[test]
4478 fn test_parse_language_filter_unified_with_spaces() {
4479 let result = parse_language_filter_unified(Some(" rust , python "));
4480 assert_eq!(result.len(), 2);
4481 assert!(result.contains(&"rust".to_string()));
4482 assert!(result.contains(&"python".to_string()));
4483 }
4484
4485 #[test]
4490 fn test_parse_language_filter_for_complexity_none() {
4491 let result = parse_language_filter_for_complexity(None).unwrap();
4492 assert!(result.is_empty());
4493 }
4494
4495 #[test]
4496 fn test_parse_language_filter_for_complexity_single() {
4497 let result = parse_language_filter_for_complexity(Some("rust")).unwrap();
4498 assert_eq!(result.len(), 1);
4499 assert_eq!(result[0], Language::Rust);
4500 }
4501
4502 #[test]
4503 fn test_parse_language_filter_for_complexity_multiple() {
4504 let result = parse_language_filter_for_complexity(Some("rust,python")).unwrap();
4505 assert_eq!(result.len(), 2);
4506 }
4507
4508 #[test]
4513 fn test_display_languages_empty() {
4514 let languages: HashSet<Language> = HashSet::new();
4515 assert_eq!(display_languages(&languages), "");
4516 }
4517
4518 #[test]
4519 fn test_display_languages_single() {
4520 let mut languages = HashSet::new();
4521 languages.insert(Language::Rust);
4522 let result = display_languages(&languages);
4523 assert_eq!(result, "rust");
4524 }
4525
4526 #[test]
4527 fn test_display_languages_multiple() {
4528 let mut languages = HashSet::new();
4529 languages.insert(Language::Rust);
4530 languages.insert(Language::Python);
4531 let result = display_languages(&languages);
4532 assert!(result.contains("py"));
4534 assert!(result.contains("rust"));
4535 assert!(result.contains(", "));
4536 }
4537
4538 #[test]
4543 fn test_edge_kind_matches_unified_calls() {
4544 let kind = UnifiedEdgeKind::Calls {
4545 argument_count: 2,
4546 is_async: false,
4547 };
4548 assert!(edge_kind_matches_unified(&kind, "calls"));
4549 assert!(edge_kind_matches_unified(&kind, "Calls"));
4550 assert!(edge_kind_matches_unified(&kind, "CALLS"));
4551 }
4552
4553 #[test]
4554 fn test_edge_kind_matches_unified_imports() {
4555 let kind = UnifiedEdgeKind::Imports {
4556 alias: None,
4557 is_wildcard: false,
4558 };
4559 assert!(edge_kind_matches_unified(&kind, "imports"));
4560 assert!(edge_kind_matches_unified(&kind, "import"));
4561 }
4562
4563 #[test]
4564 fn test_edge_kind_matches_unified_no_match() {
4565 let kind = UnifiedEdgeKind::Calls {
4566 argument_count: 0,
4567 is_async: false,
4568 };
4569 assert!(!edge_kind_matches_unified(&kind, "imports"));
4570 assert!(!edge_kind_matches_unified(&kind, "exports"));
4571 }
4572
4573 #[test]
4574 fn test_edge_kind_matches_unified_partial() {
4575 let kind = UnifiedEdgeKind::Calls {
4576 argument_count: 1,
4577 is_async: true,
4578 };
4579 assert!(edge_kind_matches_unified(&kind, "async"));
4581 }
4582
4583 #[test]
4588 fn test_parse_node_kind_filter_none() {
4589 let result = parse_node_kind_filter(None).unwrap();
4590 assert!(result.is_empty());
4591 }
4592
4593 #[test]
4594 fn test_parse_node_kind_filter_valid() {
4595 let result = parse_node_kind_filter(Some("Function,macro,call_site")).unwrap();
4596 assert_eq!(result.len(), 3);
4597 assert!(result.contains(&UnifiedNodeKind::Function));
4598 assert!(result.contains(&UnifiedNodeKind::Macro));
4599 assert!(result.contains(&UnifiedNodeKind::CallSite));
4600 }
4601
4602 #[test]
4603 fn test_parse_node_kind_filter_invalid() {
4604 let result = parse_node_kind_filter(Some("function,unknown"));
4605 assert!(result.is_err());
4606 }
4607
4608 #[test]
4613 fn test_parse_edge_kind_filter_none() {
4614 let result = parse_edge_kind_filter(None).unwrap();
4615 assert!(result.is_empty());
4616 }
4617
4618 #[test]
4619 fn test_parse_edge_kind_filter_valid() {
4620 let result = parse_edge_kind_filter(Some("calls,table_read,HTTP_REQUEST")).unwrap();
4621 assert!(result.contains("calls"));
4622 assert!(result.contains("table_read"));
4623 assert!(result.contains("http_request"));
4624 }
4625
4626 #[test]
4627 fn test_parse_edge_kind_filter_invalid() {
4628 let result = parse_edge_kind_filter(Some("calls,unknown_edge"));
4629 assert!(result.is_err());
4630 }
4631
4632 #[test]
4637 fn test_normalize_graph_limit_default_on_zero() {
4638 assert_eq!(normalize_graph_limit(0), DEFAULT_GRAPH_LIST_LIMIT);
4639 }
4640
4641 #[test]
4642 fn test_normalize_graph_limit_clamps_max() {
4643 assert_eq!(
4644 normalize_graph_limit(MAX_GRAPH_LIST_LIMIT + 1),
4645 MAX_GRAPH_LIST_LIMIT
4646 );
4647 }
4648
4649 #[test]
4654 fn test_find_path_no_graph_returns_none() {
4655 use sqry_core::graph::unified::concurrent::CodeGraph;
4656 use sqry_core::graph::unified::node::NodeId;
4657
4658 let graph = CodeGraph::new();
4659 let snapshot = graph.snapshot();
4660 let starts = vec![NodeId::new(0, 0)];
4661 let targets: HashSet<NodeId> = [NodeId::new(1, 0)].into_iter().collect();
4662 let filter: HashSet<Language> = HashSet::new();
4663
4664 let path = find_path_unified_bfs(&snapshot, &starts, &targets, &filter);
4665 assert!(path.is_none(), "No path should exist in an empty graph");
4666 }
4667
4668 crate::large_stack_test! {
4673 #[test]
4674 fn test_build_graph_load_config_defaults() {
4675 use clap::Parser as _;
4676 let cli = crate::args::Cli::parse_from(["sqry"]);
4677 let config = build_graph_load_config(&cli);
4678
4679 assert!(!config.include_hidden);
4680 assert!(!config.follow_symlinks);
4681 assert_eq!(config.max_depth, Some(32));
4683 assert!(!config.force_build);
4684 }
4685 }
4686
4687 crate::large_stack_test! {
4688 #[test]
4689 fn test_build_graph_load_config_hidden_flag() {
4690 use clap::Parser as _;
4691 let cli = crate::args::Cli::parse_from(["sqry", "--hidden"]);
4692 let config = build_graph_load_config(&cli);
4693 assert!(config.include_hidden);
4694 }
4695 }
4696
4697 crate::large_stack_test! {
4698 #[test]
4699 fn test_build_graph_load_config_max_depth_nonzero() {
4700 use clap::Parser as _;
4701 let cli = crate::args::Cli::parse_from(["sqry", "--max-depth", "5"]);
4702 let config = build_graph_load_config(&cli);
4703 assert_eq!(config.max_depth, Some(5));
4704 }
4705 }
4706
4707 crate::large_stack_test! {
4708 #[test]
4709 fn test_build_graph_load_config_follow_symlinks() {
4710 use clap::Parser as _;
4711 let cli = crate::args::Cli::parse_from(["sqry", "--follow"]);
4712 let config = build_graph_load_config(&cli);
4713 assert!(config.follow_symlinks);
4714 }
4715 }
4716
4717 #[test]
4722 fn test_language_filter_strategy_empty_filter_allows_all() {
4723 use sqry_core::graph::unified::TraversalStrategy;
4725 use sqry_core::graph::unified::concurrent::CodeGraph;
4726 use sqry_core::graph::unified::edge::EdgeKind;
4727 use sqry_core::graph::unified::node::NodeId;
4728
4729 let graph = CodeGraph::new();
4730 let snapshot = graph.snapshot();
4731 let filter: HashSet<Language> = HashSet::new();
4732
4733 let mut strategy = LanguageFilterStrategy {
4734 snapshot: &snapshot,
4735 language_filter: &filter,
4736 };
4737
4738 let node = NodeId::new(0, 0);
4739 let from = NodeId::new(1, 0);
4740 let edge = EdgeKind::Calls {
4741 argument_count: 0,
4742 is_async: false,
4743 };
4744 assert!(
4745 strategy.should_enqueue(node, from, &edge, 1),
4746 "Empty language filter must vacuously match any node"
4747 );
4748 }
4749}