Skip to main content

sqry_cli/commands/
graph.rs

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