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