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