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