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