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