Skip to main content

sqry_cli/commands/
graph.rs

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