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