Skip to main content

sqry_cli/commands/
graph.rs

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