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