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