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